ADR-0012: A process that outlives the exec fence holds no grant
- Status: Proposed
- Date: 2026-08-07
- Implementation:
shipped—src/facade.ts:290startDetached/:359detachedStatus/:376stopDetached; selective teardown atsrc/sandbox-do.ts:808killFencedProcesses, including thekillAllProcesses()fallback whenlistProcessescannot be read (:817); decision functions insrc/engine/detached.ts. Tests:engine/detached.test.ts,engine/exec-fence.test.ts,facade.workers.test.ts. The record’s own deferrals (preview URLs, directory retrieval) are out of scope by its text, not gaps.
Context
Section titled “Context”ADR-0005’s fence closes the grant window when the exec returns: apply, run, kill, then revoke. The kill is what makes the revoke meaningful — a backgrounded child would otherwise still hold the grant after it closed.
Three dispatcher runs need a process that is still running when the exec returns. cdp-acceptance
and product-demo start an app and then drive it; self-heal-pr runs a 10–20-minute agent whose
turn is indivisible and would blow past any exec timeout. Each uses sandbox.runDetached on the
dispatcher’s own fleet. On the facade there is no equivalent, and the reason is not a missing
method: what a grant means for a process the substrate is not synchronously awaiting is
undecided, and killAllProcesses() is all-or-nothing, so the fence cannot spare one.
Two related surfaces are blocked behind the same runs. A preview URL (sandbox.exposePort) is an
inbound path the facade has no way to express. Tarring a directory out of the container
(artifact.upload({ container })) has no facade equivalent — checkpoint snapshots /workspace as
a restore cache, which is a different thing from handing a caller a blob.
Cost of leaving it: two container fleets share one account ceiling behind two admission gates — the
condition ADR-0004 exists to end — and the substrate’s caps
stay pinned at CONTAINERS_CEILING = 16.
Decision
Section titled “Decision”A detached process holds no grant. Ever.
The fence’s grant window is the exec. A process that outlives the exec outlives the window and keeps
running under the container’s floor posture: enableInternet = false, empty allowlist, no handler
mapped. It can serve, it can be dialled on localhost by a later command, and it cannot reach the
network. Egress a run needs happens inside a fence — install dependencies in one exec, start the
server detached in the next, where it needs none.
This is a decision about semantics, and it makes the surface small:
startDetached(key, input)starts a process and returns a substrate-assigned id. It runsensure()behind the ticket gate and crosses the ADR-0007 approval floor exactly asexecdoes — a floor command started detached must not be a way around the floor — and it applies no grant.detachedStatus(key, processId)answers running / exited-with-code / gone, wheregonecarries the reason the substrate no longer has the process (an id this execution never started, a process the container no longer tracks, or a container that cannot be reached). There is nowaitForExiton the facade: a consumer polls from its own durable steps, the same shape admission already uses, because a Worker call that blocks for twenty minutes is not a call.stopDetached(key, processId)kills it and forgets it.abortandcheckpointclear every record with the container.
The fence’s teardown becomes selective: kill every tracked process except the ones this
execution declared detached. With no declared process — the overwhelming majority of executions — it
is killAllProcesses() unchanged, so the hot path grows no new failure mode. When listProcesses
cannot be read, it falls back to killAllProcesses(): killing a declared process is a wrong answer,
leaving a grant-holder alive is a worse one.
Diagram source
stateDiagram-v2 accTitle: Detached process lifecycle state "Running, floor posture" as Floor state "Running inside a later fence" as Shared state "Exited, record kept" as Exited state "Gone" as Gone [*] --> Floor : startDetached, behind ticket and approval floor, no grant Floor --> Shared : a later exec applies its grant Shared --> Floor : fence kills undeclared processes, then revokes Floor --> Exited : process ends Floor --> Gone : stopDetached, abort, checkpoint or container stop Exited --> Gone : stopDetached, abort, checkpoint or container stop Gone --> [*]Preview URLs stay off the facade for now
Section titled “Preview URLs stay off the facade for now”exposePort is an inbound route into a container, not an egress grant, and the two need different
reasoning. Shipping it means the substrate worker owns a proxy route of its own (the dispatcher’s
lives in its index.ts, bound to one container class), the hostname is substrate config rather than
a consumer input, and the token stays the SDK’s random 16 characters — a consumer-chosen stable
token is a guessable, long-lived public door into a container. Until that route exists,
cdp-acceptance and product-demo stay on the dispatcher’s fleet.
A directory leaves the container by being written, not pulled
Section titled “A directory leaves the container by being written, not pulled”No facade method. The substrate already mounts R2 at /artifacts inside every container, prefixed
per container id, so a run that wants a directory out writes its own archive there — tar -czf /artifacts/report.tgz playwright-report — inside a fence it already has. What the facade owes is
the other half: the consumer holds a sandbox key and never the container id, so it cannot address
what the run wrote. That is a retrieval surface, and it follows denials() exactly.
Consequences
Section titled “Consequences”- A detached process is unfenced, and while a later fence is open it shares that fence’s grant. It holds none of its own, but it lives in the container, so during an exec the open grant admits its requests too — bounded by that grant’s own host, method and path rules, which is the same bound the fenced command has. Between fences it has no grant at all.
- The kill’s real coverage is narrower than the fence’s prose claimed, and this ADR is where that
gets corrected.
killAllProcesses,killProcessandlistProcessesall address the container server’s/api/process/*registry, which onlystartProcesspopulates;execposts to/api/execute, a different endpoint with a different lifecycle. So a child a fenced command backgrounds has never been in that registry and the kill has never reached it — what bounds it is the revoke that follows and the container’s lifetime, not the kill. Since the substrate calledstartProcessnowhere before this, the fence’skillAllProcesses()returned 0 on every execution to date, and the change here is behaviour-preserving for every run that declares nothing. - What the selective walk actually buys is the inverse of how it reads: it is not that more gets
killed, it is that
killAllProcesses()would otherwise kill the declared process the moment any later command ran. - The
killedcount inExecOutcomemeans “processes this fence killed from that registry”. It was the only signal akillAllProcessescontract change would surface, and it still is. - Declarations are reaped where
listProcesses()is already paid for — in the teardown — and cleared wholesale inonStop, because a container that stopped falsifies all of them at once. A record that outlived its process spares nothing and would pin every later teardown to the selective path. - Process ids are substrate-assigned UUIDs, never container pids. A consumer cannot name a process it did not start, and a pid is not a stable name across a container restart anyway.
- A detached process does not survive a checkpoint.
/workspaceis snapshotted and the container stops; the process is gone and its record with it. A consumer that checkpoints mid-run restarts it. - The drain still waits on the preview-URL route:
cdp-acceptanceandproduct-demokeep theexpose-portgap, so thecontainersstanza cannot leave the rootwrangler.jsoncyet.