Tether
Know where your things are.
You lend something out and then it is gone. Nobody stole it — they just forgot whose it was, or passed it on to someone who never knew.
So every item gets a scannable tag, and lending is a scan between two phones. If the borrower passes it on, the chain grows and you still see the last hop.
The interesting problem is not the tags. It is that a physical object can be held by exactly one person, and two phones scanning the same tag at the same moment is the whole game.
No lock, no transaction, no read-modify-write — the lost update is not expressible.
README, on claiming custodyCustody is claimed with a single conditional update, so there is nothing to race.
- Compare and swapThe update sets the new holder only where the holder is still who I thought it was. One row changes or none does, and the code reads the count. There is no window between reading and writing, because there is no read.
- An append-only logCurrent holder, hop count and the full journey are folds over an event log rather than stored counters, so they cannot drift from each other.
- Idempotency as an indexDouble taps and retries are the normal case when somebody is standing in front of you holding a camera, so a replayed scan collides with a unique index instead of being reasoned about.
- A type that forbids the bugThe helper wrapping a critical section is typed so that an
awaitinside it is a compile error rather than a code-review note.
Realtime is server-sent events, not WebSockets, and the reasoning is written down: the traffic is one-directional, and an SSE response is an ordinary HTTP response, so it reuses the same router, the same auth check and the same CORS rules. A socket upgrade bypasses all three. The events carry no row data — only an id and a reason — so the client re-fetches through normal authorised endpoints and a bug in the stream cannot leak a row.
| Concern | Usual library | What is actually used |
|---|---|---|
| HTTP server | Express, Hono, Elysia | Bun.serve and a ~90-line router |
| Database | Prisma, Drizzle | bun:sqlite and raw SQL |
| Migrations | a migration tool | an array of statements and a table |
| Password hashing | bcrypt, argon2 | Bun.password, off the event loop |
| Google sign-in | an OAuth client | JWKS fetched and verified with WebCrypto |
| Realtime | socket.io | SSE with a replay buffer and resume |
The point was not minimalism for its own sake. It was that each of those is a small, well-specified problem, and owning them means the failure modes are mine to read rather than mine to guess at.
Built and demonstrated. Not deployed, and not tested the way I would want.
- No testsVerification was screenshotting every screen on a real emulator, which is where all of the real bugs were found — but it is not a suite, and I will not call it one.
- Runs locallyThere is no Dockerfile and no CI. It runs on a laptop and an emulator, against a SQLite file.
- Deliberately unwiredSMS, push, NFC writing and printing return not sent rather than pretending, because a fixture that looks delivered is worse than one that admits it is not.
- There is a demoFour minutes and forty-four seconds of it, captured on a real device against the real API.
The backend is 3,000 lines and imports nothing.
That was not a constraint I was given. It was a question about how much of a framework I actually needed, answered by not using one.