← Story
Chapter 6August 2026

Ferry — building for a world with no server in it

By August 2026 I had a document I'd been carrying from project to project — my own architecture blueprint. Ten decisions in order of how much they matter, an architecture map, twenty numbered invariants, patterns for the API layer and the database and the frontend and security and testing, a list of what must never happen, and a list of deliberate simplifications. It came out of EduQate, where it survived real load and real deadlines.

Ferry is where I brought that blueprint to a project it mostly didn't fit, and learned which parts of it were actually load-bearing.

What Ferry is

An open-source peer-to-peer tool for developer teams. Files, secrets, and short messages moved directly between machines on a LAN or a hotspot — no server, no relay, no hosted component anywhere, ever. Every machine runs the same daemon, which is simultaneously client and server. Three surfaces — a CLI, a VS Code extension, a desktop app — all talk to the same local daemon.

Eight Rust crates in one workspace. About 16,700 lines of Rust, 350+ tests, and the dependency direction between the crates is enforced by the compiler — a violation is a build error, not a code-review note you might miss.

The blueprint assumed a world that isn't this one

My blueprint assumes: a server. A database that is the product. HTTP requests. Human roles. And an authority that enforces a rule once, on everyone's behalf.

Ferry has none of those. There's no server to hold the database. There's no HTTP. There are no roles. And critically, there's no trusted third party — every rule has to be enforced independently, by the machine that cares about it, against cryptographic evidence rather than someone's word.

So I did the thing that makes reuse work instead of turning into cargo cult: I wrote down, at each point of divergence, what carried over unchanged, what carried over modified, what I dropped entirely, and what I had to add because the blueprint had never faced this problem.

What carried, unchanged, turned out to be the stuff that was never really about the web: one enforceable place per rule. Real layer boundaries. Decoupling a decision from the announcement of it. And not calling anything done until you've watched it work.

What got inverted: "the database is the asset" became "the database is not the asset." Ferry's SQLite holds queue state and history — all of it recreatable. The thing that actually can't be lost is the identity key and the roster. That one inversion deleted a whole CI job (the schema-drift check) and a whole backup ritual that I would otherwise have reused by reflex, and redirected all that care to the OS keychain instead.

What got moved: deny-by-default. In the blueprint it lives at a route wrapper. In Ferry it moved down to the cryptographic handshake — an unrostered peer fails during the Noise XK handshake, before a single byte is parsed as a command. There's no code path where an unauthorized peer's message gets read and then rejected, because the connection never becomes a command channel at all. Enforcing that in the UI would be theatre.

What got deliberately violated: "nothing hard-deletes." For secrets, burn-after-read has to genuinely destroy. So the rule became: the audit log records that an event happened — actor, kind, id, time, outcome — and never what it contained.

And "done" got stricter. In the blueprint, done means "driven end to end as a real user." For Ferry it means "driven end to end on two real physical machines on a real network, including at least one disconnect and reconnect." A passing two-node in-memory test harness tells you a lot about the state machine and almost nothing about a hotspot with a sleeping laptop on it.

The problems that came out of the build

bincode cannot deserialize a tagged enum. At all. The wire envelope used a self-describing tag representation and encoded it with a binary format that structurally can't look ahead at a string tag. Not a bug in either library — a mismatch between two individually reasonable choices. Caught by the transfer engine's own unit tests rather than left latent, which is the entire argument for making the transfer engine testable without a socket. And while fixing it I realised those wire types are the peer-to-peer protocol and the UI never touches them — it only talks to the local daemon — so their auto-generated TypeScript bindings were pointless from the start. Eight stale binding files fell out.

A chunk-size constant that could never have worked. One crate defined the default transfer chunk at 64 KiB. A real encoded chunk at that size — plus the envelope overhead, plus the encryption tag — exceeds the maximum frame size the framing layer allows. Every large transfer over a real connection would have failed. It had never been caught because a second crate defined its own chunk constant, smaller, and that smaller one was the only one anything actually exercised. So there were two constants for one concept, and the load-bearing one was fine while the exported default was wrong and untested at the same time. The two-node harness — the first thing to ever push real-sized bytes through the real framing layer — is what surfaced it. A duplicated constant is a place where one copy is load-bearing and the other is decoration, and the decorative one is exactly where a bug hides indefinitely.

Time, when you can't trust a clock. A ten-minute secret is sent to a laptop that's asleep. It arrives twenty minutes later. When does it expire? Using the sender's timestamp is wrong twice over — two machines on a hotspot genuinely disagree about the time, and the sender's clock is attacker-controlled, so an attacker who can set a clock can make a secret live forever. The rule is that expiry runs on the receiver's monotonic clock, starting at delivery. That kills a class of bugs and a class of attacks in one sentence. The hard part was surviving a process restart, because a monotonic clock reading is meaningless outside the process that took it and has no persistable form. The fix: each process gets a random session id stored alongside the reading. Trust the monotonic value only within the session that produced it; otherwise fall back to a conservative wall-clock estimate; when the two disagree, expire — the safe direction.

Pairing can't live in the daemon. The pairing exchange runs in the CLI, over a raw socket, not in the daemon. Because pairing requires a human to read a verification phrase off their screen and confirm it out loud matches the other person's, and the daemon is headless with no guaranteed terminal. Obvious once you say it, easy to design past when you're thinking about which component "owns" the protocol instead of which component can reach a person. A step that structurally requires a human can't live in a process that structurally can't reach one.

Crypto is assembled, never designed. age for sealing, Noise XK for transport, SPAKE2 for pairing, ed25519 for signatures. No novel constructions, no hand-rolled key derivation. When I needed the identity to hold both a signing keypair and a sealing keypair, I generated two independent keys rather than deriving one from the other — because the derivation involves a birational-map conversion that reads as exactly the hand-rolled key derivation the rules ban. Two keys cost slightly more space in the keychain. That's the whole downside, and it's worth it.

The standard I built it to

The thing I'm proudest of on Ferry isn't a feature, it's the bar. Every user-facing flow has a definition of done that is deliberately harsh: driven end to end on two real physical machines, on a real network, including at least one disconnect and reconnect where that's relevant. Compiling doesn't count. Unit tests passing doesn't count. Even the two-node in-memory harness passing doesn't count — it earns a flow a different, lower mark, because a harness tells you about the state machine and a hotspot with a sleeping laptop on it tells you about reality.

I keep that bar written down, per flow, as the single source of truth for what works — not the codebase, not the test output, not what "should" work because the code exists. It's the same discipline that caught eleven bugs in one pass on EduQate, ported to a project where the network itself is the hard part.

That's a recurring theme with me, if you've read this far. I'd rather hold the bar high and tell you exactly where the line is than move the line to make a sentence sound better.