--- id: e-stop title: An operator can halt the swarm when nothing else is working status: open crates: [didbot-serve, didbot-pds] dependsOn: [] exitCriterion: > With the owner's server unreachable and the web surface down, an operator halts token issuance and provisioning, and can see what is being refused. --- # e-stop Two settings, because they answer different questions. **Pause** stops new tokens and new provisioning and leaves outstanding work alone. **Revoke** stops outstanding work too, which is not undone by releasing it. The reason this is its own epic is availability, not mechanism. Policy lives in the owner's repository and arrives on a poll, so a policy-level stop waits for a network, a server somebody else runs, and an interval. That is correct for a rule and useless for an emergency. **The stop is local state, and it is the only control that is.** - [ ] **Scope it** — everything, one node, one app, one lineage subtree — using only selectors an agent cannot assert about itself. Still "everything" only: the three narrower selectors were worked through and refused, and the refusals are here rather than in a commit message because the next person to reach for scoping needs them. One fact decides most of it. A halt narrower than "everything" must leave some provisioning permitted, and `bot.did.provisionAgent` authenticates a host and nothing narrower: the daemon on a host signs for every context on it, and the profile the request carries is the caller's word about itself. A halted agent that can still provision takes a fresh account under the same host and carries on. So a selector narrower than a host is not merely weaker than "everything" — it reads as a halt while being none. - **One node.** The value is the host a claim names, bound to a key that host alone holds and recorded as `Assurance::NodeCredential`. Provisioning refuses a host with a lock on its account, which is how one node is stopped; see `didbot_pds::lockout` and `estop.rs`'s own module doc. - **One lineage subtree.** An attested context's parent is its host, and a registration naming any other parent is refused (`ProvisionError::ParentDisagrees`). The daemon names the host as the parent of every context, a subagent's included, so below the host there is no parent link the server checked. A subtree narrower than a host is the harness's word. Refused. - **One app.** The only one of the three whose value is sound. A `client_id`'s origin is established by this server fetching a metadata document from it over TLS, the app is not the agent, and no agent can move its own requests to another origin. Note that the selector has to be the origin and not `ClientKey`, which a halted app changes by republishing its document at the same admitted host. Refused anyway, on other grounds: stopping one app is already a local, synchronous decision, consulted in process at every pushed authorization request, and [app-allowlist](app-allowlist.md) owns it and calls it "a policy edit, not a hunt for outstanding grants". It needs no emergency channel, and a second copy of it behind this socket would be a weaker control justified by an unreachability that does not apply to it. Reading "app" as the harness instead does not rescue it: `harness` and `agent_type` are the harness's word, written down unchecked, and `docs/trust-model.md` puts anything at that tier below a fact that selects policy. What is left is therefore not this epic's. A node is stopped by a lock on its host's account, and a lineage below a host needs a parent link the server checks. ## Done - [x] **A latch both gates read**, at issuance and at the write. `didbot_pds::Estop::check_issue` gates `createSession`, `refreshSession` and `bot.did.provisionAgent`; `Estop::check_use` gates every repository write route and `uploadBlob`, checked fresh on every request rather than cached — see the immediacy test. - [x] **Two of the three ways to throw it:** an admin command over a unix socket (`didbot-serve::estop_admin`) and a file whose presence is the latch (`Estop`'s own file check), so it works from a shell with nothing else running. - [x] **A latch that cannot be read is engaged.** An unreadable file (bad permissions) and a readable-but-corrupt one (garbage contents) both read as `Mode::Revoke` — the strongest setting, not merely "engaged" — which is the opposite of `plan/policy-store.md`'s fall-back-to-last-good-copy rule for policy. Tested against real file permissions and real garbage bytes, not a mocked boolean. - [x] **Engaging is cheaper than releasing.** Over the admin socket the two now cost the same, and that is this item's settled reading rather than its abandonment: `PAUSE`, `REVOKE` and `RELEASE` all rest on reaching a `0600` socket in a `0700` directory this process owns. The asymmetry exists so a stop never fails for want of an authorization server, and it was never an argument for making *recovery* depend on one. The operator secret this item once named is gone; see `didbot-serve`'s `estop_admin` module documentation for the whole argument. - [x] **Say what it is refusing.** `Estop::status` reports tokens, operations and accounts refused since the latch last engaged, reset when it clears — the socket's `STATUS` command returns it as JSON. - [x] **Tell agents distinctly.** A halted request answers `503 Halted` with a message stating plainly that this is an operator emergency stop, not a transient failure, and that retrying will not help — `didbot_pds::Halted` and its `ApiError` mapping. - [x] **Decide whether the file latch is acceptable**, given anything on the host can engage it, including an agent. Decided yes, on the epic's own reasoning: the failure direction is safe (engaging is never the wrong call to make available) and the alternative — no local, dependency-free way to throw it — is the exact failure mode this epic exists to close. The denial-of-service surface this opens is real and is the trade this epic is deliberately making. - [x] **Pause vs Revoke as distinct settings**, with Pause leaving outstanding sessions and writes untouched and Revoke ending them — and releasing a Revoke does not resurrect what it ended (`Mode` and `revoke_hook` in `crates/didbot-pds/src/estop.rs`). Written as a plain bullet before, so it counted in no tally. Left open: scope selectors narrower than "everything". The open item above says which were examined and why each was refused.