Writing
The machine that runs the agents holds no secrets
An agent that gets out of its container should find nothing worth taking. That is a property of what the machine holds, not of how carefully the container was built. So the process was cut in two, and the half that runs the agents was given no database connection and no decryption key.
One process could not be in two places
The half that authenticates needs the database: accounts, canvases, share tokens, the encrypted credential for a code host. That database sits on a private network with no public port. The half that runs the agents executes code a model wrote, so it has to sit far away from everything else that network hosts. It was one process doing both jobs, and that is the entire reason the deployment was stuck.
Neither shortcut is acceptable. Opening the database to the internet puts a database that also serves other projects on the public road. Moving the executor next to it puts arbitrary code execution beside them.
What made the separation cheap
The authorisation decision was already a pure function. It does no input or output. It reads no database and no clock.
export function checkFrame(access: ConnectionAccess, frame: ClientMsg["type"]): AccessCheckAnd the access it judges carries four fields: the user, the account session behind that user, the canvas, and the right held on it. So the far half replays the same policy from four fields the near half hands it. The policy is not copied. It is shared, which is the difference between one rule and two rules that will disagree in six months.
The call that counting could not find
Every call into the accounts store was located, and each one sat inside a handler belonging to the half with the database: sign-up, log-in, resume, log-out, creating and listing a canvas, share tokens, disconnecting a code host. Not one in spawn, input, resize or kill, and none in any board, project, presence or orchestrator handler. The seam looked clean.
Then one turned up that the counting could not have seen, because it is not in a handler at all. A helper called authorFor reads a user row, and it is reached from underneath the spawn path and the orchestrator path, so that a commit is attributed to the real person who asked for it. A measurement that walked the line ranges between handlers was structurally incapable of finding it.
The answer was not to move the spawn. The frame stays where the pty is, and that half is handed narrow methods instead of a database URL.
Narrow is the security property, not the convenience.
The identity method answers a name and an email address, and not the user row. The user row carries an Argon2id password hash. Before the split, the machine that executes arbitrary code was receiving password hashes when what it needed was a name. The fix is not a rule that we do not read that field, because a habit degrades silently. The fix is that the answer has no field a hash could ride in, so the reduction happens on the side that has the database and only the result crosses.
The same argument runs twice more. The credential method takes a canvas and never a user, so the subject is derived on the side that routed the request: the credential of the owner of this canvas can never widen into any credential in the database. And the canvas method answers three fields rather than the canvas row, because the row also holds the read and edit share links, and the edit one grants write access to whoever has it. Built field by field on the near side, a spread on the far side has nothing to leak.
Why it is a role and not two programs
Only a small part of the socket server is handlers. The rest is frame validation, connection lifecycle, fan-out, backpressure, keepalive and the sweep that retires idle canvases, and both halves need every line of it. Splitting the files would have duplicated all of it, and two copies of a fan-out is two fan-outs with one bug fixed. Splitting roles duplicates nothing: one binary, one set of shared machinery, and only the active handlers change.
export const ROLES = ["all", "control", "sandbox"] as const;
export function needsDatabase(role: Role): boolean {
return role !== "sandbox";
}
export function servesBrowsers(role: Role): boolean {
return role !== "sandbox";
}Those two predicates are what keeps the property inseparable from the code. No role both skips the database and serves browsers, and there is a test asserting it for every role that exists, so adding a fourth role that broke the rule would fail rather than ship.
The accounts option was already required, with no default and no mode that runs without it, precisely so a deployment cannot serve an unauthenticated socket by forgetting a flag. The new role does not relax that. It is not accounts becoming optional. It is the role that runs with no database and refuses browsers outright, accepting only a relayed connection from a process that has already decided who is asking. An unknown role never falls back to the permissive default either: it throws, because a typo in a deploy variable quietly becoming the everything mode is exactly how a sandbox ends up serving browsers.
The credential that has to cross anyway
Pushing to a private repository needs two things that now live apart: the encrypted credential, which is in the database, and the cloned repository, which is on the other machine’s disk. So the far half never stores the credential. It asks for it immediately before a push, uses it, and forgets it. A decrypted credential exists only as a local variable inside a single call, and there is a test that walks the object graph to prove nothing parked a copy.
What the relay costs
Terminal output now crosses twice, which is affordable at this size and reversible later without touching the browser. Two properties nearly broke on the way, and both are the same mistake in different clothes: a relay makes a process measure the relay instead of the thing it thought it was measuring.
Backpressure was measured on the browser’s own socket. Behind a relay the far half would be reading the near half’s buffer, so a slow browser could fill the near half’s memory while the far half saw a healthy connection. The near half therefore raises the desync itself and stops reading, which pushes the pressure back over TCP where it belongs.
Liveness was answered by the browser’s network stack. Behind a relay the near half would answer in its place, and the far half would believe a dead browser was alive and keep its agents attached to nobody. So the death of a connection is propagated rather than absorbed.
The bug class a split creates
On the half without the working tree, the project registry is not missing. It is empty, and an empty registry answers every question confidently and wrongly. Repository status with no project fills its whole local half with empty strings and zeros, which on that process is a confident description of a working tree that is alive on another machine. That needed a third predicate, and naming it was most of the fix.
export function holdsWorktree(role: Role): boolean {
return role !== "control";
}If you are building the same thing
Look for the pure function before you look for the file boundary. The one decision that governs everything here had no input or output, and that single fact is what turned a rewrite into a launch mode.
Count the calls, then assume the count is wrong. The one call that mattered was in a helper, and it was found by reading rather than by grepping.
Reduce at the source. Every object that crosses a boundary should be built field by field on the side that owns it, because the day someone spreads it on the far side is the day you find out what it was carrying.