All projects Possessions · Built

Tether

Know where your things are.

0runtime dependencies in the backend — not a framework, Bun itself
35routes and 13 tables behind them
1statement to claim an item, which is what makes the race unwinnable
01Why it exists

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 custody
02The one statement that matters Two people standing next to each other, both scanning.

Custody 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 await inside it is a compile error rather than a code-review note.
03The shape of it A Bun backend, an Expo app, and one stream between them.
Expo appexpo-router, 21 screensBun.serve :4000hand-written routerRoute modules7 files, 35 routesSSE streamone per user, replayablebun:sqliteraw sql, 13 tables

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.

04Zero dependencies, on purpose Every line in this table is a library that is not in the project.
What replaced what
ConcernUsual libraryWhat is actually used
HTTP serverExpress, Hono, ElysiaBun.serve and a ~90-line router
DatabasePrisma, Drizzlebun:sqlite and raw SQL
Migrationsa migration toolan array of statements and a table
Password hashingbcrypt, argon2Bun.password, off the event loop
Google sign-inan OAuth clientJWKS fetched and verified with WebCrypto
Realtimesocket.ioSSE 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.

05Honest state

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.