# Reemoat — decision record Why this system is built the way it is, as a set of questions and the answers it arrived at. Reemoat is a daemon that owns coding-agent sessions — `claude`, `kimi` or `codex`, spawned over ACP (Agent Client Protocol) — and exposes them over HTTP and WebSocket; a control plane that issues identity and relays every request to them; and a web UI that supervises the whole fleet from a phone. One person, one machine, many agents, and no sandbox. ## What this document is A decision record, not a manual. `CLAUDE.md` states the rules as they stand and is what you need in order to *change* the code. This file answers the other question — **why is it like that** — and it exists because that answer was otherwise being carried in prose at the top of the codebase, where it cost more to read than it returned. Every entry is a question somebody actually had to answer, with the reasoning that settled it. Where a decision rests on a measurement, the measurement is quoted with its date and the version it was taken against, because an observation about `claude-agent-acp` 0.63.0 is not a fact about ACP. ## How to read an entry Entries are numbered `Q.` and grouped by area. The fields vary by what kind of thing is being recorded: | Field | Means | |---|---| | **Decision** / **Rule** / **Behaviour** / **Position** | What is true now. `Rule` marks a constraint that was a defect first; `Behaviour` marks a fact about a third-party tool rather than a choice. | | **Why** | The reasoning — above all, the concrete failure the decision prevents. | | **Measured** | An observation, with its date and the versions it was taken against. | | **Rejected** | An alternative that was considered, or built and removed, and why it lost. | | **Status** | `Current`, `Known limitation`, `Not built`, `Deliberate non-goal`, or `Reversed an earlier decision`. | Three conventions are worth knowing before you start: **Rejected alternatives are kept.** Several things here were built, measured and taken back out. Those entries are the most useful ones in the file: they are the only record of why an obvious-looking idea does not work, and without them the same idea gets rebuilt. **Known limitations are labelled, not smoothed over.** Where something is broken and not fixed, the entry says so. Where a claim in an earlier version of this material turned out to be wrong, the correction is recorded rather than quietly swapped, because a reader who reached for the old claim deserves to know it moved. **Names are cited by symbol, never by line number.** Line numbers rot; a symbol you can `grep` for does not. If a name in this file greps to nothing, that is a bug in the file. ## Groups | Group | Covers | Entries | Heading | |---|---|---:|---| | [**Q1**](#identity-reachability-and-trust) | Identity, reachability, and what is deliberately not confined | 147 | `###` | | [**Q2**](#session-lifecycle-questions-and-attachments) | Session lifecycle, restart and resume, questions the agent asks, attachments, messages between agents | 113 | `###` | | [**Q3**](#the-web-client) | The web client — the list, the transcript, the composer, the ask card | 453 | `####` | | [**Q4**](#deployment-packaging-and-code-layout) | Deployment, packaging, and code layout | 69 | `###` | | [**Q5**](#invariants--rules-that-were-defects-first) | Invariants — rules that were defects first — and every bound in one table | 116 | `####` | | [**Q6**](#measured-behaviour-of-the-agents-and-the-tools) | Measured behaviour of the agents and of git, node and HTTP/2 | 81 | `###` | | [**Q7**](#open-questions-and-deliberate-non-goals) | Open questions and deliberate non-goals | 154 | `###` | | | | **1133** | | **The two largest groups are one level deeper, and counting only `###` is how the number comes out wrong.** Q3 and Q5 sit at `####` because each subdivides further with `###` dividers of its own (`### The relay`, `### Tokens and authentication`, and five more); promoting their entries would make them siblings of their own dividers. So the count is over **both** depths, and it says 1133 rather than the 564 that reading one depth gives — a number that had been restated, and drifted, fifteen times before `docscheck` started asserting it against the real headings. It asserts this sentence too, both halves of it, for the same reason. ## Provenance This was assembled from a single long-form engineering document that grew alongside the code between 2026-07 and 2026-08. Reshaping it into questions did not add or remove engineering content, but **this file is now the only copy**: the repository was published with fresh history, so there is no earlier revision to go back to and no commit to cite. Where an entry names an incident it names it in words, for the same reason. Measurements throughout were taken against `claude-agent-acp` 0.63.0 with `claude` 2.1.220, `kimi` 0.29.2, `codex-acp` 1.1.9, and — where the ACP reference implementation is cited — `gemini-cli` 0.53.0. Where a version matters to the conclusion, the entry names it. The pins moved to `claude-agent-acp` 0.73.0 and `codex-acp` 1.8.0 on 2026-09-04, with `claude` 2.1.259 and `codex` 0.153.1 on PATH; Q7.114 and Q7.115 carry the re-measurements, and an entry naming the older pair was measured against it and is not rewritten. --- ## Identity, reachability and trust ### Q1.1 — What is Reemoat, and where do the agents actually run? **Decision.** A daemon owns coding-agent sessions and exposes them over HTTP + WS; a control plane issues identity and relays every request to them; a web UI supervises all of it from a phone. The daemon runs on your own machine and spawns agents as children of itself, as you — several at once, each in its own git worktree, each able to bring up a dev server and run what it just wrote. **Why.** One person, one machine, many agents, and no sandbox. Spawning an agent as a child of the daemon under your own uid is the same thing that happens when you type `claude` in a terminal, except that you can be somewhere else. Separate worktrees are what stop concurrent agents fighting over one index and one HEAD. **Status.** Current ### Q1.2 — Why was the container-per-tenant model abandoned rather than kept? **Decision.** The multi-tenant model — a host directory per *tenant*, a long-lived container it was mounted into, and a grant — is abandoned rather than superseded. **Why.** It was coherent and it bought real things: kernel namespaces, cgroup limits, a defence against somebody you do not trust at all. It is gone because the product no longer asks the question it answered. What it cost was everything a container is in the way of: ports, tooling, the host's own caches, and the ability to look at what you built. See **What is not confined** (Q1.14–Q1.19) for the trade in the other direction, which is not a small one. **Status.** Reversed an earlier decision ### Q1.3 — If tenancy is gone, what does "multi-user" mean now? **Decision.** Multi-user moved rather than disappeared. The control plane still holds several people, each with their own machine and a grant on it; a fleet of one-person daemons is what "multi-user" now means. The daemon accepts any token whose `aud` is its own machine id and stops asking who the subject is. **Why.** Nothing extra had to be written for the fleet case — the audience check already carries it. **Status.** Current ### Q1.4 — What does the network layer assume about clients? **Decision.** The daemon spawns `claude`, `kimi` or `codex` over ACP (Agent Client Protocol), normalizes all three into one event union, and puts that behind a network layer built on the assumption that **clients are unreliable**. The daemon is the source of truth and the agent must never notice a client leaving. **Why.** A laptop lid closes, a phone drops to LTE, a tab is discarded. Any of those must be invisible to the running agent. **Status.** Current ### Q1.5 — What is the build, and what compiles? **Decision.** Node >= 24, ESM, TypeScript strict. Everything in `src/`, `scripts/` and `packages/control-plane` runs straight off `tsx` with no build step. `packages/web` is the one exception and is bundled by Vite — inside the control plane's image, which is the only thing in this repository that compiles anything. **Why.** No build step keeps the daemon and the control plane runnable directly from source; the web bundle is the single artifact that genuinely needs one. **Status.** Current ### Q1.6 — With no test framework, what is the automated safety net? **Decision.** `pnpm typecheck`, `authcheck`, `daemoncheck`, `relaycheck`, `webcheck`, `pincheck`, `deploycheck`, `docscheck`, `imagecheck` and `harness` are the whole automated safety net, and they are drivers rather than unit tests on purpose. Eight are runnable offline in a single process with no fleet, no agent and no deploy; two are not, in different directions. `harness` drives a real agent and needs a login CI cannot hold, so it is excluded. `imagecheck` builds a container image and starts it, so it needs a Docker daemon and a network, which is why it is a *separate job* in `.github/workflows/check.yml` rather than a step beside the others. Everything except `harness` runs in CI. **`docscheck` is the newest, and the only one whose subject is prose.** It holds `CLAUDE.md` to a budget and each rule file to a smaller one, resolves every `Q.` citation against a real heading, greps every symbol *this* file cites, checks the Groups table and the totals stated around it against the real headings, and checks every `paths:` glob against a real file. It exists because the last cut of `CLAUDE.md` installed no number and no check, only two qualitative sentences, and the file was larger six days later — the same reason the entry count in this document is asserted rather than restated. **Why.** `imagecheck` earns its exception: the control plane reaches into the repository root for exactly four files by literal relative path, and a fifth import passes `typecheck` and every other driver while breaking only the image. **Measured.** Four files reached by literal relative path from the repository root; a fifth import that passes `typecheck` and every other driver and breaks only the image. **Status.** Current ### Q1.7 — Does running the checks deploy anything? **Decision.** Deploying is a *separate* act from checking, and nothing automates it. **Why.** The checks say the tree is sound; they do not decide when a running machine is disturbed. **Status.** Current ### Q1.8 — How does the daemon decide who is asking? **Decision.** `REEMOAT_AUTH` picks the mode: `shared_secret` (the default, unchanged, with no control plane anywhere), `signed`, or `both`. **Why.** The shared-secret path has to keep working with no control plane in the picture at all; signing is what a fleet needs. **Status.** Current ### Q1.9 — Why does the daemon never contact the control plane after enrollment? **Decision.** Under `signed` the daemon verifies Ed25519-signed tokens against a public key it obtained **once**, at enrollment, and **never contacts the control plane again**. **Why.** That is the load-bearing property: a control-plane outage cannot stop a session, cannot stop a daemon starting, and cannot stop a token verifying. **Status.** Current ### Q1.10 — Is the control plane on the data path? **Decision.** **The control plane is on the data path, always.** Every request to every machine goes down the tunnel its daemon dialled out. The daemon is never **asked** anything: no key is fetched, no revocation list polled, no token validated over the tunnel. **Why.** An outage there stops you reaching your machine and cannot stop a session running, a token verifying, or a daemon starting. **Rejected.** The text that used to sit here said the control plane was *not* on the data path, and then that it was a *fallback*. Both are gone; the half that survives every rewrite is the careful one. **Status.** Reversed an earlier decision ### Q1.11 — What does a grant entitle somebody to? **Decision.** **A grant is full access to the machine.** Anybody granted a machine sees all of the work on it. **Why.** `owner_subject` used to partition every route, so a grant let somebody see their own sessions on a machine and nothing else. That filter is gone with the tenancy: there is one person's work on a machine. Under the old model this was a tenancy question; it is now the whole authorization model, which is why it is stated here rather than in a footnote. **Status.** Reversed an earlier decision ### Q1.12 — How quickly does revoking a grant take effect? **Decision.** **Revocation is immediate.** The relay reads live user, machine and grant rows before a byte enters the tunnel, so revoking a grant takes effect on the *next request*. What the token lifetime still bounds is one thing: a WebSocket already open, which the daemon closes `4401` on its own ping tick at `exp + leeway` — five minutes by default, six with leeway. **Why.** The earlier claim — no revocation list, blast radius exactly one token lifetime — was true only while a client could reach a daemon directly, which is precisely the path nothing could revoke. **Status.** Reversed an earlier decision ### Q1.13 — Where does the control plane live, and may `src/` import from it? **Decision.** The control plane lives in `packages/control-plane`, with its own SQLite and its own entry point. It signs tokens, holds users, machines and grants, mints single-use enrollment codes, and relays. **Nothing in `src/` may ever import from it.** **Why.** The dependency runs one way only; the daemon must remain buildable and runnable with no knowledge of the control plane's internals. **Status.** Current ### Q1.14 — What is an agent confined to? **Decision.** Nothing. **An agent runs as you.** It is a child of the daemon process, with your uid, your `HOME`, your files, your `~/.ssh`, your browser profile and your other repositories. `cwd` is not confined, `REEMOAT_ROOTS` narrows the directory *picker* and nothing else, and the ACP `fs` capabilities are granted. **Why.** `REEMOAT_AUTH` decides *who* is asking; nothing decides what they can reach, and that is said here rather than left to be discovered. Declining the `fs` capabilities would confine nothing — the agent could make the same read itself. This is the same trade every coding agent on a laptop already makes. The difference this daemon adds is that it can be driven from a phone, over a relay, by anybody holding a grant on the machine. **Status.** Current ### Q1.15 — What happened to the isolation layer? **Decision.** The container per person, the bind-mounted home, the cgroup limits, the mount namespace and the `owner_subject` filter on every route are all gone. **Why.** It was not wrong; it answered a question — "how do I serve somebody I do not trust" — that this product stopped asking. **Status.** Reversed an earlier decision ### Q1.16 — What does `agentEnv()` actually protect against? **Decision.** **The agent inherits this process's environment.** `agentEnv()` strips the session-scoped `CLAUDE_*` names and everything beginning `REEMOAT_`, and that is **hygiene, not a fence**. **Why.** The agent runs as this uid and can read `/proc//environ`, the env file and `REEMOAT_DB` itself. What the strip prevents is three accidents rather than an attack: an agent running `env` and pasting the output into a transcript that lands in the log, in the UI and in a bug report; an agent running `pnpm daemon` and colliding on the daemon lock; and `REEMOAT_TOKEN` reaching a subagent's context window. **Status.** Current ### Q1.17 — Do a repository's own git hooks run? **Decision.** **Git hooks run as you again, and that is the intent.** `GIT_NO_EXEC_CONFIG` is deleted, so `git worktree add` runs the repository's own `post-checkout` and its LFS smudge filters. **Why.** Cloning a hostile repository is therefore exactly as dangerous here as in your own terminal — which is to say: exactly as dangerous, and no more. The hooks were neutralised while the repository was on the other side of a boundary, and the cost of keeping that was silent: a blanked `GIT_CONFIG_GLOBAL` checks out LFS pointer files instead of content, and the agent reads a spec URL where a binary should be. **Status.** Reversed an earlier decision ### Q1.18 — Can the permission machinery be bypassed entirely? **Decision.** Yes. **`~/.claude/settings.json` can bypass the permission machinery entirely.** Where it blanket-allows `Bash`, `Edit` and `Write`, the inner CLI decides for itself and the permission state machine this daemon is built around never sees a request. **Why.** This sat in **Known gotchas** as a testing inconvenience; under the current trust model it is a product fact, and it belongs with the security posture. **Measured.** On the development machine, `~/.claude/settings.json` blanket-allows `Bash`, `Edit` and `Write`. **Status.** Current ### Q1.19 — If a sandbox is wanted again, where would it go? **Decision.** The seam is `SessionRuntime`. It is kept as an interface with one implementation for exactly that reason. **Why.** Re-adding confinement should be filling in an implementation rather than reopening a design. **Status.** Not built ### Q1.20 — How does a browser reach a daemon behind NAT? **Decision.** The control plane also runs a **relay**, on its own port, and **it is the only way in.** A daemon dials out over WSS at startup and holds one connection; every request from every client is spliced onto it. The daemon binds `127.0.0.1`, the registry records no address for it, and there is nothing else to address. **Why.** Identity without reachability is not a product: daemons sit behind NAT and the browser is outside. **Status.** Current ### Q1.21 — Why was the direct path deleted rather than made optional? **Decision.** **The direct path is deleted, not disabled.** `REEMOAT_HOST` defaults to `127.0.0.1`, and the `baseUrl` column is gone rather than merely left null. **Why.** A machine's `baseUrl` used to be its whole routing policy — with one, clients probed it first and a client on the same LAN never touched the relay; without one, the relay was the only path. That was a good design for a fleet of machines other people might reach directly, and `--no-url` was how you turned it off. It was also never the *lever* it looked like: the daemon went on listening on every interface regardless, and a client that had already memoised a direct route went on using it. **Loopback binding is the lever.** **Rejected.** `--no-url` / a null `baseUrl` as the opt-out, because it did not change what the daemon bound to or what an already-memoised client did. **Status.** Reversed an earlier decision ### Q1.22 — Does `REEMOAT_PORT` still matter if nothing outside dials it? **Decision.** `REEMOAT_PORT` stays known and stays 7887. `REEMOAT_PORT=0` still works for a daemon only ever served by the relay — and is what the desktop app gives every daemon it runs for a server other than the one `~/.reemoat/daemon.env` names, the `~/.reemoat` daemon keeping 7887 (Q7.148). **Why.** The things that address the port are on the same machine: `pnpm client` under the shared secret, and the deploy script's `/health` probe. With `0`, the relay routes by the verified `aud` claim and splices each CONNECT to a fresh loopback connection — nothing outside needs to know the port. **Status.** Current ### Q1.23 — Can a deployment opt out of the relay? **Decision.** No. `REEMOAT_CP_RELAY_URL` is **required** — `main.ts` refuses to start rather than pretend otherwise — and `REEMOAT_RELAY=0` on the daemon is gone. Both are warned about rather than ignored when left in an old environment file. **Why.** A control plane without a relay is a fleet nobody can reach, and opting out of the relay is opting out of being reachable, which is not a configuration. **Status.** Current ### Q1.24 — What does the relay do that ngrok or Cloudflare Tunnel cannot? **Decision.** **Authorization is the point, and it is now the *whole* point.** The relay verifies the caller's signed token and checks their grant *before a byte enters the tunnel*, against live rows — so revoking a grant takes effect on the next request. **Why.** ngrok and Cloudflare Tunnel already move bytes; what they cannot do is know that user X may reach machine Y. The cost is stated where it will be read: the control plane is now permanently on the data path, and an outage there costs *all* reachability where it used to cost nothing. What it still cannot touch is anything already running (Q1.9, Q1.10). **Rejected.** The earlier framing of two paths with different revocation windows, the direct one bounded by token lifetime. There is one path, and it is the revocable one. **Status.** Current ### Q1.25 — How does one tunnel carry many connections? **Decision.** HTTP/2 over the WebSocket, one `CONNECT` stream per browser connection, spliced on the daemon side to a fresh loopback connection to its own listener. **Why.** The tunnel therefore carries **opaque bytes, not parsed HTTP**, which is why tunneling a WebSocket inside a WebSocket needed no special case and why nothing in `server.ts`, `session.ts` or `registry.ts` changed. **Status.** Current ### Q1.26 — What does the `requestsProxied` counter measure? **Decision.** `cpctl admin relay` shows a per-tunnel `requestsProxied` counter; it is plain traffic accounting. **Why.** It used to be the measurement that made "it went direct" a fact rather than a claim. With one path there is nothing left to prove. **Status.** Current ### Q1.27 — How do agent credentials get onto disk at all? **Decision.** Two paths put them there: a pasted token (Q1.29) and a wizard that drives the agent's own login under a pty (Q1.31). The daemon never calls ACP's `session/authenticate`. **Why.** `acp/agents.ts` still says the true thing — both agents authenticate out of band and the daemon can only inherit credentials from disk. So something has to put credentials on that disk. **Status.** Current ### Q1.28 — Why does a login feature survive the move off tenants? **Decision.** It stays, with a different justification. **Why.** It existed because a tenant had no shell on the host; on your own machine `claude auth login` in a terminal is the whole answer and always was. What it exists for now is the case this entire product exists for: **you are not sitting at the machine.** From a phone there is no terminal, and a login is exactly the thing you need when an agent has just refused a prompt. **Status.** Current ### Q1.29 — What is stored when somebody pastes a token? **Decision.** Path A stores `agent_credentials(agent, env_name, secret, updated_at)`, merged into the agent's environment at spawn. Secrets are merged *last*, so a pasted token beats an ambient one. **Why.** What a credential *is* is the name of the variable the CLI reads it from — `CLAUDE_CODE_OAUTH_TOKEN` (what `claude setup-token` mints), `ANTHROPIC_API_KEY`, `KIMI_API_KEY` — so the name is stored beside the value and nothing else has to know any of them. Merging last is what makes the Settings screen's "set" true about what the agent will actually read. **Status.** Current ### Q1.30 — Does pasting a key work for kimi? **Decision.** Yes, on one of kimi's two credential paths, and the contradiction this entry used to record is **answered**: both statements were right about different code. **Measured** against the installed kimi 0.29.2. Its raw model client reads the process environment — `options.apiKey ?? process.env["KIMI_API_KEY"]` — so `AGENT_LOGIN.kimi.envNames` is real. Its **provider manager**, which is the path a `managed:kimi-code` provider takes, resolves the key from `provider.env`, a TOML table in `~/.kimi-code/config.toml`, and never consults `process.env` at all — so `resolveAgent`'s `authHint` was right too. **Why neither side could win by reading harder.** They were describing different call sites of one CLI. Which one an installation is on is a fact about *its config file*, not about kimi, and the honest rendering is to keep the slot and stop the hint claiming it is useless. **Status.** Current ### Q1.40 — Does the pty get a stdin pipe? **Decision.** Only where the flow reads one, and on BSD only. `AGENT_LOGIN[agent].interactiveStdin` says which, and `loginStdio(platform, interactive)` turns it into the `stdio[0]` for the spawn. **Why.** It fixes the macOS defect — the login wizard did not run there for *any* agent — for two of the three. BSD `script` reads its own stdin's termios in order to copy it onto the pty it is allocating, so a pipe makes it exit 1 before the agent is reached; `/dev/null` succeeds. Measured, `claude auth login` waits on a paste prompt and needs the pipe, while `kimi login` and `codex login --device-auth` are device-code flows whose input box was never used. **Why Linux keeps its pipe unconditionally.** util-linux `script` works with one today, an immediate stdin EOF is a plausible way for it to decide the session is over, and Linux is where this deploys. There is nothing to buy by changing it. **Confirmed on macOS 2026-08-08, both halves from one daemon**: `kimi login` exited 0 with `Logged in to managed:kimi-code.`, `claude auth login` exited 1 with the `tcgetattr` line. What is still unmeasured is whether BSD `script` survives a *full* device flow with `/dev/null` on stdin — that kimi was already signed in, so it returned at once. **What it costs.** `AgentProcess.stdin` is `Writable` and every session relies on that, so login takes a narrower `LoginProcess` whose stdin may be `null` rather than widening the common type. `POST /agent-auth/login/:id/input` answers `400 login_not_interactive` instead of the silent no-op it would otherwise be. **Status.** Current ### Q1.41 — Is "can this agent be logged in here" one question or three? **Decision.** Three, folded into `SessionRuntime.loginSupport(agent)`: `script` is on PATH, that agent's CLI resolves, and whether its flow reads stdin. **Why.** The daemon-wide `loginSupported` answered only the first. The adapter and the CLI are different binaries — Q1.33 — so an agent whose CLI is missing is an ordinary state, and it produced an enabled button and a `503 login_unsupported` after the tap, with the reason only in the daemon's own log. That is precisely what Q1.31 says `loginSupported` exists to prevent, reached by another door. **Why the old field stays.** An older client reads it and nothing else. **Status.** Current ### Q1.31 — Why does the login wizard need a pty? **Decision.** Path B runs the agent's own login under a pty allocated by `script`, via `hostLoginArgs`, which is pure so `daemoncheck` can assert both platforms from a machine that is only one of them: util-linux takes the command as a shell string (`script -qec "" /dev/null`) and BSD takes it as argv (`script -q /dev/null `). **Why.** Both login flows are interactive terminal programs and will not prompt without a pty, while a daemon's stdin is never a tty — the same fact that made `docker exec -it` unusable from inside a daemon, now one layer down. Three things follow that the container version could not have had: the command is now an absolute path that may contain spaces and must be shell-quoted; macOS `script` has no `-e` so it does not propagate the child's exit status, which is why the wizard re-probes availability rather than trusting the code; and `script` may simply not be installed, which is what `SessionRuntime.loginSupported` reports so the client never draws a button that answers 503. **Status.** Current ### Q1.32 — Can a caller name the program a login runs? **Decision.** No. **The login command is a table lookup, never a request field.** There is no route, body field or header anywhere that names a program to run. **Why.** That was a tenant fence and it is still worth having for a different reason: this daemon is reachable from the internet through the relay. **Status.** Current ### Q1.33 — Is the binary a login drives the same one the daemon launches? **Decision.** No. `available` is about `claude-agent-acp`, the ACP adapter, while `loggedIn` and `login` are about `claude`. `resolveLoginBinary` reads `CLAUDE_CODE_EXECUTABLE` first. **Why.** `claude` ships inside a platform-specific package of the SDK with no `bin` entry. Conflating the two is what made the old documented remedy unrunnable — the adapter resolved perfectly and `claude` was not on PATH. And a login that wrote credentials for a different build than the session reads is a login that appears to work and changes nothing. **Status.** Current ### Q1.34 — Why is a login driven over HTTP rather than the WebSocket? **Decision.** **No new WebSocket.** Output is polled and input is an HTTP request whose response confirms it landed. **Why.** That is the read-only-WS invariant applied where it was written to apply: a login code is sent once and is unrecoverable if it evaporates, which is exactly what `ws.send()` into a half-open socket does. **Status.** Current ### Q1.35 — How does an agent push? **Decision.** An agent pushes with your `~/.gitconfig`, your credential helper and your keys, exactly as you would. Nothing mediates it. **Why.** `GIT_NO_EXEC_CONFIG` had to go for that sentence to be true: it cleared `credential.helper` and `core.sshCommand` on every invocation, which is precisely what would have made it false. Whether that is right or alarming depends entirely on how much you trust what you are running, and it is the same answer as everything else in **What is not confined** (Q1.14–Q1.19). **Status.** Current ### Q1.36 — What happened to the forge feature? **Decision.** There is no forge feature. `src/forge.ts` held a token per (tenant, host) in SQLite, wrote a credential file into the container's writable layer, and installed a per-host helper into the tenant's `.gitconfig`; all of it is gone. **Why.** It existed because an agent in a container had no credentials of its own and no way to be given any. An agent runs as you now. **Rejected.** Keeping the section. What is *lost* with it is worth naming, because it was good writing about real measurements: the symlink-redirect write primitive, the `rm -f` → `set -C` → temp → `mv` sequence that defeats it, and "clearing writes an empty file; it must never delete one". All correct, all about a container. **Status.** Reversed an earlier decision ### Q1.37 — What is a login throttle keyed on? **Rule.** A composed key, built only by `loginKey(name, address)`, `addressKey(address)` and `passwordChangeKey(userId)` in `throttle.ts`. Never the bare submitted name, which is what it was. **Why.** Keyed on the name alone the defence was a **weapon**: anyone who could reach the service could refuse a named person their own account, from anywhere, with no credential. Pairing the name with the address means a block follows the guesser rather than the name they typed, and being throttled still proves nothing about whether the account exists — which is what lets the refusal be an honest `429` instead of another indistinguishable `401`. `passwordChangeKey` is namespaced on the *user id* because that route and the login route shared one key space and one instance, so the remedy was blocked by the attack it is the remedy for. Every builder is namespaced, including the login one, and that is not decoration: the address half is caller-supplied, so with a bare `|
` a login naming `pwchg` and forwarding a user id writes exactly the key a password change reads. `field()` also strips the separator, or `a|b` at address `c` and `a` at address `b|c` are one counter. **Measured.** Eleven unauthenticated `POST /v1/login` requests naming `ada` made ada's own sign-in answer `429` **with the correct password**, and made her password change answer `429` on a valid session. Sustaining it cost about eleven requests every fifteen minutes. `relaycheck`'s "the lockout, which is the defect this key composition exists for" is that sequence, asserted. **Rejected.** The escape hatch the file used to claim — "somebody locked out of the web UI still has `cpctl`" — was retired rather than relied on: a user created today gets no API key at all, so it was only ever true of the bootstrap admin. **Known limitation, stated at the constant rather than hidden.** Behind a reverse proxy that does not set `x-forwarded-for`, every caller shares the proxy's socket address, so `loginKey` degrades back toward one bucket per name and `addressKey` toward a global counter; an office behind one NAT is the same shape with no proxy at all. Where there *is* no proxy the header is caller-supplied and wins, so a sprayer can mint a fresh bucket per request and can aim at somebody's real address. Neither is a regression — today's key needs neither — and what the composition buys is that a block is at worst as narrow as the address somebody chose, instead of fleet-wide for a name anybody can type. An attacker spread across many addresses is bounded by Q1.39, not by this file. **Status.** Current ### Q1.38 — When is a failed attempt recorded, relative to the KDF? **Rule.** Before the `await`, and un-recorded by `succeed()` if the password turns out to be right. **Why.** `check` is synchronous and `fail` used to run only *after* `await verifyPassword`, so every guess that arrived inside one KDF window saw a counter nothing had incremented yet. The throttle was therefore measuring the semaphore in `password.ts` rather than the guesses. Recording optimistically closes the window and costs nothing a wrong-then-right sequence did not already cost, because forgetting the failures on success is `succeed`'s whole contract. **Measured.** 40 concurrent guesses against one account reached **36** real verifications, against a documented threshold of 5. After the change the number that reaches the KDF is threshold + 1 — the request that trips the block passed `check` before its own `fail` set it — and `relaycheck` asserts `<= 6` with the old figure quoted in the report line. **Rejected.** Making `check` and `fail` one call. The class already supports the optimistic pattern, and a combined `checkAndFail` would have to answer for the success path anyway; naming `succeed` as "what un-records one" was the smaller change and is the sentence the docblock now leads with. **Status.** Current ### Q1.39 — Why are there two hashing lanes rather than one gate? **Rule.** `HashLane` is `"public" | "authenticated"`, required at every call with **no default**. Four hashes may run at once; at most two of them may be public. The wait lists are per lane — 32 authenticated, 16 public — and `release` wakes an authenticated waiter first, unconditionally. **Why.** The login route reaches the KDF on *every* request by design: an unknown name verifies against the decoy so the timing says nothing (Q1's login-oracle rule). So a spray of **distinct** names is a spray of real hashes, and no per-identity threshold ever sees it — which is exactly the shape Q1.37's address backstop exists for, and neither defence is sufficient alone. With one gate of four, the remedy for the flood was denied by the flood. The decoy takes the *same* lane as a real verification rather than being pinned to `"public"` inside: time spent queued is time a caller can measure, so a decoy waiting in one lane while a real verification waits in another would rebuild the user oracle out of the defence against flooding — under load, and therefore exactly when somebody is looking. **Measured.** Before the split, 36 hashes in flight and every login *and* every admin password reset answered `503 overloaded`. `relaycheck` sprays 40 distinct names from 40 distinct addresses (both counters have to be missed for the spray to reach the KDF at all) and asserts that an authenticated reset still completes through it. **Why the numbers are what they are.** Four is a **memory** bound, not a CPU one — at N=2^15 each hash holds 32 MiB for its duration, so four is ~134 MiB peak and a hundred simultaneous attempts would be 3.2 GB and a kill rather than a slowdown. It is deliberately far below `UV_THREADPOOL_SIZE` (64), which is shared with `serveStatic`: a gate at the pool's size would let a spray against this endpoint stop the login *page* loading. The per-lane wait lists total 48 against 32 before, which is the one place the split changes a number rather than only routing — a shared list would let a spray fill it and refuse the password change anyway, i.e. move the starvation one door along rather than close it. **Cost, stated plainly.** Sign-in throughput is halved under no load at all, ~20 logins a second against ~40. Nobody reaches that on a fleet that is one person and their machines. **Status.** Current ### Q1.610 — Can an admin reset their own password without giving it? **Rule.** No. `proveSelf` requires and verifies `currentPassword` whenever `:id` is the caller's own, on **`POST /v1/admin/users/:id/password`** and **`POST /v1/admin/users/:id/keys`** — the same question `POST /v1/me/password` has always asked, in the same place, with the same throttle key and the same `PasswordBusyError` → `503`. **Both routes are since deleted; see the supersession at the end, and read the measurement rather than the rule.** **Why.** Those two routes are `/v1/me/password` reached by another door, and they carried nothing. `disable` and `delete` both have self-refusals; these had none. So a session token lifted from an admin's tab converted into ownership of the account in exactly one request — and the real owner was locked out, because the reset revokes every session and (now) every key. **Measured.** Driven against a real app: `/v1/me/password` correctly refused without `currentPassword`, while `POST /v1/admin/users//keys` answered `201` with a permanent key and `POST /v1/admin/users//password` reset the password outright, both asking nothing. **Rejected.** Refusing self outright, which was the obvious symmetry with `cannot_disable_self`. It is wrong here: an admin resetting their own password because they think it leaked is the ordinary case, and on the single-admin deployment `install.sh` creates there is nobody else to ask. **Consequence.** `POST /v1/admin/users/:id/keys` now reads a JSON body, which it never did. Aimed at anybody else the body is ignored; the migration exception is `/v1/me/password`'s, unchanged — a caller with no `user_passwords` row is allowed through, because their API key was already full authority and requiring a password they have never had would make the migration impossible rather than safe. **Superseded — the guard became a deletion, which is the stronger form of the same fix.** Q7.74 deleted both routes this entry guards, plus `withKey` on `POST /v1/admin/users`, on the observation that this entry's "Rejected" paragraph had already half-made: guarding an admin's door into somebody else's account still leaves the door. The answer to the heading is now *there is no admin password reset at all*, for anybody's account including your own; re-keying yourself is `POST /v1/me/password`, and getting back in without the current password is the mailed link at `POST /v1/reset`. What survived is the question rather than the helper. `proveSelf` is gone; `proveCurrentPassword` is what is left of it, and app.ts says so at the definition — *"What is left of `proveSelf` after the two admin credential routes were deleted"*. It asks the same thing with the same `passwordChangeKey(caller.userId)` and the same `PasswordBusyError` → `503`, about **the caller** and never about a parameter, on the two `/v1/me/*` routes that are now the escalation this entry found: `POST /v1/me/keys` (minting a permanent credential from a borrowed session) and `PUT /v1/me/email` (the address is the reset channel, so repointing it *is* taking the account). `POST /v1/me/password` verified inline then — since 2026-09-05 it goes through `verifyCurrentPassword`, shared with the email route's API-key arm (Q1.630, amended) — because it is also the route that must let an account with no password row set a first one. **The measurement above is the part worth keeping**, and it is why this is superseded rather than deleted: `POST /v1/admin/users//keys` answered `201` with a permanent key and `POST /v1/admin/users//password` reset the password outright, both asking nothing. That is what those routes did on the day somebody looked, and it is the evidence for removing them rather than hardening them. **Status.** Superseded — both routes are deleted; the question moved to `proveCurrentPassword` on `/v1/me/keys` and `/v1/me/email`, on 2026-09-04 left `/v1/me/keys` again by the owner's decision (Q1.630), and on 2026-09-05 `proveCurrentPassword` went too: `/v1/me/password` and the API-key arm of `/v1/me/email` share `verifyCurrentPassword` (Q1.630, amended) ### Q1.611 — Can an API key be revoked? **Rule.** Yes, now. `revokeApiKey` is the one *targeted* write, behind `DELETE /v1/me/keys/:keyId` and `DELETE /v1/admin/users/:id/keys/:keyId`; `POST /v1/admin/users/:id/password` sweeps the account's keys as well. Two `UPDATE api_keys` statements, three routes — and `POST /v1/me/password` is deliberately not one of them. **The sweep and its route are since deleted, which takes the count to one statement and two routes; see the supersession at the end.** **Amended 2026-09-06.** The admin route is deleted too (Q1.631): one statement, one route, the holder's own. **Why it is here at all.** `callerAuth` read `api_keys.revoked_at` and answered `api_key_revoked`, so from both ends the capability looked present — the schema has the column, the middleware honours it — while there was **no `UPDATE api_keys` anywhere in the service**: three INSERTs, one SELECT, and a DELETE inside user deletion. A key was therefore immortal until the account holding it was deleted outright, which on a single-admin deployment means a leaked admin key had no in-band remedy at all. This is the failure `sessionOf` is named for, in the other direction: **a property the code appears to have and nothing enforces is worse than one it visibly lacks**, because the reader stops looking. **Why an *admin* reset takes the keys with it and a self-service change does not.** `POST /v1/admin/users/:id/password` exists precisely for the case where somebody else may hold the account, so a reset that leaves a permanent, never-expiring credential in place has handed the account back to whoever has that instead — and an API key is the one credential here that outlives a password change, which is what made leaving it a *silent* failure rather than a partial one. `POST /v1/me/password` revokes every session but the caller's and leaves keys alone, because a key is a separate credential with a separate lifecycle and `cpctl` is holding one; somebody who thinks their key leaked revokes the key. Uniform on self, deliberately: a rule with an exception for the caller is one somebody has to remember. The consequence is stated rather than special-cased — a `cpctl` shell holding that key stops working, including the one `install.sh` persists into `~/.reemoat/cpctl.env` and `deploy/lib.sh`'s `cpctl()` reads, and the way back is `POST /v1/admin/users/:id/keys`. **Why everybody gets the route and not only an admin.** The person most likely to know a key leaked is the person who pasted it somewhere. Revoking the key you are *holding* is allowed, and refusing it would be refusing the whole point. **What a listing may say.** `apiKeyRows` returns id, prefix, `createdAt`, `revokedAt` — never the hash and never the key, which is unrecoverable by construction. Revoked rows are **listed** here and **filtered** from the count on `GET /v1/admin/users`, and the asymmetry is the two questions: the count answers "how many credentials still work", the list answers "is the one that leaked dead yet", and a row that vanishes on revocation cannot answer the second. **Measured.** `relaycheck`: a key revokes, `GET /v1/me` immediately answers `401 api_key_revoked`, a second revoke is `404`, revoking it as somebody else's is the same `404` (the `user_id` clause, not the route, is what makes that true), a reset retires both of an account's keys and the key that made the request stops working, and the admin list then counts zero. **Superseded — one statement, two routes, and no sweep anywhere.** Q7.74 deleted `POST /v1/admin/users/:id/password`, so the third route and the second `UPDATE` went with it. What is left is `revokeApiKey` alone: ``` UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL ``` reached by `DELETE /v1/me/keys/:keyId` and `DELETE /v1/admin/users/:id/keys/:keyId` and by nothing else (**amended 2026-09-06**: the second of those is deleted by Q1.631, so it is reached by `DELETE /v1/me/keys/:keyId` alone, and the count on `GET /v1/admin/users` two paragraphs up is gone with it). **No password change on this service retires a key** — not the self-service one, which never did, and not the mailed reset that replaced the admin's, which counts the account's live keys into `apiKeysActive` and revokes none of them. The paragraph above arguing that an admin reset *must* take the keys with it is not reversed, it is unreachable: there is no route for the case it was about, because an admin can no longer enter an account at all. Two consequences the earlier text has backwards. The way back from a revoked key is **not** `POST /v1/admin/users/:id/keys`, which is deleted — it is `POST /v1/me/keys`, self-service, behind the session alone since Q1.630, which is also the first thing in this service that ever bounded the table (**10 live per account**, because until now only an admin could write to it). And "somebody who thinks their key leaked revokes the key" stopped being advice and became the only mechanism: the reset does not do it for them, so the sentence is now load-bearing rather than a preference between two remedies. **What the driver asserts now.** The same list, minus the reset arm and plus its inverse — `relaycheck` mints a key through `POST /v1/me/keys` because nothing else can (`withKey` is deleted), then checks *"changing your own password leaves your keys alone"* by counting unrevoked rows before and after. The admin-list count and the listed-but-revoked row are unchanged. **Status.** Superseded — the sweep went with the admin reset; one `UPDATE api_keys`, two routes, and no password change retires a key ### Q1.42 — Does an enrollment code outlive the person who minted it? **Rule.** No, and it does not outlive their *access* either. `burnUserCodes` runs inside `DELETE /v1/admin/users/:id`'s existing transaction marking every unredeemed code `used_from = 'user_deleted'`, and inside `POST /v1/admin/users/:id/disable` — which has no transaction of its own, having never needed one — marking them `'user_disabled'`. The reason is a **required** `usedFrom: UserCodeBurnReason` parameter rather than a literal inside the function, so a third caller cannot get the forensic column wrong by omission. **Why.** The delete swept every table that authenticates as them — sessions, origins, passwords, API keys, grants, ownership — and not this one. An unredeemed enrollment code *is* a credential: `/v1/enroll` sits above THE LINE and asks only whether the code is unused and unexpired, and redeeming one answers with the machine id and the fleet's public keys **and** calls `issueTunnelKey`, which retires whatever tunnel key that machine currently holds. So a just-deleted account could take a live machine off the relay and put itself on it, using a code minted a minute before it was removed. **Why the same sentence had to be applied to `disable`.** Scoping the rule to the delete route left it reachable through the *reversible* remedy, which is the one an admin reaches for first. Every other credential that authenticates as a disabled person is refused by `callerAuth`, which reads `disabled_at` live — but `/v1/enroll` sits above THE LINE, has no caller at all, and its claim asks only whether the code is unused and unexpired: never `created_by`, never `users.disabled_at`. So the paragraph above was true of a banned account word for word. The invariant is about the **code**, not about the route, and writing it down as a property of one route is how it came to have a hole. **`enable` does not give them back**, and the cost is stated rather than engineered away: a mistaken disable spends one live code (1 hour TTL; minting another is one request), which is the same trade `enable` already makes about revoked sessions. **Rejected.** Extending the burn to `POST /v1/admin/users/:id/password`, which swept sessions and API keys on exactly the argument that would justify it. That route was also the *self*-recovery path — an admin re-keying their own account after a suspected leak — and burning your own in-flight daemon install is a cost nothing has measured. Left open at Q7.61 rather than decided by symmetry, and **closed there by deletion**: the route is gone (Q7.74), and `POST /v1/reset`, the mailed link that replaced it, deliberately does not burn codes either. The argument above is why, and it did not have to be re-made — proving control of your own address is not evidence that a daemon you enrolled is compromised. So the list stays at two writers, `delete` and `disable`. **`created_by` is still left dangling, on purpose.** That is the distinction the delete route's own comment draws and this does not reopen: the row's job is to say what happened, and rewriting history to keep a join valid is the opposite of an audit trail. The code is burned; the record of who minted it survives naming a user who is gone. `'user_deleted'` rather than `'revoked'` because they are different events and that column is the only forensic trail there is — the same reason `mintEnrollmentCode` writes `'superseded'` for the code it burns. **Why it is in `keys.ts`.** The same argument `mintEnrollmentCode` and `burnMachineCodes` already make one line above: two hand-written copies of one `UPDATE` is how two routes come to disagree about one of its clauses. It is synchronous, like every other statement in that block, so it is safe between `BEGIN` and `COMMIT` on a connection every other writer shares. **Measured.** `relaycheck` deletes a user holding one live code and asserts the response's `enrollmentCodesInvalidated`, and that the row reads `user_deleted` rather than `revoked`. Beside it, the disable mirror: the response reports the count, the held code answers `409 code_unusable` at `/v1/enroll`, the row reads `user_disabled` and **not** `user_deleted` — the two acts stay apart in the only forensic column there is — and `enable` succeeds with the code still burned. **Status.** Current ### Q1.43 — Can a machine's owner be released, and can it be given back? **Rule.** Both. `releaseOwner` drops the `machine_owners` row inside the same transaction as the revoke, on both revoke routes; `PUT /v1/admin/machines/:id/owner` writes one, with the grant, for a machine that has none. **Why release.** `machine_owners` has no `revoked_at`, and its unique index on `(user_id, label)` does not join `machines`. So a revoked machine went on holding two things for ever: **the label**, and **one of `MAX_MACHINES_PER_USER`** — the quota is counted with no revoked filter. Revoke `laptop`, create `laptop` again, and the answer is a `409` naming a machine that appears in no list and can never be reached; fifty revocations and the account cannot add a machine at all, with nothing on screen to explain it. The `machines` row itself is untouched and stays revoked: the audit trail of which machines existed is in *that* table. **Why reassignment.** `INSERT INTO machine_owners` happened in exactly one place, `createOwnedMachine`, which always mints a fresh id. So *becoming* an owner was inseparable from *creating* a machine, and ownerless was a one-way state: `POST /v1/admin/machines {ownerId}` reads like an adoption and creates instead, and after `DELETE /v1/admin/users/:id` reports `machinesReleased` those machines could never have an owner again — rename, re-enroll and revoke all resolve through `ownerOf` and answer 404 for the life of the row. A person leaving the fleet stranded their hardware, and the only remedy was editing SQLite. **Why the grant goes with it.** `GET /v1/machines` joins `grants`, so an owner with no grant owns a machine that appears in no list — the exact failure user-owned machines exists to remove, and the lesson `createOwnedMachine` already records. Every scope, for the same reason: without `machine:admin` the owner cannot remove a workspace on their own hardware. **Rejected.** Also moving the *previous* owner's grant. Ownership and access are different things here — two people may hold a grant on one machine — and taking somebody's access away is its own verb with its own audit story. A revoked machine is refused rather than adopted, because revoking is what *frees* the label and the slot and handing one back would spend both on something nothing can reach. ⚠ **Superseded in part, and named here rather than quietly rewritten.** That verb used to be `DELETE /v1/admin/grants`, which is deleted: taking somebody's access away is the machine owner's `DELETE /v1/machines/:id/grants` now, because a grant is full access to a machine that runs agents as its owner (Q1.633). And the case this paragraph leaves the previous owner's grant alone *for* is one this route can no longer reach: it refuses `403 machine_owned` on any transfer away from a live owner (Q1.635), so the only previous owner left to leave alone is the same user being handed the machine back. Read it as the reason `releaseOwner` is followed by an insert rather than by a sweep of `grants`. **Status.** Current, amended by Q1.633 and Q1.635 ### Q1.44 — Is the enrollment paste shell data or shell source? **Rule.** Data. `shellQuote` single-quotes both values in `packages/web/src/enrollment.ts`, and `enrollmentLines` in `cpctl.ts` does the same, `'\''` included. **Why.** `controlPlaneUrl` is `publicUrl(c)` — `new URL(c.req.url).origin` — so it comes from the request's own `Host` header, which anybody who can reach the service writes. The three lines are then pasted into a shell on another machine by a person who is looking at a plausible URL. This is the identical rule `deploy/lib.sh`'s `set_env`/`sq` already applies to the env file after the `REEMOAT_ENROLL_CODE=xy$(touch PWNED)` incident (Q7.57); it was applied to the file and not to the paste, which is the same text arriving by hand into the same shell. **Measured, 2026-08-08, through a real `node:http` server.** ``Host: a`id`b``, `a$(id)b`, `a'b` and `a;id` **all** reach `URL.origin` intact — including the apostrophe, which is why plain quoting without the escape would still be steppable-out-of, and which makes the `'\''` arm reachable rather than defensive. The failure was measured too: sourcing ``export REEMOAT_CONTROL_PLANE=http://a`touch PWNED`b`` creates the file and leaves the variable reading `http://ab`, so the person pasting sees a plausible URL and nothing else. **How the two copies are kept agreeing.** By running one of them. `webcheck.shell-and-enrollment.ts` reads `cpctl.ts` off disk, slices `enrollmentLines`'s body out (header line to a bare `}` in column 0), strips the one TypeScript annotation in it, and hands it to `new Function` — then compares five value pairs against the web copy and asserts that `|| BASE_URL` is the *only* permitted divergence. The docblock used to claim the two were "kept byte-identical … on purpose" with nothing enforcing it, which is the failure mode `sessionOf` is named for; comparing behaviour rather than a transcription of it is what closed it. **Status.** Current ### Q1.45 — Does `?token=` authenticate anything other than the WebSocket handshake? **Rule.** No, on the daemon. `readCredential` reads `?token=` only when the request carries `upgrade: websocket`; a present-but-malformed `Authorization` header still short-circuits to a failure rather than falling through, as it always did. **Why.** The exception has one justification and it has always been written down — "a browser cannot set headers on a WebSocket" — but nothing ever *narrowed* it to that case. `readCredential` is called from the single `app.use("*")` gate, so `GET /sessions/s_1/files?path=chart.png&token=` returned the file's bytes to a URL that lands in browser history, in `Referer`, and in the log of every intermediary, on the very origin whose `localStorage` holds `reemoat.credential`. Q2.38 refused `` partly on the grounds that it "would widen the `?token=` exception" — an argument about discipline against a hole that was already open. **Keyed on the handshake header, not on the stream route's path.** A route reader fails open the day it falls behind; the header is the thing the exception is actually about. **What it does not close.** Somebody who already holds the token can still craft the request by hand. What is closed is the URL a browser *follows*, which never carries that header. **Measured.** `daemoncheck`, inside "the stream, over a real socket": `GET /sessions?token=` and `GET /sessions/:id/files?path=notes.txt&token=` are both 401 with no header, the header path is still 200, and the same token on a request carrying `Upgrade: websocket` still authenticates — the existing `attach()` helper, which passes its token only in the URL as a `ws://` client must, is the live positive control. Reverting turns both 401s into 200s, one of them serving a file. **Consequence.** The relay is deliberately **not** narrowed the same way: its `readToken` reads the query on both paths, because that is where the browser's socket arrives. A relayed non-upgrade request carrying a query token is therefore authorized at the relay and then refused by the daemon. **Status.** Current ### Q1.46 — What happens to a request target Node's HTTP parser accepts and `new URL` rejects? **Rule.** It is answered `401 missing_token` and its socket is released. `readToken` returns `null` when the URL parse throws. **Measured, in this repo's Node.** `GET //% HTTP/1.1` is accepted by llhttp and handed to the handler as `req.url === "//%"`; `new URL("//%", "http://relay")` throws `Invalid URL`, as do `/\` and `//[`. Both halves were re-measured before the patch, because either one moving makes the case vacuous. **Why it mattered.** `readToken` runs **first** on both `handleRequest` and `handleUpgrade`, before `authorize`, so the throw was reachable with no credential at all against the only listener this system requires to be internet-facing. It escaped the `'request'`/`'upgrade'` emit into `main.ts`'s `uncaughtException` backstop — which keeps the process alive while nothing writes a response and nothing destroys the socket: `requestTimeout` was already cleared and `keepAliveTimeout` only arms once a response is sent. One unauthenticated line per leaked fd, until the limit stops every daemon dialling in. **`pathOf` had the identical guard and it was dead code**, because `readToken` parsed the same URL first and threw. The guarded copy was the one nobody could reach; the unguarded one was on the unauthenticated path. **Rejected.** Wrapping the whole bodies of `handleRequest`/`handleUpgrade` in try/catch as belt and braces. Those are two hot paths whose refusal arms already write to the socket, and it would convert any *future* throw into a swallowed 400 rather than the `uncaughtException` log that is currently the only signal such a bug exists. The named hole is closed at its one source; a second escaping throw remains possible in principle and is now the only remaining case. **Measured.** `relaycheck` sends all three targets on a raw `node:net` socket — `fetch` and `ws` both normalize the target through the parser this is about, so there is no other way to send one — on the request path and on a real `Upgrade:` handshake, and asserts `401` with `missing_token` in the envelope and `requestsProxied` unmoved. Reverting the guard does not merely redden those lines: it takes the driver down with an uncaught exception, which is what it does to the service minus the backstop. **Status.** Current ### Q1.47 — Can a mistyped relay URL take the daemon down? **Rule.** No. `RelayTunnel.dial` **checks** the scheme rather than assigning over it, emits `rejected` naming the scheme, and does not retry; `new WebSocket` is inside its own try for the same outcome. **Why.** `target.protocol = "ws:"` is a silent no-op when the URL's scheme is not one the spec calls special. So `htps://relay.example` — which `new URL` accepts, and which is therefore accepted by `enroll.ts` and by the control plane's own `main.ts`, both of which "validate" a relay URL with that constructor — kept `htps:`, fell through the old guard, and threw out of `new WebSocket` **outside** the try, into a process `scripts/daemon.ts` gives no `uncaughtException` handler. Under a unit with `KeepAlive`/`RunAtLoad` that is a permanent crash loop whose every pass re-runs `restore()` and auto-resume, spawning agents that are killed seconds later. The file's own header promises that a relay which is down, unreachable or rejecting costs nothing but log lines. **`ws`/`wss` are in the allowed set** beside `http`/`https`, because a URL already stored in that form must still dial — the guard is "these four", not "not-http". **Still not done.** Validating the scheme where it is *claimed* to be validated. Three call sites construct a `URL` and conclude the value is good; none of them looks at what came back. **Measured.** `daemoncheck` drives `RelayTunnel.start` with the typo and asserts it does not throw, reports `rejected`, names the scheme and dials nothing; that an unparseable URL is still refused by the arm that already existed; and that `ws://127.0.0.1:1` still reaches `connecting` with its scheme intact, which is what pins that the set was *widened* rather than the guard "fixed" by refusing everything non-http. **Status.** Current ### Q1.48 — Which paths must ask whether a machine's name is already visible? **Rule.** All five. `nameVisibleTo` guards `POST /v1/machines`, `PATCH /v1/machines/:id`, `PUT /v1/admin/machines/:id/owner` and the `ownerId` branch of `POST /v1/admin/machines`; `nameVisibleToGrantees` guards `PATCH /v1/admin/machines/:id`. **Why a wider check than the index.** `machine_owners` is unique on `(user_id, label)` and that index is BINARY and knows nothing about a machine somebody merely holds a *grant* on, or a legacy row they see under `machines.name` — which are exactly what a second `laptop` collides with in the only list a human reads. Two indistinguishable rows, and `resolveMachineRef` tries `machine_owners.label` before `machines.name`, so `POST /v1/tokens {machine: "laptop"}` silently resolves to one of them and the other becomes unreachable by name. **Why the admin door needed it too.** `POST /v1/admin/machines {ownerId}` is the route `deploy/install.sh`'s daemon wizard drives, and it went straight to `createOwnedMachine` — i.e. relied on the index alone — so an admin registering a machine *for* somebody produced exactly the state the guard was written to prevent. **Why the rename needed a different function.** A legacy row has no owner to ask about: `PATCH /v1/admin/machines/:id` checked only `SELECT id FROM machines WHERE name = ? AND id != ?`, which cannot see the collision, because an *owned* machine's `machines.name` is `qualifiedName(label, id)` and never the bare label. So renaming an ownerless machine to `laptop` shadowed the `laptop` its own grantee owns, with the uniqueness check passing. `nameVisibleToGrantees` asks `nameVisibleTo` once per user holding a grant. **Measured.** `relaycheck` uses only the two shapes that discriminate, and says so: posting a label the owner already owns *in the same case* is caught by the index and answers the identical 409 either way, so a case built on it would have been green before the guard existed. What is asserted is an ownerless legacy machine the target holds a grant on (refused through both doors, as a pair) and `DESK` against an owned `desk`, which the BINARY index cannot see at all. **Status.** Current ### Q1.49 — Can `cpctl` reach the two routes that require proving your own account? **Decision.** Yes, by prompting. `selfProof(targetUserId)` GETs `/v1/me` and, only when the target is the caller **and** `hasPassword` is true, asks for the current password and returns it as the body; otherwise it sends nothing and asks nothing. **Why.** Q1.610 made `proveSelf` require `currentPassword` on `POST /v1/admin/users/:id/password` and `POST /v1/admin/users/:id/keys` aimed at self, and `cpctl` sent no body at all — so both answered `400` about a body the CLI had never been asked to send. That made the single-admin recovery path unreachable from a terminal, which is the one place it matters: `install.sh` itself prints `pnpm cpctl admin passwd `, and the web reset sweeps every API key including the one `cpctl` is holding. **Why it asks nothing when the target is somebody else.** A CLI that prompts for a password it then discards trains an operator to type one whenever it asks. The `hasPassword` question is the migration exception, mirrored from `case "passwd"`: `proveSelf` lets an account with no password row past on its API key, so asking for a secret it has never had would be theatre. **Consequence.** One extra `GET /v1/me` per invocation, and aimed at yourself in a non-tty context these now block reading stdin where they used to fail fast with an unusable 400. `proveSelf` also stopped answering "expected a JSON object body" — a null body falls through to the `currentPassword` check, so the 400 names the field — with the same status and code, so `authFailure` reads it identically. **Measured.** `relaycheck` runs cpctl's own `selfProof` body, extracted off disk the way `webcheck` runs `enrollmentLines` (the file cannot be imported: its module body reads `process.argv` and dispatches, and neither symbol is exported), against the live app with a stubbed `api` and `readSecret`. What it used to send is a 400 from the real route; what it sends now mints the key with a 201. The call-site wiring is a source-substring count and the comment beside it says so, because driving it needs `main()`, a listener and a subprocess. **Superseded — there are no such routes, so there is nothing to aim at an `:id`.** Q7.74 deleted both, and `cpctl`'s `admin passwd` and `admin key` went with them; `relaycheck` now asserts their *absence* by the same source-reading technique, `/case "passwd"/` and `/case "key"/` tested against the slice of `cpctl.ts` after `async function admin(` and expected `false`. Proving a deletion is the point: nothing else here would notice a route quietly still registered, and "we removed it" is the kind of claim that survives a revert. What is left of `selfProof` is `currentPasswordBody()`, which takes **no argument** — that is the whole difference, and it is what makes the heading unanswerable rather than answered. There is no target to compare against `me.id`, so the `hasPassword` question is the only one left: `GET /v1/me`, and if there is a password row, prompt for it and send `{currentPassword}`; otherwise send `{}`. It serves `cpctl passwd` and `cpctl key`, both of which are now about the caller by construction. **The `bodyOf` extraction is deleted, and the order in which that had to happen is the durable lesson.** It threw at module scope when the named function was missing, so removing `selfProof` from `cpctl.ts` without removing the driver block made `pnpm relaycheck` **crash on import** rather than report a failure — and a driver that dies takes its other nine hundred assertions with it. A source-reading check that fails loudly is right; one that fails *before the run starts* is not. **Status.** Superseded — both routes are deleted; `selfProof` became the argumentless `currentPasswordBody`, and the driver now asserts the deletion ### Q1.50 — What happens when two admins create the same user at once? **Rule.** The loser gets `409 user_exists` in the error envelope. `POST /v1/admin/users` catches its `ROLLBACK`ed insert and maps a uniqueness failure through `isUniqueViolation`, which `machines.ts` now exports rather than having it written a third time. **Why the read cannot be moved.** The guard is `SELECT id FROM users WHERE name = ?` and the INSERT is on the other side of ~51ms of scrypt, so the window is the full KDF and no reordering closes it. Rethrown, the violation reached Hono's default handler and answered `500 text/plain` — a body with no `error.code` — from the request that answers `409 user_exists` a second later. **What the `ROLLBACK` in front of the 409 protects** is not visible in the 409: the loser throws on the first statement inside its transaction, and what an un-rolled-back `BEGIN` takes out is the *next* writer on the shared connection. **Rejected.** An `app.onError` envelope renderer. That is a service-wide contract change rather than the smallest correct fix, and it would mask the next unmapped throw instead of surfacing it. **Consequence, said out loud rather than claimed away.** `isUniqueViolation` is a string match on the driver's message, so in principle a primary-key collision maps to `user_exists` too. Every other unique column in that transaction is a PK holding an id `newId` just minted from fresh random bytes. **Measured.** `relaycheck` fires two concurrent creates of one name: statuses sort to `201,409`, the loser's `content-type` is `application/json` and its code is `user_exists` (a non-JSON body is reported with its text, because that *is* the defect), exactly one user row and one password row landed, and the next create still answers 201. **Status.** Current ### Q1.51 — How many machines may one person run, and what happens when that number goes down? **Decision.** A configurable limit beside the existing ceiling, and **being over it is derived, never stored**. A machine is over iff its *rank* among its owner's machines — ordered by `(machine_owners.created_at, machine_id)` ascending — is `>= effectiveLimit(owner)`. Lowering the limit switches off the ones acquired most recently; raising it switches them back on. Nothing is written to a machine either way. **Why a second bound rather than making the first one configurable.** `MAX_MACHINES_PER_USER = 50` has its own recorded argument, and it is anti-abuse: creating a machine is reachable by anybody with a password, and each one is a row plus a code plus a tunnel credential against a `synchronous = FULL` file in the process carrying every tunnel. The new one is commercial — a number an admin *raises to sell*. Collapsed into one, an admin typing 500 for a customer has silently removed the database-growth bound, and that failure is not a refusal anybody sees: it is a slow instance. So the ceiling is refused on both write paths and clamped again on read, and the configurable limit can only ever be lower. **Why derived rather than a `suspended` column.** A stored flag needs a writer on every path that could change the answer — create, revoke, adopt, delete, disable, and both limit writes — and the one that gets forgotten is the one that leaves somebody's machine dark after the admin has already fixed it. Derived, three things are free: raising un-suspends with no recompute; **revoking promotes**, because `releaseOwner` deletes the ownership row and every later rank decrements; and a create/count race cannot overshoot the bound, since an extra machine is simply rank ≥ limit and does not work. That last one is why nothing here needs a transaction it did not already have. **Why acquisition order and not last connection.** "The most recently connected machine stops working" reads as ordering by tunnel activity, and that oscillates: the suspended machine cannot connect, so its last-connect timestamp stays old, so it becomes the *oldest*, so it un-suspends and takes the other one down instead — on every poll, for ever. **Why the tiebreak is not tidiness.** `created_at` is `Date.now()` and two machines acquired in the same millisecond are reachable from a script. Without the `machine_id` half of the row-value comparison both rows count zero older siblings, both report rank 0, and an account with a limit of 1 keeps two working machines — the bound wrong by one, silently, which is the only way a commercial limit fails that nobody notices. `relaycheck` inserts two rows sharing a timestamp for exactly this. **Why `rowid` is not the ordering.** `releaseOwner` DELETEs and SQLite reuses rowids unless the table is `AUTOINCREMENT`, so a revoke-then-create would silently reorder somebody's fleet. **Where the check sits, and why last.** After the grant is proved, in `relay/authorize.ts` and in `POST /v1/tokens` alike. Both routes answer the same refusal for "no such machine" and "no grant" so a caller cannot map the fleet; this one names a real state — which is the point, because the owner has to see *why* their machine stopped. Asked any earlier it is an enumeration oracle: any valid token would report whether an arbitrary `aud` exists and is over somebody's limit. `403 machine_over_limit`, matching `no_scopes` rather than the shared 404, because the caller is known and the machine is present. **Why the tunnel is refused at dial, unlike a plain refusal.** Letting an over-limit daemon hold a tunnel that carries nothing makes `relayOnline` a lie — it is read by `POST /v1/tokens` and `GET /v1/machines`, so the machine presents as *online and broken*, indistinguishable from a relay fault. Refused at dial it is `relayOnline: false`, which every client already draws and explains, plus `overLimit: true`, which turns "asleep" into "switched off, and here is why". The daemon's existing 1s→30s full-jitter backoff then becomes the recovery mechanism rather than a cost. **Unset means 50, and that is load-bearing rather than a default.** Nothing seeds `instance_settings`, deliberately, so this setting is unset on every existing deployment. If unset resolved to 0 the next deploy would take every machine in the fleet off the network with no operator having acted. Unset means *exactly the behaviour before this setting existed*; 0 is a value somebody chooses. **An ownerless machine is unlimited**, because there is no owner to have a limit. See Q7.95. **A bug this feature created, and fixed.** `PUT /v1/admin/machines/:id/owner` did `releaseOwner` + `INSERT … created_at = now` unconditionally. For a transfer that is right — the machine becomes the new owner's newest acquisition. But that route is *also* the admin's only re-label, which is why its own count excludes `machine_id != ?`, and re-stamping there moved a machine to the back of its own owner's queue and silently changed which of their machines the limit switches off. It preserves `created_at` when the owner is unchanged now. **Measured.** `relaycheck` drives the whole of it: three machines inserted in the order 3, 1, 2 so an implementation ordering by rowid, id or insertion would agree with a naive fixture and disagree with this one; raising un-suspends with the same token and no other act; revoking the oldest promotes the one that was over; two rows sharing a timestamp still rank apart and the same one twice; a caller with no grant still gets the shared 404; a lowering deletes nothing and reports what it switched off. **Status.** Current ### Q1.52 — What should banning somebody do to the machines they own? **Decision.** Switch them off, **reversibly and without revoking anything**. A machine whose owner is banned is refused at the relay, refused at the tunnel dial, and refused a token — derived from `users.disabled_at` by `quota.ts`, on the row it already reads, so it costs no extra query on the per-request path. `enable` brings the whole fleet back on the daemons' own backoff with nobody touching a host. **The hole this closes, which had nothing to do with quotas.** `relay/authorize.ts` reads `disabled_at` for the **caller** — `activeUser(db, claims.sub)` — and nothing anywhere read it for the *owner*. So banning somebody stopped them signing in and stopped nothing else: every machine they owned went on serving anybody holding a grant, and their daemons went on holding tunnels. The ban looked total and was not. **Why not revoke instead.** `disable` is the **reversible** remedy — that is its whole job, against `delete` which is not — and revoking a machine is not reversible in the part that matters: it must be registered again and enrolled again *on its host*. A ban that revoked machines would make the undoable act undoable everywhere except where it costs a trip to the hardware, and a mistaken ban would cost somebody their entire fleet permanently. Derived costs nothing and undoes itself. **Why a separate code from `machine_over_limit`, and separate from `user_disabled`.** Three refusals, three strings, and the middle one is a trap: `user_disabled` means *you* are banned and the client ends the session on it, so reusing it here would sign a perfectly good grantee out of the whole app for opening somebody else's suspended machine. `owner_disabled` is a fact about the machine, not the caller. It is in `meansMachineGone` (stop believing this route) and **not** in `authFailure` (end this session), and `webcheck` asserts both halves — the code-surface walk still answers "six codes end a session, and no more" with `owner_disabled` present in the table. **And separate from the limit** because the *remedies* differ: retiring a machine does nothing for one whose owner is banned. The badge and the subline order the ban first for that reason. **What delete does instead, and why the two differ.** `DELETE /v1/admin/users/:id` revokes their machines outright — see Q7.95. The person is gone, so there is nobody for a reversible state to be reversed *for*, and leaving the machines live-and-ownerless was the actual hole. **Measured.** `relaycheck` drives it through a grantee, which is the only caller who can reach it — a banned owner cannot get past `callerAuth` at all: reachable before the ban, `403 owner_disabled` after it, the body carrying `owner_disabled` and *not* `user_disabled`, reachable again the moment the ban lifts with the same token, and `machines.revoked_at` still null throughout. **Status.** Current ### Q1.53 — How does an admin add a daemon for somebody else? **Decision.** A fleet **provisioning key**: one long-lived credential, minted and rotated in Settings → Server settings, that authorizes exactly one route — `POST /v1/provision`, which creates a machine owned by a named user, raises that user's machine limit far enough for it to work, and mints the enrollment code. Nothing else. **Why anything was needed.** Adding a machine required a credential belonging to whoever would own it: their own sign-in for the web form, or an API key for `cpctl`. So an admin setting up a host for somebody had to borrow their account or hand them a credential. The admin's *own* key would work and is full authority over the fleet — not a thing to paste into an install script on somebody else's laptop. **The daemon never sees it, and that is forced rather than chosen.** This service's oldest invariant is that a daemon makes exactly one control-plane request, ever, at enrollment. So provisioning is an act of the **installer**: `install.sh` or `cpctl provision` trades the key for an ordinary single-use enrollment code and writes it into the env file. `enroll.ts`, the daemon and the tunnel path are untouched — which also means the key never lands on the host in a form the daemon would keep. **Above THE LINE, because it carries its own credential.** `callerAuth` resolves API keys and session tokens and would refuse a `pk_` outright, so this joins `/v1/enroll` as a route that authenticates itself. The public set is **ten** now, and two of them take a credential in the body. It is not an unauthenticated route; the credential is simply not a person's. It carries `PUBLIC_BODY_LIMIT_BYTES` and its own throttle namespace (`provisionKey`), and the throttle matters more here than for `/v1/enroll`: a provisioning key is long-lived and fleet-wide, so guessing it is the attack, where an enrollment code is single-use and lives an hour. **The limit is raised rather than the request refused**, and by `owned + 1` rather than `limit + 1`. An admin provisioning a machine has already decided that person may have it, and a `409` at that moment means an install script fails and somebody goes to change a number. `owned + 1` is exactly enough — incrementing an already-generous limit would quietly widen it — and it is written as a **visible override**, so Settings → Users shows where the number came from rather than an admin finding one they did not set. `createOwnedMachine` still clamps against `MAX_MACHINES_PER_USER`, so this cannot climb past the fleet ceiling. **One key, and minting is the only verb.** `POST` retires the previous row in the same transaction that inserts the new one: the reason to mint a second is that the first leaked, and a window in which both work is the window being closed. There is deliberately **no revoke and no "off"** — that would be a third state to reason about for a fleet that either hands out hosts or does not, and a key is useless to anybody who cannot also reach `POST /v1/provision`. **Nothing draws the key or any part of it.** `GET /v1/admin/provisioning-key` answers `{minted: boolean}` — not the value, not a prefix, not an id — because the only act available is minting another, so a richer projection would exist purely to be a second place a live credential can be read from. That is the rule `GET /v1/admin/settings` already follows by sending `null` for `smtp.password`. The row keeps its `prefix` and `id` regardless, so the trail is legible in the database even though no interface prints it. **A multi-key model was built and taken back out**, and it is recorded because the want behind it is real. Briefly the screen listed every live key with a per-row revoke, on the reasoning that a fleet might run one installer per site. What that cost was worse than what it bought: it made "which key is live" a thing to track, gave the screen a credential list to draw, and turned the leak remedy from one tap into finding the right row first. One key and one verb is the smaller thing that does the job. **Where the key may live, which is the rule this feature is easiest to get wrong.** It belongs on the machine you provision *from*, and **never on the host being provisioned**. That host runs a daemon, a daemon runs agents as the machine's owner, and `agentEnv`'s strip is hygiene rather than a fence — CLAUDE.md says so outright: the agent has that uid and can read the env file, `/proc//environ` and `REEMOAT_DB`. A fleet-wide credential there is any machine owner able to provision for anybody, permanently. `install.sh` asked for it on the host being set up, and the minted-key note said "put it in `REEMOAT_CP_PROVISION_KEY` where a host is installed" — both written here first and both wrong. The prompt is gone and the note is a warning. What travels to a host is the **enrollment code**, which is single-use and lives an hour: the blast radius of one machine rather than of the fleet. **Rejected: making the key single-use.** It was proposed for exactly the right reason — if the key reaches a daemon host it should be worth one machine rather than all of them — but with the key kept off that host the trade inverts. A single-use key is an enrollment code with owner selection, and this service already has enrollment codes; what makes the provisioning key worth having is that one credential sets up twenty hosts without the installer holding an account. Reuse *is* the feature. The leak remedy stays one remint, which retires the previous key in the same act. **⚠ The threat is not the obvious one.** Holding this key reads nobody's work. What it does is insert a machine of the holder's own into any user's list — and that user may then run agents on the holder's host. That is why it is a table with `revoked_at` rather than a value in `instance_settings`, why only a hash is kept, and why the screen says so above the button rather than below it. **`created_by` on the minted enrollment code is the key's id, not the owner's.** That column is an enrollment code's only forensic trail, and writing the owner there would record that they minted a code for a host they may never have heard of. A `pk_…` in it is greppable and true. **Refused rather than allowed:** provisioning for a **disabled** user, because their machines are switched off the moment they exist (Q1.52) and the installer would produce a daemon nothing can reach; and for an unknown user, with a 404 rather than a machine nobody owns. **Measured.** `relaycheck` drives the whole surface with **no `authorization` header at all**, which is the feature: it provisions, the machine lands owned and granted, the limit rises to exactly `owned + 1` as a visible override, rotation retires the old key and the old key stops working in the same breath, and the key authenticates neither `/v1/me` nor an admin route. The status route answers `null` after provisioning is turned off, and never carries a key in any case. **Status.** Current ### Q1.200 — Why is the JWS in `token.ts` hand-rolled rather than taken from `jose`? **Decision.** `token.ts` implements compact JWS over Ed25519 on `node:crypto`, by hand. **Why.** `jose` is in the lockfile and is unimportable — it is a transitive dependency of `@agentclientprotocol/*`, and pnpm's strict layout means `src/` cannot resolve it. The dependency being *present* is what makes this worth writing down: the lockfile says the library is there and it is not reachable from the code that would use it. **Status.** Current ### Q1.201 — Should a token rejected for time say how far out the clock is? **Decision.** Yes, in both directions and deliberately. `reportSkew` logs a rejection that falls inside `SKEW_DIAGNOSTIC_LIMIT_MS` — 5× `AUTH_LEEWAY_MS` — and the verifier returns `detail: {skewMs, daemonTime, leewayMs}` with the refusal. `/health` carries `time` unauthenticated. **Why.** A token rejected within 5× the leeway is far more likely to be a wrong clock than an attack, and silent skew is the classic failure in this shape of system: every signature is intact, every claim is right, and nothing anywhere says the two machines disagree about what time it is. Reporting it costs an attacker who already holds a token nothing they could not measure by trying again. **Status.** Current ### Q1.202 — What does a malformed `Authorization` header do? **Rule.** It is a failure, not a fallthrough. `bearerToken` tells a malformed header from an absent one, and a header that does not start with exactly `Bearer ` must not fall through to the `?token=` path. **Why.** It used to fall through whenever the header did not match exactly, so `authorization: bearer x` — a credential that was present, sent, and one capitalisation wrong — was reported as a *missing* token. The caller is then debugging the wrong half of the request. **Status.** Current ### Q1.203 — Why is `enroll`'s timeout cleared after the body is read rather than before? **Rule.** `enroll` must not swallow an abort while reading the body: the timeout is cleared *after* `response.json()`, and there is no blanket `.catch(() => null)` on that path. **Why.** `fetch` resolves as soon as headers arrive, so a control plane that sends a status line and then stops talking is still inside the request when the promise has already settled. Clearing the timer at that point leaves the body read unbounded, and a blanket catch around it turned the abort into a parse failure: a control plane that simply stopped talking reported `bad_response`, which names the wrong fault and points enrollment debugging at the payload. **Status.** Current ### Q1.204 — Is redeeming an enrollment code safe against a machine that is already up? **Behaviour.** No. Redeeming a code retires the machine's tunnel key (`issueTunnelKey` mints the replacement in the same call), so enrollment is destructive to any live tunnel for that machine. **Why.** It is a trap in a driver above all — a check that enrolls against a machine somebody is using takes that machine off the network for the length of the run. `relaycheck` redeems against its own machine for exactly this reason, and any new driver touching `/v1/enroll` owes the same discipline. **Status.** Current ### Q1.300 — Why does `cpctl admin settings` refuse a value on the command line for a secret? **Decision.** A key in `SECRET_SETTING_KEYS` takes **no** ``. It is prompted for with echo off, or read as one line off stdin when there is no tty, so it stays scriptable: `… settings smtp.password < secret-file`. **Why.** argv is in `ps` for every process on the host and in shell history — on the box holding the fleet signing key. The refused set is `SECRET_SETTING_KEYS`, imported rather than transcribed, so a second secret is covered by arriving: USAGE, the refusal and the prompt all read the same list. **Status.** Current ### Q1.301 — Can an admin obtain a credential for somebody else's account? **Decision.** Not through a route in this service — `db.prepare("INSERT INTO api_keys` appears in `app.ts` exactly once, and not on a route reading `c.req.param("id")`, which `relaycheck` asserts by reading the source. There is no `cpctl admin passwd` and no `cpctl admin key`. But an admin **can** take an account over by another door, and the honest form of the answer is the route table rather than a slogan. **Why.** An admin owns `smtp.host`, so they can point mail at a host they control and drive `POST /v1/forgot` for anybody with a confirmed address. That is not a code change and not a new power — the same admin can self-promote with `POST /v1/admin/users {isAdmin: true}`, delete anyone, and reassign every machine's owner. **Rejected.** The sentence this replaces — "an admin may take a credential away and may never issue one" — claimed more than deleting the admin password reset bought, and it still stands in a `schema.sql` comment beside `withKey`. It was wrong in the way documentation is worst: precise, quotable, and about a boundary that is not there. **Status.** Reversed an earlier decision ### Q1.302 — How many settings keys are there? **Decision.** `SETTING_KEYS.length` is the number, and `relaycheck` reads it rather than a literal. The count is deliberately not written into prose anywhere. **Why.** It was "thirteen" in the prose and in two docblocks, and adding `machines.per_user` made all three wrong at once. A count transcribed into prose is a count that drifts. **Status.** Current ### Q1.303 — Why can the browser no longer sign in with an API key? **Decision.** `SignIn` has no API-key field; that field and `useApiKey` are deleted. The browser asks for a password, and an account holding only a key reaches the app through `cpctl passwd` — which sets the first password an account with no `user_passwords` row needs no current password to set. **Why.** API keys survive as the credential for everything that is not a browser — `cpctl`, a script, and getting back in when this service has been rolled back — and both are full authority, `callerAuth` resolving either by its three-character prefix. What the field bought was a second door onto the one screen that already has the right one. A key already in `localStorage` is still adopted, so an open tab is not signed out by the change. **Status.** Current ### Q1.304 — Why could an API key never be taken back? **Decision.** Two routes write `api_keys.revoked_at`, both deliberate revocations: `DELETE /v1/me/keys/:keyId` and `DELETE /v1/admin/users/:id/keys/:keyId`, through `revokeApiKey`. **Amended 2026-09-06.** One route now — the admin one is deleted by Q1.631, and an admin has no verb over anybody's keys at all. **Why.** The column was read by `callerAuth` and written by nothing, so the one credential here that never expires was also the one nothing could take back — a property the code appeared to have from both ends, the schema carrying the column and the middleware honouring it. The third writer this used to name was the sweep inside the admin password reset, and that route is deleted: an admin can no longer end somebody's key by resetting their password, because an admin can no longer reset their password. **Rejected.** Retiring keys on a self-service password change. `POST /v1/me/password` revokes sessions and leaves keys alone, deliberately — a key is a separate credential with a separate lifecycle and `cpctl` is holding one. Two routes list them through `apiKeyRows`, which returns the prefix and never the key or the hash, and `GET /v1/admin/users` counts the unrevoked ones so an admin can see that a key exists at all. **Status.** Current ### Q1.305 — What is the login throttle keyed on? **Decision.** A composed key from an exported pure builder and nothing else: `loginKey` is ``, `addressKey` is the per-address backstop under the looser `ADDRESS_THROTTLE`, and `passwordChangeKey` is namespaced on the *user id*. **Why.** Keyed on the bare name it was a lockout weapon: eleven unauthenticated `POST /v1/login` requests locked somebody out of their own sign-in **with the correct password**, and out of their own password change on a valid session, with no credential and no knowledge of anything but the name. Namespacing the password-change counter on the user id is what stops anything an anonymous caller records reaching an authenticated route. What bounds a many-address sprayer is not this file but `password.ts`'s public lane. **Status.** Current ### Q1.306 — Which `x-forwarded-for` entry is the caller's address? **Decision.** As many entries from the **right** as `REEMOAT_CP_TRUSTED_PROXY_HOPS` says are yours, and the header is ignored outright at the default of **0**. A header carrying fewer entries than hops did not come through the chain the operator described, so the socket answers instead. **Why.** `callerAddressOf` took the **first** entry and preferred it to the socket, with no trusted-proxy option anywhere, so the address in every throttle key was a string the caller typed. Rotating it defeated `loginKey` and `addressKey` together, and spelling somebody *else's* address into it aimed them: thirty failed sign-ins refused that address its own sign-in **and** its own `POST /v1/forgot` for fifteen minutes, with no credential and no name known. Counting from the right is the only honest direction, because that is the end a proxy appends to — with one hop the last entry is what your proxy observed and the leftmost is whatever the client sent. **Why the default is 0 even though it is wrong for the recommended shape.** Loopback publish behind TLS puts every caller in one bucket at zero hops, so the `install.sh` wizard asks, and `main.ts` warns once at runtime when the header arrives while it is being ignored. A default that trusts a proxy nobody configured is the failure that cannot be noticed. **Rejected.** Leaving it as it was on the strength of `net.ts`'s own docblock, which said both callers "only ever *print* the answer" and "nothing authorizes on it" — while `throttle.ts` read the same value, and a `429` is a refusal. That comment is what kept the defect unexamined. **Status.** Current ### Q1.307 — What happens to a deleted person's machines? **Decision.** `DELETE /v1/admin/users/:id` **revokes** them, reporting `machinesRevoked`, in the same transaction that removes every credential authenticating as them — password, keys, sessions and their origins, grants, and every unredeemed enrollment code they minted through `burnUserCodes` with `used_from = 'user_deleted'`. Deleting *yourself* is refused. **Why.** The earlier decision left those machines registered and ownerless, on the ground that deleting a person should not take a daemon somebody may still be running off the network. What that missed is what ownerless *became*: no owner means no machine limit and no ban check, both being facts about the owner, so a delete was the one act that manufactured a live machine no rule applied to. The old argument's true half survives as the price — a running daemon does go dark, and getting it back means enrolling on that host again — and it is the right trade only because the person is gone, which is exactly why `disable` does not do it. **Why `enrollment_codes.created_by` is left dangling.** `schema.sql` used to say rows in `users` are never deleted, and what that sentence protected is now written down rather than enforced: an audit row's job is to say what happened, so a code may name a user who is gone. Refusing to delete yourself is also what makes "there is always an enabled admin left" true by construction. **Status.** Reversed an earlier decision ### Q1.308 — Does banning somebody reach the enrollment codes they minted? **Decision.** Yes. `disable` calls `burnUserCodes(db, userId, "user_disabled", now)` beside `revokeAllSessions` and reports `enrollmentCodesInvalidated`. `enable` does not give them back. **Why.** Every other credential that authenticates as a disabled person is refused by `callerAuth`, which reads `disabled_at` live — but `POST /v1/enroll` sits above THE LINE, has no caller at all, and its claim asks only whether the code is unused and unexpired. So the one sentence the delete route's code burn exists for was reachable through the *reversible* remedy: a banned account redeems a code it minted minutes earlier and `issueTunnelKey` retires the running daemon's tunnel key in the same call. A mistaken disable therefore costs one live code — 1 hour, and minting another is one request — which is the same trade `enable` already makes about sessions. **Why the reason is an argument rather than a literal.** `UserCodeBurnReason` is **required** at the call site, so `user_deleted` and `user_disabled` stay apart in the only forensic column there is, and the wrong reason is not the easy one to write. **Status.** Current ### Q1.309 — How does an invited account whose link never arrived get in? **Decision.** `POST /v1/admin/users/:id/invite` re-mints and re-sends, and **it issues nothing to the caller** — no token, no password; the link goes to the address on the account. It is `cpctl admin invite`, and Settings → Users draws it on exactly the rows in that state. **Why.** An invited account was the one state this service could not get itself out of. It holds no password and an address that is deliberately **unverified** — clicking the link is what proves it — so `POST /v1/forgot` mails nothing, because `verifiedOwnerOf` is what it looks an address up by. An invitation that was never delivered (a full outbox, a permanent 5xx, SMTP broken that minute) or simply not opened inside 48 hours shut every door at once: no sign-in, no recovery, `409 user_exists` on creating them again, and the admin reset that used to be the way out deleted by the same change. Issuing nothing to the caller is what keeps this from reopening what deleting that reset closed. **Status.** Current ### Q1.310 — Does holding an API key let you change the account's password? **Decision.** No. `POST /v1/me/password` requires the current password whenever there is one, whichever credential is presenting, and it always did. A forgotten password is recovered by `POST /v1/forgot` and by nothing else, and that needs an address the account has **confirmed**. **Why.** An unverified claim on an address reserves nothing. Four separate places in the documentation said an API key was a way back in; those sentences were wrong about code that had never behaved that way, and are corrected rather than left standing. **Status.** Current ### Q1.311 — Why does `cpctl admin relay` say how long a machine has been offline? **Decision.** `machine_last_seen` is written by the relay on the flush it already runs, and `GET /v1/admin/relay` reports it. **Why.** Presence is *deleted* on disconnect, so "offline a minute" and "offline a week" were one answer everywhere — the row simply was not there, and nothing distinguished a daemon that had just been redeployed from one that had been gone since a laptop was last opened. Riding the existing flush is what keeps it off the request path. **Status.** Current ### Q1.312 — Why did a sign-in screen with an unknown instance config draw no links at all? **Decision.** The fail-open is `showsGateLink`, which is `!== "closed"`, and `gateNotice` goes through it too — the property being that the notice is `null` **iff** both links are drawn. `gateOffer` is not the predicate anybody calls; `SignIn` calls it **nowhere**, and `webcheck` reads that file off disk to assert it. **Why.** `gateOffer` answers three ways — `link`, `closed`, `unknown` — and every caller wants two. That gap is where the design was lost once: the fail-open was *asserted* on `"unknown"` and then thrown away by call sites testing `=== "link"`, so an unknown config drew nothing, in the one frame somebody arriving at a sign-in screen actually looks at. Re-deriving a two-way answer at a call site is the exact defect the extracted predicate exists to end. **Why it fails open where `visibleSections` fails closed.** Fail closed where the cost is a missing screen; fail open where the cost is a locked-out person. **Status.** Current ### Q1.400 — Why is the control plane's auth gate positional rather than a list of paths? **Rule.** One `app.use("/v1/*", callerAuth(db))`, registered after the public set. The public routes are "the ones above the line" and nothing enumerates them anywhere else; a new private route is protected by doing nothing at all. **Why.** Auth used to be four *exact*-path `app.use` lines, which meant `POST /v1/machines/:id/enrollments` — minting a full machine identity — served with **no credential at all**. A list of exact paths fails open for every route added after it; a positional gate fails closed. The mechanism has been exercised since, because mail more than doubled the public set: it went from four routes to ten, and every one after the fourth was added *above* the line on purpose. **Consequence for bodies.** The `app.use("/v1/*")` that raises the body limit to 256 KiB is registered below the gate, so it never reaches a public route — which is why the seven public routes taking a body carry `PUBLIC_BODY_LIMIT_BYTES` themselves. Above the line is exactly where somebody with no credential decides how many bytes are read. **Status.** Current ### Q1.401 — Does a login answer faster for a name nobody has? **Rule.** No. Every branch — unknown name, a user with no `user_passwords` row, and a disabled account — verifies against a decoy hash and takes the same concurrency slot as a real verification. **Why.** Without it the route answers in microseconds for a name nobody has and in ~50ms for one somebody does, which is a user-enumeration oracle measurable over the network by anybody, with no credential. **Measured.** Over real HTTP: **50.9ms** for an unknown name against **52.3ms** for a real one. **Status.** Current ### Q1.402 — Can a stolen session take an account over through the email route? **Rule.** `PUT /v1/me/email` proves the current password **unconditionally**, whether or not the account holds a verified address. **Why.** It asked only about a *verified* address, so with no `user_emails` row or an unverified one a stolen session ran the whole chain: repoint the address (no proof asked), confirm it with the same session, `POST /v1/forgot`, `POST /v1/reset` — out comes a password the thief chose, every other session revoked, and a fresh one for them. `main.ts`'s bootstrap admin holds no `user_emails` row at all, so the fleet's founding account was in exactly that state — and the `email_changed` notice to the old address carried the same condition, so nobody was told. **The exemption was a category error.** It read "there is nothing to steal yet": true of the *address*, false of the account the route hands over. **Why the migration exception cannot be reached this way.** Every `mintSession` call site requires or creates a `user_passwords` row, so only an API key reaches the no-password branch, and a key is already full authority. **Client half.** `emailChangeNeedsProof` in `packages/web/src/account.ts` carried the same rule; changing one side without the other was a `400` on screen. **Status.** Superseded — reversed on 2026-09-04 by the owner's decision, Q1.630: the route asks nothing again, the client predicate is deleted, and the chain above is open by choice rather than by accident. Amended 2026-09-05 (Q1.630's amendment): the route asks an API-key caller again, reading `caller.via`, so the chain is open by choice for a session and closed for a key. ### Q1.403 — Should an admin be able to reset somebody's password? **Decision.** No. The two admin routes that were a password change by another door are **deleted** rather than guarded, so an admin cannot enter anybody's account at all. **Why deletion is the stronger fix.** The invariant is that a password change requires the current password even under a valid session — otherwise a session token lifted from a tab converts into permanent ownership of the account in one request, the one thing that gets worse under sessions than under a key. Guarding the admin routes would have left two more doors to keep guarded; deleting them leaves three routes, all `/v1/me/*`. **What went with them.** The key sweep those routes carried (an admin ending somebody's API keys by resetting their password) is gone and nothing replaced it: revoking a key is its own act. So is the open question of whether an admin reset should burn enrollment codes — there is no admin reset to ask it about. **The way back in** for an account holding only a key is `cpctl passwd`, which sets the first password an account with no `user_passwords` row needs no current password to set. **Status.** Reversed an earlier decision ### Q1.404 — Can somebody lock me out of my own account by guessing at my name? **Rule.** A guessing counter is keyed on a **composed, namespaced** key built by an exported pure builder — `loginKey(name, address)` and the ten beside it — and never on a name alone. **Why.** Keyed on the bare name this was a lockout weapon: eleven unauthenticated `POST /v1/login` requests refused somebody their own sign-in *with the correct password* and their own password change on a valid session, for about eleven requests every fifteen minutes, from anywhere, with no credential. **Why every builder is namespaced.** The address half is caller-supplied. With a bare `|
` spelling, a login naming `pwchg` and forwarding a user id writes exactly the key a password change reads — so an anonymous caller could write into an authenticated route's counter. **Why the list is the count.** A numeral in the prose said "ten" the day the eleventh builder arrived, which is `SETTING_KEYS`' lesson in another file. **Status.** Current ### Q1.405 — Why is a failed login recorded before the password is even verified? **Rule.** `check` is synchronous and `fail` runs **before** the `await`, with `succeed` un-recording the attempt as soon as the password verifies — deliberately before the disabled check. **Why.** With `fail` after `await verifyPassword`, every guess arriving inside one KDF window saw a counter nothing had incremented yet. The throttle was measuring the semaphore rather than the guesses. **Measured.** **40 concurrent guesses reached 36 real verifications against a threshold of 5.** **Status.** Current ### Q1.406 — What does a synchronous scrypt cost the control plane? **Rule.** `scryptSync` is never called on a live path. The one exception is the decoy hash, built at module load before any listener exists. **Why.** That process carries the API listener, every relay tunnel and `serveStatic`. **Measured.** The synchronous form blocks the event loop **25ms per attempt**, so ten logins a second is a fleet-wide outage reachable by anyone who can POST. **Status.** Current ### Q1.407 — Can a login spray deny everybody else a password hash? **Rule.** A hash names which side of the credential gate it is for and `HashLane` has no default: 4 concurrent hashes, of which at most 2 may be `"public"`, per-lane wait lists (32 authenticated, 16 public), and `release` wakes an authenticated waiter first, unconditionally. **Why.** One gate of four was a starvation weapon. The login route reaches the KDF on *every* request by design, so a spray of distinct names is a spray of real hashes and no per-identity threshold ever sees it. **Measured.** 36 hashes in flight, and every login *and* every admin password reset answered `503 overloaded` — the remedy denied by the attack. **Why a fair queue is not the answer.** Under a spray, a fair queue is always the sprayer's. Hence the unconditional preference for an authenticated waiter. **Known residue.** `POST /v1/reset` takes `"public"`, because the lane is about which side of THE LINE the route sits on and not about how the caller feels — so a login spray can still queue a mailed recovery behind it. Bounded rather than closed: the public lane's wait list is 16 and answers `503 overloaded` with `Retry-After: 1` rather than hanging. **Status.** Known limitation ### Q1.408 — Could an API key ever be taken back? **Rule.** One `UPDATE api_keys` statement, reached by two routes: `revokeApiKey` behind `DELETE /v1/me/keys/:keyId` and `DELETE /v1/admin/users/:id/keys/:keyId`. **Amended 2026-09-06.** Reached by one route, the holder's own: the admin route is deleted by Q1.631, and the `user_id` clause the rule below leans on now carries the whole of "somebody else's is a 404". **Why.** `api_keys.revoked_at` was read by `callerAuth`, which answered `api_key_revoked` — so the capability looked present from both ends, the schema having the column and the middleware honouring it — while there was **no `UPDATE api_keys` anywhere in this service**: three INSERTs, one SELECT, and a DELETE inside user deletion. A key was immortal until the account holding it was deleted outright, which on the single-admin deployment `install.sh` creates means a leaked admin key had no in-band remedy at all. **The general shape.** A credential the code can read is a credential something must be able to write. It is the same failure `sessionOf` is named for: a property the code appears to have and nothing enforces is worse than one it visibly lacks. **What is deliberately not done.** No password change on this service retires a key. The route that existed for "somebody else may have this account" no longer exists (Q1.403), and a self-service change revokes sessions only — a key is a separate credential with a separate lifecycle and `cpctl` is holding one. **Status.** Current ### Q1.409 — Can an account that has just been deleted or banned still enroll a daemon? **Rule.** `burnUserCodes` runs inside the delete's existing `BEGIN`/`COMMIT`, and `POST /v1/admin/users/:id/disable` burns them too, recording `'user_deleted'` or `'user_disabled'` in `used_from` through a **required** `UserCodeBurnReason` argument. **Why.** `DELETE /v1/admin/users/:id` swept sessions, origins, passwords, keys, grants and ownership and **not** `enrollment_codes` — so a just-deleted account could redeem a code it was still holding at `/v1/enroll`, which sits above THE LINE and asks only whether the code is unused and unexpired, receive the machine id and the fleet's public keys, and have `issueTunnelKey` retire the **legitimate daemon's** tunnel key in the same call. **Why the reversible remedy needed it too.** The invariant is about the code, not about the route, and scoping it to `delete` was the hole: `/v1/enroll` reads neither `created_by` nor `users.disabled_at`, so the same sentence was reachable through `disable`. A mistaken disable now costs one live code — 1 hour, and minting another is one request — which is the same trade `enable` already makes about sessions. **Why the reason is an argument rather than a literal.** `user_deleted` and `user_disabled` are different events, and `used_from` is the only forensic trail there is. **What is deliberately left alone.** `created_by` may name a user who is gone, dangling on purpose, because an audit row's job is to say what happened. And `POST /v1/reset` burns sibling email tokens and revokes sessions but leaves enrollment codes alone: somebody proving control of their own address is not evidence that a daemon they enrolled is compromised. **Status.** Current ### Q1.410 — What did revoking a machine leave behind? **Rule.** `releaseOwner` drops the `machine_owners` row inside the same transaction as the `UPDATE` and the code burn, on **both** revoke routes, with no `return` between `BEGIN` and `COMMIT`. **Why.** `machine_owners` has no `revoked_at` and its unique index on `(user_id, label)` does not join `machines`, so a revoked machine held its label and one of `MAX_MACHINES_PER_USER` for ever. Revoke `laptop`, create `laptop`, and the answer is a `409` naming a machine that appears in no list and can never be reached — and after fifty revocations the account cannot add a machine at all, with nothing on screen to explain it. **The inverse had to exist for the same reason.** `INSERT INTO machine_owners` happened only inside `createOwnedMachine`, which always mints a fresh id, so ownerless was a one-way state and a person leaving the fleet stranded their hardware: rename, re-enroll and revoke all resolve through `ownerOf` and answer 404 for the life of the row. `PUT /v1/admin/machines/:id/owner` writes the grant *with* the ownership row, because an owner with no grant owns a machine that appears in no list. **Status.** Current ### Q1.411 — Should a `403` from an admin route sign somebody out? **Rule.** `authFailure` decides on the error **code**, never the status. `403 forbidden` returns `null`, and so does `401 invalid_password`. **Why.** Under the old `status === 401 || status === 403` test, `requireAdmin` answers 403 to every non-admin — so merely opening the Users section would have signed a non-admin out of the whole app. The mirror case is `401 invalid_password`, a 401 about the request *body* rather than about the credential that carried it, reachable from two routes (`/v1/me/password`, `PUT /v1/me/email`; `/v1/me/keys` stopped asking, Q1.630): mistyping your own password on the screen that fixes a suspected leak must not be what ends the tab. **Status.** Current ### Q1.412 — Can a 401 about an expired token sign out the session that replaced it? **Rule.** `cpFetch` captures `const sent = credential` before building the header and tears down only while `credential === sent`. `setSession` always allocates, so identity is the whole comparison. **Why.** `CP_TIMEOUT_MS` is ten seconds, which is ample for a request carrying an expired token to answer *after* a wake has signed out, a fresh sign-in has succeeded and `bootstrap` has rebuilt the fleet. The stale 401 then cleared the brand-new token from memory and `localStorage` and returned the tab to the gate — about a session that was perfectly good and which, no `DELETE /v1/me/sessions/current` having been sent, then lingered for its full thirty days. **Status.** Current ### Q1.413 — Why does an account that is signed in need a write throttle at all? **Decision.** 60 writes per minute per ``, then a flat 10s — `writeKey(userId, what)`, namespaced, with `what` a fixed literal per route so hammering one cannot lock the other. **Why.** Below THE LINE there were three middlewares and no counter, so an ordinary account could drive `POST /v1/tokens` (a signature) and `POST /v1/machines/:id/enrollments` (a transaction under `synchronous = FULL`, into a table with no sweeper) as fast as it could ask — on the process that in embedded mode holds every tunnel in the fleet. **Why it is generous and non-escalating.** Every other policy here bounds guessing; this one bounds cost, and the likely caller is a retry loop rather than an attacker. **Status.** Current ### Q1.500 — Why does registering a machine grant it and mint its code in one request? **Decision.** `POST /v1/machines` does all three at once: it registers the machine, grants it to its creator with every scope, and mints its enrollment code. **Why.** Those were three separate administrative acts, and the third was routinely forgotten. A machine registered and enrolled but never granted appears in nobody's list — the daemon comes up, holds a tunnel and is reachable by nobody, with nothing anywhere reporting that a step was skipped. **The label rule rides the same route.** A label may not be spelled like a machine id (`MACHINE_LABEL_RESERVED`), because `MACHINE_LABEL` admits that exact shape and `POST /v1/tokens` would then mint a token for the wrong `aud` with nothing on either side reporting it. `labelIsWellFormed` is the one call that tests both, so nothing can check one rule and forget the other. **Status.** Current ### Q1.501 — Can a grant put two machines with the same name in one list? **Position.** Yes, and it is knowingly left open. `PUT /v1/machines/:id/grants` is a sixth path that reaches the same state as the five `nameVisibleTo` guards (Q1.48), without naming anything: a grant hands somebody a machine that is already called something, so the collision arrives with no write to a label or a name and no check on the way. **Why it is not refused.** Refusing would refuse a share over a collision only the **grantee** can see — and which the sharer cannot see either, since it is composed of a name on their side and a label on the other. What it costs is reachability rather than authority: `POST /v1/tokens` still checks the grant after resolving, so the worst outcome is that `resolveMachineRef` picks one of the two by name and the other must be addressed by id. ⚠ **That paragraph used to end "on the one route `cpctl admin grant` drives and the only remaining way to share a machine at all", and that half is spent.** `PUT`/`DELETE /v1/admin/grants` are deleted and sharing is the owner's `PUT /v1/machines/:id/grants` (Q1.633). It is recorded rather than swapped because the two halves are not the same argument: the spent one was a reason to leave *that admin route* open, and the collision reasoning survives the move unchanged, because it was never about who was asking. **Why it is written down.** Four guarded routes in a row read as coverage. The gap is in the fifth thing somebody would assume was covered. **Status.** Known limitation ### Q1.502 — How many routes enforce the machine limit? **Rule.** The number is deliberately not written in prose anywhere. The list is. **Why.** It said "four" in the same sentence that named six routes, while the code had eight. That is the failure `SETTING_KEYS` records one section over: a numeral in prose is a second copy of a fact the code already holds, and it goes wrong silently and stays wrong. **What is written instead.** `POST /v1/machines`, `POST /v1/provision`, `POST /v1/admin/machines` and `PUT /v1/admin/machines/:id/owner` refuse a *creation* with `409 machine_limit`; `POST /v1/machines/:id/enrollments`, `POST /v1/tokens`, `relay/authorize.ts` and `relay/tunnel-endpoint.ts` refuse an *existing* over-limit machine with `403 machine_over_limit`. **Status.** Current ### Q1.503 — In what order does `POST /v1/provision` create the machine and raise the limit? **Rule.** The machine first, and the limit only once it exists — so a refused provision changes nothing. `machineLimitRaisedTo` only rides the 201. **Why.** The write came first and neither refusal undid it, so a *failed* provision widened somebody's quota for ever. The ceiling arm is what made that serious rather than untidy: at fifty machines under a lowered limit, `min(51, 50)` was written and the create was then refused — a 409 that created nothing had un-suspended forty-five machines an admin had deliberately switched off, reported nowhere, since the raise is only ever announced on the success. **Status.** Current ### Q1.504 — Which account does provisioning mean when two names differ only by case? **Rule.** Neither. The name is resolved to exactly one account or the request is refused with `409 user_ambiguous`. **Why.** `users.name` is UNIQUE and compared BINARY while `idx_users_name_folded` is a plain index, so `Casey` and `casey` can both exist. Resolved with `.get()`, whichever row SQLite happened to yield first decided who got the machine, who got the `ALL_SCOPES` grant, whose limit was raised, and whose `disabled_at` the ban check read — four decisions about a person, taken by row order. **Status.** Current ### Q1.600 — Why is the SMTP client hand-rolled rather than a library? **Decision.** `packages/control-plane/src/mail/smtp.ts` implements SMTP itself, and `sendMessage` takes an `SmtpDialer` rather than opening a socket — the shape `AcpClient` already takes `AgentProcess` in. **Why.** The seam is what lets `relaycheck` prove, with no mail server anywhere, three things a library would only assert by having been imported: that STARTTLS is never silently downgraded — and that no `AUTH` and no `MAIL FROM` were written when it refuses — that the second `EHLO` wins, servers routinely advertising `AUTH` only after TLS, and that a `QUIT` failing after `250` is not a send failure, which is the one place a careful implementation sends the message twice. **Rejected.** A library. It puts all three properties out of reach of an offline driver, on the one outbound path this service has. **Status.** Current ### Q1.601 — Why does even the admin's own test send go through the queue? **Decision.** Nothing is ever sent from a request: `POST /v1/forgot` enqueues and returns, and so does the admin's test send. **Why.** The test send is the one route where somebody would happily hold the socket, which is exactly what makes it the worst place to allow it — that socket is held in the process carrying the API listener, `serveStatic` and every relay tunnel, and a dial that never answers is a libuv threadpool slot (Q1.603). Beyond the loop, this is also what keeps a taken address and a fresh one indistinguishable: an inline send would make the two branches measurably different in time, and the identical response body would stop meaning anything. **Status.** Current ### Q1.602 — Why base64 rather than quoted-printable? **Decision.** Both MIME parts are base64. **Why.** Base64 is 7-bit clean, so `8BITMIME` never needs negotiating, and its alphabet excludes `.`, so no body line can begin with one. **Rejected.** Quoted-printable, the smaller encoding for mostly-ASCII text. It has three independently-easy-to-get-wrong rules whose failures land *per recipient* and are invisible from here. **Status.** Current ### Q1.603 — How can a mail outage become a sign-in outage? **Behaviour.** `net.connect(host)` resolves names with `dns.lookup()`, which is `getaddrinfo` on the libuv threadpool — the same pool `scrypt` runs on and `serveStatic` draws from. A hung DNS server, or an MX that accepts the connection and never answers, consumes slots until password hashing queues behind it and the sign-in page stops loading. **Why.** It is the coupling nothing in the code names, so every bound on the mail path exists to stop it: one message at a time fleet-wide, hard per-step budgets (10 s to connect, 30 s for the body, 60 s for the final dot), 90 s wall-clock for a whole message, and a breaker that stops dialling after five consecutive failures. **Status.** Current ### Q1.604 — Why does `sniFor` return `undefined` for an IP address? **Rule.** No SNI is sent when `isIP(host) !== 0`, on both TLS paths — implicit TLS at `connect` and the `startTls` upgrade. **Why.** An IP is a thing operators really type: a docker gateway, an internal MTA, `172.17.0.1` from inside the container `compose.sh` runs. Passed through, **every** message on such a host failed for ever, doubly disguised — the throw is not an `SmtpError`, so `sendMessage`'s wrapper filed a connection that died at the upgrade under step `body`, and `permanent` is false, so each message spent all eight attempts before giving up. On an instance where mail is the only account recovery there is, that is silent and total. Omitting it is also what the RFCs want — SNI carries a *name*, and there is no name to send when the operator addressed a number — and it costs no verification: with `rejectUnauthorized` on, an IP is still checked against the certificate's IP SANs. No driver asserts it: `sniFor` is module-private and the failure is inside `tls.connect`, which the `SmtpDialer` seam exists to keep out of `relaycheck`. **Measured.** Node v26.3.0: `tls.connect` throws *"Setting the TLS ServerName to an IP address is not permitted"* before a byte moves. **Status.** Current ### Q1.605 — Why is `handshake` a step budget of its own rather than part of `starttls`? **Rule.** The eight per-step budgets are connect, greeting, EHLO, STARTTLS, handshake, AUTH, envelope and DATA, 10 s each. **Why.** Handshake is the one step with no `read` behind it. Every other budget is enforced by `ReplyReader` asking for bytes, and `total` is only re-checked *at* a read — so a stalled TLS negotiation is a step no other ceiling can see. **Status.** Current ### Q1.606 — Why is there no `POST /v1/register/resend`? **Decision.** It was deleted, and signing up again took its job — which is why the per-address budget it used to hold is not a third share. `mayMail` is spent by `POST /v1/register`, on all three of its arms, and by `PUT /v1/me/email`: two routes, so they cannot compose into six. **Why.** The re-signup *is* the resend, and a separate route with its own budget would have made the same act cost less by being spelled differently. **Status.** Reversed an earlier decision ### Q1.607 — Why does reset mail have its own hourly budget? **Rule.** `RESET_MAIL_THROTTLE`, 3 an hour per address, with no escalation, kept apart from the `mayMail` budget. **Why.** Shared, anybody who knew an address could spend the recovery that address depends on and hold it spent. **Status.** Current ### Q1.608 — Why is the default SMTP port 587 and never 25? **Decision.** The default port is 587. **Why.** Outbound 25 is blocked by every major cloud, and a blocked port hangs rather than refuses — so the visible failure is a per-step budget expiring rather than an error anybody can read. **Status.** Current ### Q1.609 — Why do the three mailed-link lifetimes differ? **Decision.** Verify 24h, reset 1h, invite 48h. **Why.** Reset is the one token that takes an account over, and the person is at the screen while it is live. Nobody is waiting on an invitation, and `POST /v1/admin/users/:id/invite` re-sends past 48h, which is what stops that number being a deadline on somebody's whole account. **Status.** Current ### Q1.100 — How is one tunnel's stream budget divided between the people who hold a grant on the machine? **Decision.** By the verified `sub`, at `MAX_STREAMS_PER_SUBJECT` — 64 concurrent streams per caller on one tunnel, under `MAX_CONCURRENT_STREAMS`' 256 for the tunnel as a whole. `RelayTunnel.open` in `relay/registry.ts` refuses a caller already at its share by answering `null`, **before the stream exists**. **Why.** A grant is full access and the tunnel budget is shared, so without a per-person share the 256 is a resource anybody holding a grant can take entirely — and the machine's *owner* is then refused `503 no_tunnel` on their own machine, which `meansMachineGone` turns into "this machine is not reachable", i.e. indistinguishable from a daemon that has stopped. A person watching their own laptop go dark while it is sitting there running is the worst reading available of a resource limit. **Nothing here is about malice, which is what sizes it.** The web client holds up to three sockets per session plus a request in flight, so somebody with several sessions open on one machine is legitimately in double digits and two people are double that. 64 is generous for one browser and a quarter of the tunnel, so four callers can be at their ceiling before the tunnel's own limit is what refuses. **Refused here rather than counted after the fact**, because the alternative is letting the h2 session's own `maxConcurrentStreams` be the thing that answers — and that one refuses at the protocol level, where there is nothing left to say about *why*. **The subject is never a request field.** It is the `sub` claim out of the token the relay has already verified — the same value that rides `STREAM_SUBJECT_HEADER` as advisory information for the daemon's logs, where it confers nothing. Here it only divides a budget, which is a question about fairness rather than about authority, and that is why reading it from a header would be a different kind of mistake from the one `STREAM_SUBJECT_HEADER` already survives. **Measured.** `relaycheck` asserts four things on one shared tunnel: a caller may hold its whole share (exactly `MAX_STREAMS_PER_SUBJECT` streams, every one granted), it is refused the one past it, **somebody else is unaffected** — which is the half that is the point, being the request that would have been refused on the owner's own machine with a perfectly healthy daemon at the other end — and a closed stream gives the slot back, since a counter that only went up would turn a busy afternoon into a machine that stops answering and never starts again. **Status.** Current ### Q1.101 — Does "newest tunnel wins" extend to the presence row, or only to the map? **Rule.** To both. `TunnelRegistry.register` overwrites **one row per machine** via `presence.up` — the same "newest wins" the map just performed, rather than a second row for a second socket — `unregister` calls `presence.down` *below* its identity guard, and `closeAll` calls `presence.down` explicitly for each tunnel before `this.tunnels.clear()`. **Why the call sits below the guard.** It is the same sentence the map's identity check exists for, one layer down. A superseded tunnel's close event fires *after* its replacement registered, so a `presence.down` above the guard would delete the row of the tunnel that is actually up. Of the two directions of staleness that is the one nothing corrects: a stale `true` costs one probe and a `503 no_tunnel`, which every client already turns into `forgetRoute()`, while a stale `false` draws a reachable machine as offline and the client never probes it again. **Why `closeAll` needs its own call.** `this.tunnels.clear()` is what makes every later `unregister` a no-op, so without an explicit `presence.down` per tunnel the row would survive a *clean* shutdown and go on claiming the machine for a whole staleness window — the one case where the relay knows perfectly well that the tunnel is gone. **Status.** Current ### Q1.612 — What is a plugin trusted with? **Position.** A plugin runs as you. It is a child process of the daemon, with your uid, your `HOME`, your files and your keys — the same trade an agent already makes on the same machine, and for the same reason: this is one person's own computer. **Why the scope table is not a fence.** `manifest.scopes` is declared, shown at install, and refused when exceeded. None of that stops anything: the child can `import("node:fs")` and read everything the daemon can. The standing is exactly `agentEnv()`'s — **hygiene, not a fence** — and the comment saying so is in `api.ts` next to the gate, so nobody restores the stronger claim by reading the code alone. **What it does buy, which is three things and all of them real.** The blast radius is *named*, which is what makes an install a decision somebody can take rather than a leap. A plugin that hangs or crashes cannot take the daemon's single event loop with it — that is what the child process is for, and it is the property that matters most on a machine also running four agents. And the plugin never holds the daemon's token or its database handle, because `pluginEnv()` strips every `REEMOAT_*` name before the child starts. **Rejected.** Claiming a sandbox. Node's permission model is process-wide rather than per-worker, so a `Worker` cannot be confined at all and a child process could only be confined by flags this daemon would have to guarantee across every platform it runs on. Writing "sandboxed" over that would be the same lie the settings screen refuses to tell next to a `~/.claude/settings.json` that has already answered. The seam if one is ever wanted is `PluginRuntime` (Q1.616). **Status.** Current ### Q1.613 — Why does no plugin code run in the browser? **Decision.** A plugin returns a **description** of a screen and the web client draws it with its own components. No plugin JavaScript is loaded, evaluated or framed. **Why.** `reemoat.credential` lives in that origin's `localStorage` and is the one credential in this system that reaches the control plane. A plugin bundle executing there would hold it, and every mitigation for that is a mitigation rather than a boundary: a sandboxed iframe needs a second CSP, a versioned `postMessage` bridge, and a decision about `frame-ancestors 'none'`, and it still ends with somebody else's code running in a page this app also draws approvals in. Refusing to run it at all is the only version of this that is a property rather than a defence. **What it costs, stated rather than hidden.** A plugin screen is a list, a form, a set of columns and some text. It is not a canvas, and no amount of manifest will make it one. That is written into `docs/PLUGINS.md` in those words, because an author who discovers it after building something is an author who was misled. **What it buys beyond the boundary.** Three things that would each have been work: a plugin screen matches the rest of the app without its author thinking about it, it works on a phone without its author thinking about it, and it cannot make the session list slow. **Precedent.** The app already renders a form somebody else described — `ElicitationCard` draws an ACP elicitation from a schema an agent sent. The idea is borrowed; the *type* deliberately is not, because that shape belongs to ACP and one type serving two wires is one type changed by the wrong release. **Status.** Current ### Q1.614 — A plugin has scopes and so does the caller. Which decides what? **Rule.** Two axes, and neither implies the other. The scope on the route — `session:read`, `session:write`, `machine:admin` — decides what the **caller** may do. `manifest.scopes` decides what the **plugin** may do. **Why both are needed.** A read-only grant may look at a plugin's screen and press nothing on it; that is the caller axis, and it is the same one every other route uses. But a hook has no caller at all — it runs because an agent finished a turn — so the manifest is the only authority there is on that path. A design with one axis either leaves hooks unauthorized or makes a plugin's authority depend on who happened to open a screen. **Measured consequence.** `daemoncheck` drives both: a read-only token listing plugins and being refused an action, and a manifest declaring nothing being refused every method in `SCOPE_OF` — swept over the table rather than asserted one at a time, so a method added without an entry fails there rather than being reachable by everybody. **Rejected.** A new token scope for plugins. Unknown scope strings are dropped rather than rejected on the way in, so a daemon that had not learnt it would silently treat every plugin call as unauthorized — and the three that exist already describe what a caller is doing here. **Status.** Current ### Q1.615 — `src/` held exactly one `fetch`. It now holds two. **Decision.** `enroll.ts`, and `net.fetch` made on a plugin's behalf in `plugins/api.ts`. Both are named, here and in `.claude/rules/plugins.md`. **Why the count mattered.** *"The daemon makes exactly one control-plane request, ever"* is what makes a control-plane outage cost reachability rather than work in flight (Q1.9), and the way that property was kept true was by counting: one `fetch` in `src/`, and the count *was* the property. **Why this one does not break it.** The substance is unchanged. This call is never made by the daemon on its own behalf, never on a start path, never on a session path, and only ever to a host somebody wrote into a manifest and approved at install. A control plane that is down changes nothing about it. **What is conceded.** The number is two, and a number is a worse guard than a zero. So both are written down in two places, and the rule file says in as many words that a count nobody restates is a count that becomes three. A plugin *registry* to poll would be exactly that third one, and is refused for it (Q7.104). **Why the host makes the request rather than the plugin.** The plugin could open its own socket — see Q1.612 — so this is not a fence either. It is a **tap**: one place a plugin's outbound traffic can be seen, and one place the allowlist, the https rule, the redirect refusal and the rate window are applied. Q7.86 is the same lesson learnt the expensive way, when agent markdown could fetch from anywhere with nothing watching. **Status.** Current ### Q1.616 — Is `PluginRuntime` a sandbox seam or leftovers? **Decision.** An interface with one implementation, kept as one deliberately — the same standing `SessionRuntime` has, and written down for the same reason. **Why.** It is the single place a confined plugin would be built, and naming it now is cheaper than finding it later. But it earns its keep today without any of that: it is what lets `daemoncheck` drive a start that never completes, an invocation that is never answered, a crash after `ready` and an exhausted restart budget — none of which is reachable by spawning a real process and hoping it misbehaves on cue. Four lifecycle paths are asserted rather than hoped for because this interface exists. **Rejected.** A `kind` discriminant on it, for `SessionRuntime`'s stated reason: an unread discriminant on the one interface that survived a deletion is how an `if (kind === "sandboxed")` branch against nothing comes to be written. **Status.** Current ### Q1.617 — Why can a plugin not send CSS? **Question.** A plugin returns a description and the app draws it. Why not let it send stylesheets, or restyle the app? **The argument that does not carry the decision.** "CSS is dangerous" is mostly untrue here. `expression()` is dead, `url(javascript:)` is dead, and the classic selector-based exfiltration — `input[value^="a"] { background: url(https://…) }` — is **already blocked by the CSP this app sends**: `default-src 'self'`, `img-src 'self' blob:`. Refusing on those grounds would be overstating a narrow risk, so it is written down here that it was considered and is not the reason. **What is genuinely dangerous, and it is one thing.** This page approves shell commands with a tap. Anything able to move pixels near that control is a tap-jacking surface — cover the card, shift Approve under a thumb, make Deny transparent. Scoping does not close it: Shadow DOM isolates *styles*, not positioning, and a `position: fixed; inset: 0` element inside a plugin's own root still paints over the whole app. It can be contained (`contain: paint`), and the shape of that sentence is the tell: it is a defence being built around somebody else's stylesheet rather than a property. **Three reasons that outrank the security one.** *The class names become a public API.* A plugin able to target `bg-surface`, `edge-strong` or `--rail-w` is a plugin that breaks when `Sheet` or `bits.tsx` is refactored — and unlike a protocol, CSS has no version to negotiate. This is `DAEMON_VERSION`'s lockstep problem arriving through the stylesheet. *Consistency is the feature.* A plugin screen currently gets a phone layout, the monochrome palette, 44px on decision controls and legible type for free. CSS opts its author out of all of it, and the first thing most would do is make their thing **stand out** — in an interface built around *does anything need me*, where attention is the scarce resource. *The measured decisions stop binding.* Q3.205–Q3.210 carry chroma in OKLCH, a ≥3:1 floor on `edge-strong`, and the rule that `bg-fg` is only the affirmative action inside a decision. A plugin with CSS violates all of them, and **its author cannot see it**: they are on a desktop, and the failure is somebody outdoors with a phone. **What people actually want, and what is offered instead.** Almost always meaning rather than pixels — *this row is urgent, this one is broken*. So the answer is **more vocabulary, in tokens**: `PluginRowTone` is `ok|warn|danger` and the host picks the ink, exactly as `PluginRowAction.tone` already did one field over. A vocabulary is versioned, driven, and lands for every plugin at once. **Reconsider if** a real plugin is blocked by the vocabulary — at which point the answer is a block type for what it needs, not a stylesheet. The honest escape hatch already exists: something needing full visual control is a web application, and the plugin is the integration with it. **Status.** Current ### Q1.618 — The install screen parses the archive a second time. What is it allowed to get wrong? **Question.** `peekPluginArchive` re-implements enough of tar and zip, in the browser, to find a plugin's `plugin.json` and show what it asks for before anything is sent. The daemon then parses the same bytes with `unpackArchive` and installs whatever *it* finds. Two parsers, one archive. What happens where they disagree? **Rule.** A member must be named here exactly as the daemon will name it, or the archive must be refused. There is no third option, and "read it generously" is not leniency — a reader that resolves a member the daemon resolves differently is describing a different archive. **Why.** The screen's whole purpose is that nothing is sent until somebody has read what the plugin asks for. Where the two spellings diverge, the operator reads one manifest and the machine installs another — under the ordinary "Install it" button, not the named blind-install path. A manifest declaring no scopes at all can therefore stand in for one holding `sessions.write` together with the `permission.requested` hook, which between them let a plugin answer every permission an agent raises on that machine. **Measured.** Four ways to diverge, found 2026-08-23 and each reproduced against both parsers end to end: the ustar `prefix` field, which the daemon composes into the name and this reader ignored; the pax and GNU long-name headers, which the daemon honours as an override; the zip end-of-directory *entry count*, which this reader trusted while `extractZip` bounds itself by the directory's declared size and walks by record signature; and the `.` path segments that `safeMemberPath` drops before joining, which made a member invisible here and a root-level file there. The daemon's own `parseManifest` accepts the substituted manifest in each case, so nothing downstream catches it. **Decision.** `canonical` normalises a member's name the way `safeMemberPath` does, before `isManifestPath` and before the depth that ranks candidates; the tar walk composes the `prefix` and refuses any archive carrying an extended header it does not follow; the zip walk is bounded by the directory's size and walked by signature, and a declared count that disagrees with what is there is a refusal rather than a preference. Two candidates at one depth stay a refusal, and now so do two that normalise to one path — `findManifestRoot` breaks no tie and the daemon refuses a duplicate member outright, so neither may be resolved here. **Why refusing is the safe half.** An archive this reader cannot name is routed to the existing "Install without reading it" control, which says plainly that nobody has read it. Costing an operator a second, named press is a real cost; describing the wrong manifest is not a cost, it is the failure. **Rejected.** Sharing one parser between the two. `packages/web` may not import from `src/` — `wire.ts`'s standing reason — so the copy is structural, and this entry is the rule that makes a copy survivable rather than the wish that it were not one. **Status.** Current. This changes behaviour shipped in 0.3.0, so it wants a release rather than a quiet landing. ### Q1.619 — Is this screen's own correctness the only thing between an operator and a plugin's authority? **Question.** Q1.618 fixes four ways the consent screen could describe the wrong manifest. What catches the fifth? **Decision.** `consentBroken` compares what the screen showed against the manifest the daemon reports having installed, and says so loudly when the plugin ended up with authority nobody was shown. It replaces the success line rather than sitting beside it: "Installed Clock 1.0.0" next to a plugin that can answer every permission on the machine is the same lie one step later. **Why.** Every fix in Q1.618 depends on somebody having thought of that divergence. This one does not: the daemon returns the manifest it really parsed, so the comparison holds for classes nobody has found yet. It is the difference between a reader that is believed and a reader that is checked. **What it compares, and what it deliberately does not.** Only authority — scopes, net hosts, hooks — and only what was *gained*. A plugin that ends up with less than the screen showed is a reader that was generous, which costs nobody anything. Names and versions are not compared: a manifest may say what it likes about itself, and the question is not "is this the file I picked" but "is this what I agreed to give it". **Status.** Current ### Q1.620 — `src/` held two `fetch` calls. It now holds three. **Position.** The third is `fetchArchive` in `src/plugins/source.ts`, reached only by `POST /plugins/source`. The other two are unchanged: `enroll.ts`, and `net.fetch` made on a plugin's own behalf. **Why the count is written down at all.** Q1.615 answered that: a number nobody restates is a number that becomes four. So this entry exists before the code does anything else. **What forced it, and it is a measurement rather than a preference.** The plugin catalogue lives on its own host and the browser reads it directly. Q7.106 assumed the browser could therefore also carry the *archive* — "it already talks to both sides" — and it cannot: `codeload.github.com` answers `access-control-allow-origin: https://render.githubusercontent.com`, so a cross-origin fetch for a tarball is refused by the browser before it leaves the page. There is no header the catalogue could send that changes that, because the header would have to come from GitHub. **Why the property Q1.615 protects survives.** That property is *the daemon asks the control plane nothing* — Q1.9 — so that a control-plane outage costs reachability rather than work in flight. This call does not go to the control plane. It goes to one hardcoded host, on a request a person made, on no start path and no session path, and a failure of it fails one install rather than anything already running. **Why Q7.104's objection does not reach it.** That objection was to **a registry to poll**, which would make somebody else's outage able to stop an install on your own machine and would be a channel by which code arrives on a host nobody named it on. Nothing here polls, nothing here discovers, and nothing here updates a plugin by itself. The daemon does not know a catalogue exists: what reaches it is a repository and a commit that a person read the permissions of. **What bounds it.** https only, `redirect: "error"`, a 30s deadline, one hardcoded host, and a `repo`/`commit` this daemon validates itself — so no part of the address comes from the caller, which makes the host a real fence rather than the spelling check `net`'s allowlist honestly calls itself. The size bound is `unpackArchive` charging each chunk against `PLUGIN_LIMITS.maxBytes` as it reads: **codeload sends no `content-length`**, measured, so a header check would bound nothing on the one URL this ever fetches. **What would make it four.** A second forge. That is an added `PluginSource.kind` and an added CSP entry, together, and it should arrive as one change or the fetcher and the policy will disagree about what is reachable. **Status.** Decided ### Q1.621 — Signing in by email was refused as a labelling complaint. What changed? **Decision.** `POST /v1/login` resolves the submitted string as a `users.name` first and, failing that, as a **verified** email address through `verifiedOwnerOf`. The body field is still called `name`, the answer to every failure is still one `401 invalid_login`, and no new status code exists. **This reverses Q3.439**, which rejected it in one line — *"a control-plane schema and auth change for a labelling complaint"*. That was wrong on the premise rather than on the reasoning: it is not a schema change at all. `user_emails`, `email_folded`, `foldEmail` and `verifiedOwnerOf` all already exist and are already on the recovery path, so what was actually being asked for is a second call to a function `POST /v1/forgot` has been making all along. The complaint was also not about a label: an address the product asks for, mails a confirmation to, and then refuses as a way in is a door drawn on a wall. **Verified only, and the partial index is the reason.** `idx_user_emails_verified` is `UNIQUE … WHERE verified_at IS NOT NULL`, because `POST /v1/register` is anonymous and an unverified claim must reserve nothing. Resolving on `email_folded` alone therefore has no uniqueness behind it at all: a stranger claiming somebody's address unverified becomes a second candidate for it, and on an account nobody else has claimed, a way in. The rule the index states as a constraint is the rule this route has to state as a query. **Why name-then-address rather than a refusal on ambiguity.** `USER_NAME` has no `@` in its class, so no name a validated route created can also be an address — but names are checked at creation only, and `app.ts` says so, so a legacy row holding one is reachable. `POST /v1/provision` answers `409 user_ambiguous` for a name two accounts share bar case, and copying that here is wrong twice: this route takes no credential, so a distinguishable refusal is an oracle telling a stranger the string names *something*; and `webcheck` pins at six the set of error codes that may end a browser session. A fixed order settles the case for nothing. **The throttle is where the real cost is, and it is accepted rather than hidden.** `loginKey` is built from what was *submitted*, so one account named two ways spends **two** counters. Folding them means resolving the string to an account before the key exists — a counter keyed on the account, which is the lockout weapon `throttle.ts` exists to have removed, arrived at from the other side. What bounds the doubling is `addressKey`: 30 attempts per address whatever they name, shared by every spelling one caller tries. The identifier half also moved from `MAX_NAME_KEY_CHARS` (120) to `MAX_EMAIL_KEY_CHARS` (254), because at 120 a long address is cut at the point that throws the address half away and every host guessing at any long address shares one counter — the exact failure that second constant was introduced to end, one value-kind along. **Measured on the body limit.** The route validated `name.length > 200`, which is `users.name`'s ceiling; `mail/address.ts` allows 254. Left alone, a legal address between the two answers `400 bad_request` where an unknown one answers `401` — a smaller oracle than user enumeration and the same kind, so the limit moved with the meaning. **Status.** Reversed an earlier decision ### Q1.622 — A plugin names a program and an endpoint. What is it trusted with? **What this answers.** `contributes.harnesses` puts a program on this daemon's spawn path and `contributes.systems` puts a host on the path a pasted key travels. Both read like new authority. Only one of them is. **A harness adds none, and that is the finding.** A plugin is already a child process running as this uid — Q1.612 — and can `import("node:child_process")` and spawn whatever it likes. Declaring a harness cannot widen that; what it buys is the same thing `manifest.scopes` buys and no more: the program is **named**, shown before anything is installed, refused when it is one of this machine's own agents, and switched off with the plugin. It catches the mistake rather than the attacker, which is the standing this whole subsystem already has. **A provider does add something, and it is the one field in this feature that changes a stated property.** `agent-systems.md` said *"no route, body field or header anywhere names a base URL"* and that sentence is still literally true — a request names a `SystemId`. What is no longer true is the sentence underneath it: **the set of hosts this daemon can be pointed at is no longer a compile-time constant of this repository.** The honest restatement is that a base URL now arrives in a `plugin.json`, inside an archive fetched from one hardcoded host at a full 40-hex commit, over `redirect: "error"`, after `consentGap` compared it against an origin somebody read and pressed a named button about — and is then fixed for the life of that install. Every link in that chain already existed. **Which is why the disclosure carries the *value* and not only the verb.** Two scopes were added, and their lines say what is at stake — *add an agent that runs a program it names*, *add a provider your saved keys are sent to*. But a line in a list is where somebody learns a capability exists, not where they can judge one, so the consent card also draws the **argv** and the **whole normalised base URL**. These are the only two scopes with a second disclosure, because they are the only two where the value matters as much as the verb. **And the compared string is the drawn string.** `addedLines` produces one line per contribution; `PluginConsent` draws exactly those, `consentGap` diffs exactly those, and `consentBroken` rebuilds exactly those from the row that came back. One value, so there is no second rendering of the same fact to drift — and the *whole* path is in it, because a plugin showing `https://api.groq.com` and shipping `https://api.groq.com/../evil` passes an origin comparison. **`http` is permitted to this machine and to this network, and refused everywhere else.** That is the opposite call from `readNet`'s `LOCAL_HOST` refusal forty lines away in the same file, and the difference is whose key is at stake: that one refuses a local target in a plugin's *own* outbound list, where it is a mistake somebody is approving without reading, while this is where the **operator's** pasted key goes to a model they named — and Ollama, vLLM and LM Studio are exactly this and nothing else in the product reaches them. `169.254.169.254` and its IPv6 sibling are refused under `https` as well, because a base URL pointed at instance metadata is not a self-hosted model somebody stood up. **Status.** Applied. ### Q1.623 — Where a refused start lives, and what clears one **Question.** Q2.221 gives the daemon a negative it never had. A negative about somebody else's machine is exactly the kind of fact this repository has been wrong about before. Where does it live, how long is it believed, and what takes it back? **Decision. In memory, per daemon, per harness, expiring on read after `START_REFUSAL_TTL_MS` — and the expiry is the design rather than housekeeping.** Durability was rejected rather than skipped. Q7.99 is this fact written down and believed afterwards: `stop("agent_signed_out")` off one observation, which stranded conversations under a notice that was already false, and was reverted. A restart is also the moment the machine has most likely changed underneath the record, so surviving one would make it most wrong exactly where it was most persistent. **The budget is `MODELS_TTL_MS`'s number and `MODELS_TTL_MS`'s argument** — ten minutes — because that is the other fact here that costs a spawn to re-measure and is re-measured by the same spawn. Two different budgets would let the model picker and the New session strip disagree about the same harness on the same machine. Emphatically **not** `LOGIN_PROBE_TTL_MS`: three seconds is a cache in front of a question this daemon may ask whenever it likes, and this is one it can only ask by starting a session somebody asked for. **What the expiry buys is that the list of clearings does not have to be exhaustive**, which is the whole reason it is not a control-flow problem. Somebody who signs in by running the CLI in a terminal on the machine itself has told this process nothing, and no hook, route or watcher can be relied on to notice. An observation ages; a verdict does not. The early clearings, in the order somebody meets them: 1. **A successful start.** `Session.start` and `Session.openResumed` call `forgetStartRefusal` on the success path. Free, and — because the record never reaches `admit` — the capability sweep still spawns the harness for real on every builder open, so this fires by itself the moment the harness works. 2. **A credential arriving**, which has two doors: `PUT /agent-auth/:agent`, and a login run reaching `done`. **Two of the five `forgetAvailability` call sites, and the other three must not.** Those are a key being deleted, a sign-out and a login abandoned — a credential going *away* — and the first would erase the record of the refusal the deleted key was pasted against. ⚠ **The login-run door was missing for a draft, and the cost was the whole flow reading backwards.** `agentStance` puts `start_refused` above `signed_in`, so somebody who signed in *inside the app* kept the badge **would not start**, kept no tile, and lost the sign-in door itself — `signInOffered` wants `loggedIn === false` — while `POST /sessions` went on refusing on the stale message. It clears on the run *ending* rather than on it succeeding: this route has no verdict to read, and a wrongly cleared record costs one start attempt that re-records it, where a wrongly kept one costs ten minutes of a screen contradicting the wizard just finished. ⚠ **And `daemoncheck` asserts this per handler rather than by counting the file**, because a count is not an anchor. Measured: moving the call out of the `PUT` handler and into `POST …/logout` — whose parameter is also named `agent`, so the line is textually identical — left the total unchanged and every driver green, while inverting the rule the code beside it argues for. `reloadCredentials`' own pair two blocks away already used the right technique and said why. 3. **A plugin lifecycle event.** `PluginHost.syncContributions`, which covers install, update, remove, enable and disable — a harness whose command, arguments and environment may all have changed is not the harness that refused. 4. **Pressing Check again.** `POST /agent-auth/:agent/recheck`, and see Q3.538 for why a control is owed at all. **Status.** Done. ### Q1.624 — When does the machine find out that a harness will not start? **Reported, in the same breath as Q3.539**: *"не должно быть такого, что демон после добавления плагина не знает о невозможности запустить."* **Question.** Q2.221 gave the daemon a way to *record* a refused start, and `Session.start` writes it. But the only thing that ever started a contributed harness was a person pressing Start — so the first press was still the measurement, and still paid for it. Is that acceptable? **Decision. No, and what was missing was an occasion rather than a mechanism.** `PluginHost.probeContributed` fires the **existing** capability read — `admit`, `claim`, a real handshake and a real `session/new`, the same one the agent builder performs — for each harness a plugin contributes, at the three moments the machine has just learned about one: install, update and enable. Nothing reads the result. Whichever way it goes, `Session.start` has already written or cleared the record, so the answer is on `GET /agents` before anybody taps anything. That is the whole implementation, and it is small because Q2.221 put the record where a *start* would find it rather than where a route would. **Detached, and that is what makes it affordable.** An install runs under `exclusive()`, and a spawn plus a handshake is 627–2260 ms per harness measured across the four built-ins. Awaited, `POST /plugins` would hold its answer for the length of somebody else's binary starting up, and a plugin whose harness never completes a handshake would hold the mutex with it. Bounded by what it calls rather than by anything new: `MAX_CONCURRENT_ASKS` meters every one, and a manifest may contribute at most `MAX_PLUGIN_HARNESSES`. **Three occasions, and the three exclusions are the interesting half.** Remove and disable have nothing to ask about — the harness is no longer offered, so a spawn is work about something nobody can reach. **Boot is the one that is tempting and is still refused**: it would put a spawn per contributed harness in front of `autoResume`, which is already starting an agent per interrupted session, and Q1.623 decided the record is in memory precisely so that a restart does not carry a measurement across a moment when the machine has most likely changed. So a daemon that has just come up is honestly ignorant, and one press is what that costs. `daemoncheck` asserts all six occasions, because the call is detached and every outcome is swallowed — the *occasion* is the only observable thing about it. **Status.** Applied. ### Q1.625 — What credential does a one-line installer hold, and for how long? **Rule.** Only the **enrollment code** reaches the host's disk. An API key is used in memory to mint one and then dropped; a session minted by signing in is revoked before the script exits, in the trap rather than on the happy path. **Why.** This is the same rule Q1.53 states for the provisioning key, arriving at the same host by a different door. A daemon host runs agents as its owner and `agentEnv`'s strip is hygiene rather than a fence — the agent has that uid and can read the env file, `/proc//environ` and `REEMOAT_DB`. An account credential left there is the whole account; an enrollment code is single-use, lives an hour and is worth exactly one machine. The installer is the one moment when a person's password is legitimately typed on that host, because it is *their* laptop — and that is an argument for the password, not for what it mints. **`DELETE /v1/me/sessions/current`, not `…/sessions/:id`, and this is not a preference.** `app.ts` registers `…/current` at 2675 and `…/:id` at 2760, with `app.use("/v1/*", requirePasswordCurrent(db))` at 2736 between them. Every account `cpctl admin adduser` creates owes a password change, so the `:id` form answers `403 password_change_required` for exactly the person most likely to be running an installer — and the failure would be a live session left behind on a path that looked like it cleaned up. **The same gate is why `POST /v1/machines` can refuse.** It is at 3155, below the line, so an admin-created account cannot add a machine until it has set a password — and the installer cannot do that for them (`POST /v1/me/password` needs the current one). It prints the remedy and exits rather than looping. **Status.** Current ### Q1.626 — Waiting for a mailed confirmation, without locking somebody out of the account they just made **Rule.** The installer waits on **a keypress, not a timer**. It prints that the mail was sent, then spends exactly one `POST /v1/login` per Enter, capped at five. **Measured.** `DEFAULT_THROTTLE` in `packages/control-plane/src/throttle.ts` is `{threshold: 5, windowMs: 15 min, baseBlockMs: 30s, maxBlockMs: 15 min}`, and `POST /v1/login` calls `fail(loginKey(name, address))` on every 401 — before the await, so a refusal costs its count whatever else happens. A five-second poll therefore spends the entire budget in **twenty-five seconds** and then sits in a block that doubles to fifteen minutes, against an account created ninety seconds earlier, on somebody's first contact with the product. **Why five and not three or ten.** Five *is* the budget. One attempt per human action means it is only spent by somebody actually opening mail, and stopping at the threshold rather than past it means the script can never be the thing that causes the block — it names the rule when it stops. **It also matches the browser.** `Gate.tsx` submits nothing on mount and reaches the confirm screen by the link being opened, never by polling for it. **Rejected: a `GET /v1/register/status` route.** `POST /v1/register` goes to real trouble to answer identically for a fresh address and a taken one; a route saying whether an address had confirmed would hand that enumeration straight back. **Rejected: telling the person which of the two things is wrong.** The script cannot. `POST /v1/login` answers `401 invalid_login` for an unknown name, an unconfirmed address, a wrong password and an account with no password row, and that is deliberate — so the message says it cannot tell rather than guessing. **Status.** Current ### Q1.627 — The installer's default origin behind a TLS-terminating proxy **Rule.** `GET /install.sh` substitutes `installOrigin(c, trustedProxyHops)` — `publicUrl` corrected by `x-forwarded-proto`, and **only as far as an operator has declared a proxy**. **Why `publicUrl` alone is wrong, and wrong in production specifically.** `@hono/node-server` derives the scheme from `socket.encrypted`. This service is served over plain HTTP behind a proxy that terminates TLS — Traefik forwards `app.reemoat.com` to `http://control-plane:7888` with `passHostHeader` — so `publicUrl` answers `http://app.reemoat.com`. **Measured against the live deployment**: `http://app.reemoat.com/v1/instance` answers `301` to the `https` form, because the `web` entry point redirects permanently. `bootstrap.sh` does not follow redirects — a redirect is somebody else's idea of where the control plane is — so an installer built from `publicUrl` would refuse on its very first request, on the only deployment shape the feature exists for. Nothing installed, and the message would blame the control plane for not being one. **Gated on `trustedProxyHops`, the same gate `callerAddressOf` uses.** The header is caller-supplied. Believed from a direct client it would let anybody make a plaintext instance hand out an `https://` default — an installer that cannot reach what it was told to join. At zero hops it is ignored and `publicUrl` stands, which is exactly right for a host with no proxy. Both halves are asserted in `imagecheck`, from a real container, because a header this service reads only under a condition is not something an offline driver can pose the question to. **⚠ It was scoped to this route once, and the scoping is what let the rest of it survive.** `publicUrl` also feeds `controlPlaneUrl` on the four code-minting routes, and what they answer goes verbatim into a daemon's `REEMOAT_CONTROL_PLANE`, into the panel's `export …` paste and into `cpctl`'s. This entry used to guess the consequence, and guessed it wrong — it said `enroll.ts`'s `fetch` follows the 301 and that a `POST` is re-issued as a `GET`. **Measured against the live deployment**: `GET http://app.reemoat.com/v1/instance` answers `301`, but `POST http://app.reemoat.com/v1/enroll` answers **`308`**, which preserves the method and the body. Enrollment therefore succeeded every time, and the only cost was the single-use enrollment code crossing the first hop in the clear on a machine whose operator had typed `https`. The redirect paid the bill, which is why nobody saw it. **What it costs where the plaintext port does not answer at all.** Measured on a stand serving 443 and not 80: the one-shot installer created the machine, took `http://…` from the 201 and wrote it into the env file, and the daemon's enrollment `POST` then failed at the *connection* — `could not reach the control plane at http://…: fetch failed` — exiting 2 into launchd's `KeepAlive` twelve times over, while `wait_healthy` reported `health: FAILED after 30s` for a machine that had been created and would never enroll. The setup-code path was untouched throughout, because `create_machine` returns before it reads `controlPlaneUrl`; that is the other half of why this stayed invisible. **Status.** Applied to `/install.sh` and to all four code-minting routes. `relaycheck` pins both directions on `POST /v1/machines` and on the enrollments route beside it; `imagecheck` still pins the installer's half from a real container. ### Q1.628 — Which machines are running a July `claude`, answered without opening a shell on any of them **Question.** With the CLIs no longer vendored (Q4.114) and refreshed daily by each machine on its own, the fleet's builds are whatever each machine's last `deploy/agents.sh` run left, and the only place that fact existed was the machine itself: one machine's picker names the build under its model list (Q6.106), and nothing named it for the fleet. "Which machines are still on a July `claude`" was a shell on each host. **Decision.** The daemon announces which build of each built-in harness's CLI it *would launch* on the tunnel handshake, as `AGENT_CLIS_HEADER` (`x-reemoat-agent-clis`: `claude=2.1.259;codex=0.153.1;kimi=-`), beside `DAEMON_VERSION_HEADER` and under its rule — advisory, recorded in `machines.daemon_agents` by `recordDaemonBuild` on every dial, read back by `GET /v1/admin/fleet` and `cpctl admin fleet`, branched on by nothing. It is read off `LocalRuntime.agentCli`, the same choice a launch resolves through, so it names the build a session would get rather than a copy that happens to be installed. The grammar lives once, in `src/relay/protocol.ts` (`formatAgentClis` / `parseAgentClis`), which ships in the control plane's image, so the daemon's spelling and the relay's reading cannot drift. **As fresh as the machine's last dial, and that is the decision rather than a gap.** The daily `deploy/agents.sh` run calls `forgetAvailability()` and does not redial, because a redial drops every live stream on the machine — every browser socket — for a report nobody is blocked on; a relay deploy, a network blip or a daemon update refreshes it, and one machine's live answer is its own `GET /agents/capabilities`. A re-announce with no redial would need the daemon to *send* the control plane something outside a connection it opens anyway, which is the second control-plane request Q1.9 refuses. **Refused whole, never cut.** `readAgentClisHeader` mirrors `readDaemonVersionHeader` with one measured difference: the value is a list with a grammar, so an over-long, malformed or duplicate-id value is `null` rather than a prefix — a label cut short is still a label, a list cut mid-entry stores a version nothing reports. Absence, a refusal and "no CLI for any harness" all land on the same `null` on purpose: telling them apart would need a comparison on `DAEMON_VERSION`, which `compatibility.md`'s rule 1 forbids. Shipped daemon-first, because the daemon answers and the control plane asks; an older daemon dials, enrolls and is listed with the field `null` — `relaycheck` drives that, the redial where the newer value wins, and the malformed values that still connect. **Alternatives taken out.** A column per harness, which puts the relay back in lockstep with the daemon's harness list. Carrying it at enrollment, which freezes it for the life of the machine — `DAEMON_VERSION_HEADER`'s own argument against that. A "refresh" verb on the control plane, which is Q7.42: a report, never a verb. **Status.** Applied. `relaycheck` pins the row, the redial, the omission and the refusals; `daemoncheck` pins what the daemon sends and that an empty inventory sends no header at all. ### Q1.629 — When was the password last changed, and when was a key last used? **Decision.** Two nullable columns, `users.password_changed_at` and `api_keys.last_used_at`, added by `ALTER TABLE … ADD COLUMN` with `CP_SCHEMA_VERSION` unmoved. `GET /v1/me` answers `passwordChangedAt`; both keys routes answer `lastUsedAt`. The first is written by `markPasswordChanged` from `POST /v1/me/password` and the mailed reset only — a temporary password an admin issued, the bootstrap row and the sign-in rehash leave it `NULL`, so "Set" on the Account row means *issued and never replaced*. The second is touched on an accepted bearer lookup **at most once a minute per key**, `KEY_TOUCH_INTERVAL_MS`, compared in the middleware and again in the `UPDATE`'s `WHERE`, so two overlapping requests cost one write and a revoked key is never touched. **Why.** The settings redesign turned two forms into rows, and a row needs a value: "Changed 3mo ago" and "last used 2d ago" are what make a stale key recognisable on a phone. A write per request is the cost `schema.sql`'s own comment on `api_keys` warns about, which is what the minute buys — the row reads "2d ago" either way. Additive, so `compatibility.md` rule 3 holds: an older web client ignores the fields and an older control plane answers without them, which is why `Me.passwordChangedAt` and `ApiKeyRecord.lastUsedAt` are optional on the wire and the row says "never used" for `undefined` and `null` alike — it cannot tell them apart, and "unknown" is not something a person can act on. **Alternatives taken out.** Deriving "changed" from `password_hash` moving, which counts the rehash on sign-in. Touching `last_used_at` on every request, per the schema comment. Answering the age as text from the control plane, which is a string a client cannot re-derive when it reloads a minute later. **Status.** Applied. `relaycheck` pins the throttle (two lookups inside a minute write once), the null arm on a row from before the column, that a revoked key's value stops moving, and that a migrated file migrates twice harmlessly. ### Q1.630 — Does minting your own API key, or changing your address, need your password? **Decision.** No, neither. `POST /v1/me/keys` takes a session and nothing else; it reads no body, so a bodiless request and `{}` mint alike and a stray `currentPassword` is ignored rather than verified. `PUT /v1/me/email` takes the session and the address. `POST /v1/me/password` still asks — as decided, alone and inline; since the amendment below neither word holds, because the API-key arm of `PUT /v1/me/email` asks too and both go through `verifyCurrentPassword`. `proveCurrentPassword` is deleted with its two callers. **The email half reopens Q1.402's chain, by decision.** With a stolen session bearer and nothing else: repoint the address, confirm it with the same session, `POST /v1/forgot`, `POST /v1/reset` — a password the thief chose and every other session revoked. The owner weighed that against a password field on every address change (2026-09-04) and chose the field's absence; the route's docblock keeps the chain written down so nobody rediscovers it as a bug. What bounds it is that the session is what is stolen either way, that Devices lists every sign-in and ends any of them, and that a password change still asks. **Why.** The owner's decision on 2026-09-04, in one line: the session is enough. The route had asked since it replaced the deleted admin minter (Q1.610's escalation: a session token lifted from `localStorage` converting into a permanent credential in one request). What answers that now is the keys screen rather than a gate — every key is listed with when it was made and last used (Q1.629), the one this browser holds is marked, and any is one tap to revoke — so a key minted from a borrowed session is a row its owner can see and kill, and the sign-in a leak would need is the same sign-in the leak already has. The cost is stated rather than hidden: a stolen session is now worth a permanent key until its owner looks at that screen. **Alternatives taken out.** Asking only when the session is older than N minutes, which is a rule nobody could predict from the screen. Keeping the gate and hiding the field behind a "use my session" toggle, which is the same tap count with a lie in it. **Amended 2026-09-05.** The email half is split by `caller.via`. "The session is enough" was decided about a session, and the route was reading no credential kind at all — `callerAuth` sets `via` to `"api_key"` for an `rk_` bearer, and the only read of it was `GET /v1/me` echoing it back — so the chain above was open to an **API key** as well, and `relaycheck` was driving it with one from `seedKey` under the words "the session alone". That is a different credential with a different failure: a session is a person signed in, listed under Devices and one tap to end, while a key is a machine credential that sits in `~/.reemoat/cpctl.env`, a backup or a CI log and can leak with no person anywhere in the chain — and there is no admin password reset behind it (Q1.403). So `PUT /v1/me/email` now asks an API-key caller for the current password, verified through `verifyCurrentPassword` — the helper `/v1/me/password` now verifies through as well, its once-inline check moved there so the two routes that ask cannot drift, spending the same `passwordChangeKey` because it is one password being guessed — and asks a session nothing. The no-password-row exemption stands for a key, and is unreachable by a session. `POST /v1/me/keys` is unchanged: cloning a key escalates nothing a key does not already have. The consequence for `cpctl` — an API-key caller unless `REEMOAT_CP_KEY` came from `cpctl login`, which prints a session token into the same variable — is that `cpctl email` keeps its password prompt for a key and asks nothing under a session, because `currentPasswordBody` reads `via` off `/v1/me` and sends what the route will read for whichever credential the shell holds, while `cpctl key` loses one the server never read. **Status.** Applied. `relaycheck` pins the 201 with no body, with `{}`, and with a wrong password in the body; the password gate stays pinned on `POST /v1/me/password` and on the API-key arm of `PUT /v1/me/email` — refused without the password, refused with a wrong one, through with the right one, and the session and no-password-row arms through with none. ### Q1.631 — May an admin see or retire somebody else's API key? **Decision.** No, and nothing else about anybody's keys either. `GET /v1/admin/users/:id/keys` and `DELETE /v1/admin/users/:id/keys/:keyId` are deleted, `GET /v1/admin/users` no longer counts a person's live keys, and the Users screen's "API keys" item and the panel it opened are gone with `adminUserKeys` and `adminRevokeKey`. What is left of an admin's reach over `api_keys` is the account itself: disable ends every credential's use through `callerAuth`'s live `disabled_at` read, and delete removes the rows. A key is listed by its holder alone (`GET /v1/me/keys`) and retired by its holder alone (`DELETE /v1/me/keys/:keyId`, `cpctl keys --revoke`), so `revokeApiKey` stands behind one route. **Why.** The owner's instruction on 2026-09-06, in one line: an admin cannot look at a user's API keys, nor do anything at all with them. Q3.217 kept the panel against an earlier instruction to remove it, on the argument that its revoke was the only writer of `revoked_at` for a key you do not hold, so that deleting it would leave a credential the code reads and nothing can write. That argument has expired: `DELETE /v1/me/keys/:keyId` has been the holder's own writer since Q1.611, and the leaked-key case Q1.408 was about is the account's own key, which its holder revokes from the keys screen. What the admin route bought after that was a second reader of somebody else's credential list — the prefixes and when each was last presented — and the owner's model of the product is that a key is between the person and their machine. **Alternatives taken out.** Keeping the count on the fleet list ("N keys" with no list behind it): still a fact about somebody's credentials, and the instruction was about anything at all. Keeping the revoke and dropping the read: a revoke needs an id, and the only way to an id was the read. Leaving the routes and hiding the menu item: a route no client can reach is the shape Q7.74's `withKey` was deleted for. **Status.** Applied. `relaycheck` asserts both routes answer 404 to the admin that used to reach them, that a fleet-list row carries no `keys` field, and that the holder's own list and revoke still work — the revoked row still listed, a second revoke a 404 — and, by reading `app.ts`, that no route mounted under `/v1/admin/users/:id/` reads or updates `api_keys`. `webcheck` asserts `UsersSection` imports nothing from `KeyRow`, that `cp.ts` exports neither function, and that the row's one panel is the machine limit. Amended by Q3.686: the machine limit is a leaf screen opened from the row's menu, not a panel under the row. ### Q1.632 — Where does an instance point somebody who has no machine? **Decision.** At an address the operator sets in the environment, and at nothing by default. `REEMOAT_CP_MACHINES_OFFER_URL` is read once in `main.ts`, validated with `isBrowserReachable`, warned-and-ignored if it is not http(s), and handed to `createControlPlaneApp` as an option; `GET /v1/instance` publishes it as `machines.offer`. In the browser `parseInstanceConfig` reads it through the existing `isAbsoluteHttpUrl`, `machineOffer` answers `null` fail-closed on an unknown config, and `machineOfferHref` builds the link with `URLSearchParams`, putting `me.email` in it so the far side can prefill its own form. `MachineOffer` draws one row and returns `null` when there is nothing to offer, on `MachinesSection`, `SessionBrowser` and `AppShell` — inside the `mayAddMachine` arm, below `installCommand`, and never in the composer strip. **Why.** The checkout at `get.reemoat.com` has accepted `?email=` since it was built, with a docblock naming "a link in the app" as the case it was for, and nothing had ever generated one: the app had no notion the shop existed and the shop cannot ask who is signed in. What was missing was the one place both facts are known at once, which is the screen a new account lands on with an empty fleet. **Environment-only, and not a `SETTING_KEYS` row — which it was, for half a day.** The first version put it beside `machines.per_user` with a field on the Server settings screen, on the argument that an admin owning the value is what lets a fork use the feature for its own shop. The owner's instruction on 2026-09-06 was that the option is not for public use, and the argument behind it is stronger than the one it replaced: `SETTING_KEYS` is drawn on the settings screen of *every* instance and projected by `GET /v1/admin/settings` to every admin, so a row there does not offer a fork a feature — it puts one particular shop, run by whoever runs the deployment, in front of every administrator of every copy. `pluginCatalogueUrl` is already kept out of that array, so the shape existed; only its reason is different (a CSP built once at construction cannot follow a database). Whoever owns the deployment owns the env file. The price is that changing the address needs a restart, which is right for a value that changes about never. **An address rather than a boolean, which is `pluginCatalogueUrl`'s decision and its argument.** A client that renders a link cannot be told "there is an offer" and left to invent where it goes. The stake here is one degree higher than a refused `fetch`: this value goes into an `href` a person taps, carrying their email address. And the boolean form needs a URL compiled into `packages/web` — which, under AGPL, puts one project's shop on every fork's screens in a build they are entitled to and have not changed. Absence-is-off is already this file's dominant idiom (`plugins.catalogue`, `registration.email_domains`, `mail.reply_to`), so a URL setting *is* the parameter that is off by default. **Only in the `mayAddMachine` arm, and the reason is commercial as well as structural.** A machine bought on the far side comes back here to enroll, which needs a free slot: at or over the limit the dial is refused with a `403`. An offer in the other arm therefore takes money for a host this control plane will not connect — and draws it beside the sentence saying the fleet is full, which makes the "Add a machine" heading a lie while `machineQuotaNotice` stays literally `null`-iff-`mayAddMachine`. `webcheck` pins it from both sides: after `installCommand(` and before the `machineQuotaNotice(` call. **`URLSearchParams`, not concatenation, and measured.** The base is operator-supplied and may carry a path, a trailing slash, an existing query or a fragment. Against `https://get.example#top`, `` `${base}?email=…` `` produces `https://get.example#top?email=…` — the whole query inside the fragment, so the far side receives none of it and the link still opens and still looks right. `encodeURIComponent` cannot fix that; it does not answer `?` against `&`, or where the `#` goes. `set` rather than `append`, so a base already naming an address is replaced rather than joined by a second the receiver picks between arbitrarily. And the serialisation turns `+` into `%2B`, which is not cosmetic: the service on the other side reads the query with Hono, whose decoder replaces `+` with a space *before* percent-decoding, into a field that page renders `readonly` — so a bare `+` would arrive as a mangled address nobody could correct. **An unverified address is still prefilled.** `emailVerified` answers whether *this* control plane may send to it — it gates recovery and invitation, and exists because an unverified claim reserves nothing here. That is a statement about this instance's trust, not about whether the string is the one the person would type into somebody else's checkout. Withholding it would empty the form for exactly the newest account, whose confirmation link is still in their inbox, and that is the person this offer is most for. **Alternatives taken out.** A boolean flag with `get.reemoat.com` compiled in: shortest, and wrong for a fork. A pair — `offer_enabled` beside `offer_url` — worse than either: two fields that can disagree, which is what `mailConfigured`'s docblock exists to argue against. A `SETTING_KEYS` row with the admin control removed but the key left in: the screen would stop drawing it and `GET /v1/admin/settings` would go on projecting it to every admin, which is the half of the exposure that mattered. Deep-linking the checkout as `/checkout?plan=cloud&email=…`: it works today and is brittle by construction — that route names a catalogue row and answers 404 for one that is not active, the catalogue is Postgres rows in a service on another deploy pipeline this build cannot see, and a plan had just been retired there by switching a row off. The link names the shop's root instead, and the shop resolves its own plan. Drawing it in the composer strip: `MachineLine` has the same three-arm empty state and is the obvious fourth site, and a link off the origin between somebody and starting work is the worst instance of a control leaving the strip. Signing the address so the far side could trust it: it protects a form default — the order is built from the POST body either way — at the cost of a shared secret spanning two repositories with no rotation story. **Status.** Superseded — Q1.650: the offer is deleted, by the owner's decision of 2026-09-23. Nothing above was wrong; it is kept whole as the way back. ### Q1.633 — May an admin hand somebody a grant on a machine they do not own? **Decision.** No, and the routes that did it are deleted rather than guarded. `PUT /v1/admin/grants` and `DELETE /v1/admin/grants` are gone. Sharing is the owner's verb — `GET`, `PUT` and `DELETE /v1/machines/:id/grants`, driven by `cpctl shares`, `cpctl share` and `cpctl unshare`. `GET /v1/admin/grants` is kept. **Why.** The two deleted routes upserted and removed any `{userId, machineId, scopes}` behind `requireAdmin` alone: no check that the caller owned the machine, no consent from the person who did, and nothing on any screen afterwards. A grant is *full access* to a machine (Q1.11), and that machine runs agents as its owner's uid with no sandbox, so the pair was **one request from an admin credential to arbitrary code execution on somebody's computer**. It is the same class of escalation Q7.74 spent a whole change removing — an admin credential becoming somebody else's — arriving through the door marked *access* rather than the one marked *identity*, which is why deleting the identity routes left it standing. **Why it survived so long, and why that argument is now spent.** It was the only way to share a machine at all — the sentence `machines.ts` used to justify leaving the name collision at `nameVisibleToGrantees` open (Q1.501). Deleting it on its own would have removed co-working rather than a privilege. So this is a *move*, and the replacement differs by one line: `ownedMachine(c)`, the resolver `PATCH /v1/machines/:id`, `POST /v1/machines/:id/enrollments` and `POST /v1/machines/:id/revoke` already use. A machine you do not own answers `404 machine_not_found` rather than 403, for that resolver's reason: nobody may map the fleet by watching which ids answer differently. **The invariant this buys**, stated over the service rather than over a route — the shape Q7.74 used for `api_keys`, and greppable the same way. `INSERT INTO grants` appears in exactly three places: `createOwnedMachine` (a machine coming into existence, granted to the owner it is created for), `PUT /v1/admin/machines/:id/owner` (Q1.635) and the owner's own route. **No route under `/v1/admin` adds or widens a grant on a machine that already has an owner, for anybody other than that owner.** ⚠ The last clause is not decoration: the same-owner re-label falls through Q1.635's guard and re-upserts that owner's own all-scopes grant, so the shorter wording would be false against the code, and widening a grant somebody already holds in full is not the power that was removed. `DELETE /v1/admin/users/:id`'s `DELETE FROM grants WHERE user_id = ?` is a sweep inside an account ceasing to exist, not a fact about a grant. **What is deliberately kept, and why a read is not the power.** `GET /v1/admin/grants` stays. An operator who cannot see the grant table cannot answer *why can this person reach that machine* at all, and the audit is the half of this that has to survive the deletion of the write. **The body is read before the machine is resolved, and the order is the fix rather than the style.** It was the other way round first, and an `await readJsonObject(c)` between `ownedMachine(c)` and the `INSERT` is a window the caller controls: send a valid `content-length`, let the ownership check pass, have the machine revoked, then finish the body. The insert lands on a machine with no owner, and the `DELETE` then answers 404 — a grant its owner cannot remove. With every `await` ahead of the check, the check and the write are one synchronous run of the event loop and the window is not expressible. `relabelMachine` states the same rule for the same shape. **Both writes cost a write-throttle slot** (`spendWrite`, as `machine_share` and `machine_unshare`), which took that budget from nine call sites to eleven. They are one row each rather than a transaction, which is why they were nearly missed; what earns them a slot is that sharing is reachable by *every* signed-in owner and each request leaves a permanent `grants` row behind, on the file the relay shares. **⚠ What none of this buys, so that nobody infers it.** Whoever operates this control plane holds `signing_keys.private_pem` and can sign a token for any machine with any `sub`; the daemon checks the signature, the issuer and the audience and never compares the subject to anything (`src/auth.ts`). This closes the door an admin *credential* opens. No route deletion closes the operator's reach, and self-hosting stays the only version of "not trusted" this system has — `SECURITY.md` says so in the same breath as the claim, on purpose. **Alternatives taken out.** Guarding the admin routes with an ownership check instead of deleting them: two spellings of one verb, and the one no client drives is the one that rots — the shape Q7.74's `withKey` was deleted for. Keeping `DELETE /v1/admin/grants` alone, on the argument that an admin should be able to take access away: the denial side an admin still has is revoking the machine, which the owner can see, where a silently removed grant is a change to somebody's access with nothing anywhere saying so. Leaving both routes and dropping the `cpctl` verbs: a route no client reaches is not a route nobody can reach — the credential is an HTTP one. **Status.** Applied. `relaycheck` asserts both admin writes answer `[404, 404]` while the read still answers 200, that a non-owner sharing a machine is told `404 machine_not_found` rather than 403, that an admin cannot get the machine into their own list because there is no admin door left to do it with, and the owner's whole path: share, list, widen, take back, a second un-share as `404 grant_not_found`, and both refusals of the owner's own grant as `409 grant_is_owner`. ### Q1.634 — Who is a share addressed to, and why is there no name box? **Rule.** A user id, typed in full. `PUT /v1/machines/:id/grants` takes `userId` and nothing else; the person being shared with reads their own id off `GET /v1/me` (`cpctl me`) and says it out loud. `GET /v1/machines/:id/grants` answers with the name beside the id, because by then the owner has already been told who that is. **Why not a name.** There is no directory an ordinary account may read, and a name lookup here would create one — a user-enumeration oracle on a route *every* signed-in person can reach, answering `404 user_not_found` for a name nobody has and 200 for one somebody does. That is a **new** oracle rather than a second copy of an old one: Q7.78 concedes registration's and bounds it, and `registration.enabled` can be switched off, which closes that door while leaving this one open. Addressing by id is not the same exposure, because an id is unguessable where a name is the thing its owner tells people: probing ids enumerates nothing, which is the whole of why the harder-to-use form is the one that ships. **What it costs, said plainly.** Sharing is a two-step human protocol: one person reads an id off their own account, the other types it. That is worse than a name box and it is the price of the route being reachable by everybody rather than by an admin. A share-by-name flow is a product decision with its own oracle to weigh, and it is deliberately not this change. **A suspended account is refused rather than written**, matching `POST /v1/provision` and `POST /v1/admin/users/:id/invite`, which both name the state (`409 user_disabled`). A grant written to a disabled account is inert while `callerAuth` reads `disabled_at` live, and then goes live the moment somebody re-enables them, with nothing on the owner's screen ever having said so. The share the owner would have wanted is the one they make after the account is back. **The owner's own grant is refused on both writes** (`409 grant_is_owner`) rather than upserted. It exists already and carries every scope, so the only thing `PUT` could do to it is narrow it — taking `machine:admin` off the owner's own hardware with no way back through this route — and `DELETE` would leave somebody owning a machine that appears in no list, since `GET /v1/machines` joins `grants`. That is the exact failure user-owned machines exists to remove, and the lesson `createOwnedMachine` already records. Retiring the machine is the verb for giving up your own access. **Status.** Known limitation ### Q1.635 — May an admin take a machine from the person who owns it? **Decision.** No. `PUT /v1/admin/machines/:id/owner` answers `403 machine_owned` whenever the machine has a live owner who is not the target, so what it can still do is adopt an *ownerless* row and re-label a machine for the owner it already has. An ownerless row somebody already holds a grant on may be adopted only **to one of those grantees** (`403 machine_granted`). Adoption burns the machine's outstanding enrollment codes and reports `enrollmentCodesInvalidated`. **Why the first guard.** Taking a machine from somebody was one request: `releaseOwner`, an insert, and a grant with every scope — with the previous owner's own grant deliberately left in place (Q1.43), so that nothing they can see changes. Full authority on a working machine, invisible to the person on it. **Why the second, which is the class the first misses.** Both guards were first written as *an ownerless row has nobody to ask*, which quietly reads **no owner** as **no users**. A machine registered before ownership existed can be enrolled, online and carrying other people's grants — precisely the state `nameVisibleToGrantees` exists for. Keyed on `ownerOf` alone, an admin could adopt such a row with every scope and be answered 200. `dependants` answers both halves — the owner and the grantees — and it is what this route and Q1.636's guard both read. A genuinely orphan row (enrolled, no owner, no grants) has nobody to ask and stays the operator's, which is the case the guards were justified by in the first place. **Why adopting to an existing grantee stays open.** It is what the route was written for, and it is also the answer to what Q1.633 took away: deleting the admin grant writes left legacy grantees with no way to list, re-scope or revoke a share, because every replacement route resolves through `ownedMachine`. Regularising the row to one of them gives the machine a person to ask and gives them the owner's verbs. An operator who wants it for themselves revokes it, which is the denial side and is visible. **Why the codes are burned in the same transaction.** ⚠ Q1.636's guard protects *minting* and not *redemption*, so without this it is one request out of order away from nothing: mint a code for an enrolled ownerless row while it is still nobody's, adopt it to somebody, then redeem the code you kept. A fresh mint now answers 409 while the retained one still returns a tunnel key and replaces the daemon — the machine acquires an owner and is substituted *after* they have it. `burnMachineCodes` is the same act `POST /v1/machines/:id/revoke` and the account sweep already perform, and it costs the adopting owner nothing they cannot redo, since minting is their own route now. **403 rather than 404**, against the 404 rule the owner's routes follow. The caller is an admin who can already list every machine and its owner through `GET /v1/admin/machines`, so there is nothing here to conceal, and a 404 over a row they were just shown would be a lie about existence rather than a refusal. **Status.** Applied. `relaycheck` asserts the refusal *and* that the ownership row and `acquiredAt` are unchanged after it — ⚠ the assertion it replaced proved the opposite, that a real transfer moves the date, which is what a driver written against the old behaviour is for — and that an ownerless legacy row is still adoptable, so a guard written as "refuse every owned machine" fails here rather than shipping. ### Q1.636 — May an admin mint an enrollment code for a machine that is already enrolled? **Decision.** Not while somebody depends on it. `POST /v1/admin/machines/:id/enrollments` answers `409 machine_enrolled` for a machine that has enrolled **and** has an owner or grantees. **Why.** Redeeming a code calls `issueTunnelKey`, which *retires* the machine's current tunnel credential. So minting one here and redeeming it on another host does not read somebody's machine — it **replaces** it. The owner's daemon is dropped from the relay as superseded, and every grant-holder's traffic for that machine, prompts and uploads and bearer tokens included, is delivered into the new process, while the owner's list still reads owned, enrolled and online. **Why the owner's twin route allows exactly this.** `POST /v1/machines/:id/enrollments` says so in its own docblock: re-enrolling takes the machine away from whatever holds it, *"but since only the owner can ask, the thing it takes it away from is their own daemon, which is what re-installing a host means."* That sentence is the entire justification and it does not transfer to a caller who is not the owner. This route carried no restriction at all. **Why owned, and not enrolled alone.** Both legitimate uses survive the narrower condition. `deploy/install.sh`'s daemon wizard runs `cpctl admin addmachine --owner` and then `cpctl admin enroll` against a row created one line earlier, so `enrolled_at` is null and nothing changes. And a machine registered before ownership existed has nobody to ask, so an operator re-installing their own admin-managed host is untouched — refusing there would leave no path rather than move one. **409 rather than 403.** The machine is not forbidden; it is in a state that makes this the wrong verb, and the message names the person who holds the right one. **Status.** Applied. `relaycheck` drives a machine that is created, enrolled and then refused, rather than asserting over a row it wrote by hand: a guard on `enrolled_at` that has never seen a real redemption presents as *the machine did not enrol* and passes for the wrong reason. ### Q1.637 — What is left after those refusals, and why is it disclosed rather than refused? **Position.** Machine **substitution** is left. It cannot be refused without restoring defects this record already closed, so it is made *visible* instead: `GET /v1/machines` carries **`enrolledBy`**, naming whoever's enrollment code brought a machine online when that was not the person reading the list. **The composition, in three requests each of which has to stay.** Revoke somebody's machine — `releaseOwner` frees the label in the same transaction (Q1.43). Register a new one *for them* under that freed name — `nameVisibleTo` filters `revoked_at IS NULL`, so the name passes (Q1.48). Mint its first code — the machine has never enrolled, so Q1.636's guard does not fire — and redeem it on your own hardware. Their list then draws the name they just lost, `owned: true`, enrolled and online, and it is your computer. No single route is wrong, which is why the driver for it runs all three end to end: a test per route passes while the composition stands. **Why neither obvious refusal is taken.** Keeping the label on revoke reinstates Q1.43's own defect — revoke `laptop`, create `laptop` again, and get a 409 naming a machine that appears in no list and can never be reached. Teaching `nameVisibleTo` about revoked rows refuses the owner their own recreate. And registering a machine for somebody is what `deploy/install.sh`'s daemon wizard does from the host being installed, which is a real flow rather than an attack. **Read off `machines.enrolled_by`, and the derived version is the lesson.** The column is added in `migrate()` and written at the redemption that sets `enrolled_at`, from the same statement, holding whatever minted the code — a user id, or a `pk_` provisioning-key id. It was derived from `enrollment_codes` first and was wrong four ways, ⚠ **all four reverting to `null`, which the route reports as *you enrolled this yourself***: - the rows are swept seven days after a code is used, so the answer expired; - `created_by` is deliberately left dangling on `DELETE /v1/admin/users/:id`, so an `INNER JOIN users` dropped it the moment the enroller's account went; - `POST /v1/provision` writes a `pk_` id no `users` row matches, so every provisioned machine named nobody at all; - `used_at` is stamped by four *burn* paths as well as by redemption (`superseded`, `revoked`, `user_disabled`, `user_deleted`), so an owner pressing "new enrollment code" twice — the likeliest reaction to noticing something odd — overwrote the other person's name with their own. The shape is worth more than the four: provenance read out of a table that is swept, mutated by burns and joined to rows somebody may delete is not provenance. **Measured.** The derived version also scanned and sorted the whole fleet's `enrollment_codes` per request — no machine filter, no index on `used_at` — on a route the web client polls every four seconds per machine, against a table any signed-in caller can grow as fast as `WRITE_THROTTLE` allows: **168 ms of synchronous event-loop block at 200k retained rows**, on the file the relay shares. The column costs one `IN` over the handful of ids a listing actually names, bounded by `MAX_MACHINES_PER_USER` at fifty. The `grants` read beside it got the index it had never had — `idx_grants_machine`, after the same listing measured 0.8 ms at 10k grants against **62.5 ms at 500k**, to return a single row. **Three answers rather than a name or nothing**, because collapsing the other two into `null` is what made the predecessor lie. `enrolledByFor` answers `null` only for the caller's own id or an absent one; a `pk_` id is *a provisioning key* — `POST /v1/provision` creates a machine for any account with no account at all, only `REEMOAT_CP_PROVISION_KEY`, so it is the **most** alarming case rather than the absent one — and an id with no `users` row is *a deleted account*. **⚠ The limit, stated because a disclosure that overstates itself is worse than none.** A name here does not by itself mean a substitution: `install.sh`'s daemon wizard is `admin addmachine --owner` then `admin enroll`, so *every* wizard-installed machine names the admin who ran the installer, and a substitution draws exactly the row a normal install draws. It is a name to recognise rather than a flag to trust, and the honest remedy for one you do not recognise is to re-enroll the machine yourself, which sets the field back to you. **Where it is drawn, and why it is one string.** `cpctl machines` prints `[enrolled by …]` last on the row, so it never pushes the columns above it out of line, and the browser renders the same fact from one function: `enrolledByText` answers `null` for every case that means *unknown* — you enrolled it, the machine predates the column, the control plane predates the field — so `MachinesSection` grows a subline and `MachineSection` a sentence **only** in the case the disclosure is about, rather than a line reassuring somebody it was them. One function rather than two spellings, because two spellings of one disclosure are two disclosures and only one of them survives the next shortening pass; the punctuation is each surface's own, the list's sublines being fragments where the machine screen's lines are sentences. **Status.** Known limitation ### Q1.638 — Whose terms does a fork's control plane serve? **Decision.** The three documents are compiled into `packages/web/src/legal.ts`, and the party they bind is compiled in beside them, in `OPERATOR` — one named sole proprietor in one jurisdiction, with a ⚠ block at the top of the file saying so and telling a fork to replace it. That is a deliberate departure from the rule `.claude/rules/cp-accounts.md` states for the two environment-only values, and it is the owner's call rather than a derivation. **Why the rule points the other way.** Of `REEMOAT_CP_MACHINES_OFFER_URL` that rule says it *"points at one particular shop run by whoever runs the deployment"* and that neither it nor the catalogue URL has a compiled-in default, *"because this is AGPL software and forks run their own control planes."* [⚠ the offer is deleted — Q1.650; `cp-accounts.md` now makes the same argument of `REEMOAT_CP_APP_DOWNLOAD_URL`, and it applies here unchanged.] Terms are that argument sharpened: they name a legal person with liability behind them, and they are drawn on the sign-up screen of **every** instance rather than on an admin screen. A fork that forgets to replace them is telling its users they have a contract with somebody who has never heard of them, and collecting their agreement to it under a button they pressed to sign up. **Why it is compiled in anyway.** `SOURCE_URL` is the counter-precedent and it is the shape adopted: a value that names one deployment, compiled in, carrying the instruction to change it at the site of the value. Two things decide it. The **prose** describes this software's behaviour — what an account is, what an agent may do on a machine, what the control plane stores and for how long — so a fork inherits documents that are *true*, and the marker tells it the one field that is not. And the documents were required at short notice for the instance this repository's author runs; a seam that made them absent by default on that instance would have solved a fork's problem by creating the operator's. **⚠ Amended: a switch was built, and it is not the one priced below.** The party stays compiled in; what became environment-driven is whether a deployment *publishes* these documents at all. `REEMOAT_CP_LEGAL_DOCUMENTS` is env-only with no compiled default, the third member of the family `cp-accounts.md` describes, and off is the default — an instance claims the documents rather than inherits them. With it off there is no document page, no consent box and no requirement on the register route, so a fork's users are never shown one operator's contract and never refused for failing to accept it. The party being compiled in thereby stops being a statement made to a fork's users and becomes a value that fork must replace before turning the switch on, which is what the ⚠ block at the head of `legal.ts` says. `instance.ts` reads the flag strictly — only literal `true` is a claim — and `App.tsx` has three states for a document route rather than two, because *not yet known* may not be drawn either way. **The way back for the party itself is written down so nobody re-derives it.** Move the party to an environment variable named REEMOAT\_CP\_LEGAL\_OPERATOR, published on `GET /v1/instance` beside `legal.documents` [it said `machines.offer`, deleted by Q1.650], read in `instance.ts` with the guard `isAbsoluteHttpUrl`'s sibling, and fall back to *"whoever runs this control plane"* — `gateNotice`'s own sentence, so two screens cannot disagree about who somebody is being asked to deal with. Its price is a field on that route, which `webcheck` lifts with `new Function`: the handler gains a free variable or the driver throws `ReferenceError` at call time, and four flat fixtures gain a field. **Status.** Current ### Q1.639 — Which leg of the native client leaves the webview, and why only one **Question.** The native shell bundles `packages/web` and loads it from `tauri://localhost`. Which of this client's three conversations — control plane, relay, daemon — has to move into the host process? **Measured, 2026-09-14.** `grep -rn "hono/cors" packages/control-plane/src/` returns nothing, and `grep -rn "access-control-allow" packages/control-plane/src/` finds only prose. The **control plane mounts no CORS middleware at all.** The daemon does (`src/server.ts`, `origin: "*"`, `allowHeaders: ["authorization", "content-type"]`, `credentials: false`) and so does the relay (`relay/proxy.ts`, the same `corsHeaders()`). `packages/web/vite.config.ts` records why the control plane has none: `/v1` is proxied in dev *"instead of making dev the one place a CORS rule has to exist for the control plane"*. **Decision.** Exactly one leg moves, and it is `/v1/*`. `cpSend` in `packages/web/src/native.ts` is `fetch(path, init)` in a browser and one `host_cp` call in the shell; the relay, the daemon and the WebSocket stay on the webview's own `fetch`, `XMLHttpRequest` and `WebSocket`, unchanged. **Why the others may not move, four reasons and any one is sufficient.** `sendWithProgress` is `XMLHttpRequest` because `fetch` reports no upload progress and a streamed body is Chromium-only, and a Rust client has no progress events. `withTimeout` composes an outer `AbortSignal` which `request` threads through, and an `invoke` cannot be aborted. `isTransportFailure` is a **negation** — anything that is not an `ApiError` — so a second definition of "the request was never answered" eventually disagrees, and the two ways it can disagree are *every subway tunnel signs the fleet out* and *nobody is ever signed out*. And the Noise handshake runs in the page: a daemon leg in Rust would give an encrypted stream two decryptors. ⚠ The device *key* is the one part that does live in Rust, and it does not contradict this — `host_device_dh` answers a shared secret rather than a key, so the page drives the handshake while holding none of the static. **Rejected.** Adding a CORS layer to the control plane — it would put a header on the one service that has never needed one, to serve a client that does not need it either. And `tauri-plugin-http` for everything, which fails on the first reason above. **Status.** Current. `pnpm nativecheck` asserts the control plane does **not** appear in the shell's `connect-src`: if it does, the split has stopped being one. ### Q1.640 — What a native client keys its credential on, and what it may not keep **Question.** In a browser the control-plane credential lives in `localStorage` under `reemoat.credential`, and the origin scopes it. A native shell has one webview origin for every server somebody might point it at. What scopes it there? **Decision.** The operating system's credential store, with the **server's origin as the lookup key** — `credential#`. So a credential cannot be *read* for a server it was not issued by: structurally, rather than because some code path remembered to clear it on a change. `host_set_server` also erases the previous origin's entry in the same act, so nothing is retained for a server this app is no longer going to present to. [⚠ reversed by Q7.148: it keeps it now, so a server switched away from stays signed in; the lookup key above is unchanged and is what the reversal rests on.] `#` as the delimiter because a URL origin cannot contain one, which makes the rule unambiguous with no escaping and makes a later `credential##` an extension of the shape rather than a migration away from it. [⚠ extended by Q1.651: it is that shape now — `credential##` — and a `credential#` written before it is moved there once, after `GET /v1/me` has named its owner.] **Why not keep it only in the host process**, which is stronger on the face of it: `cpFetch` attributes a 401 by comparing `credential === sent` **by identity**, and its docblock records the exact ten-second race that rule defends. A handle the page could not compare would lose it silently. So the value lives in the page's memory exactly as it does in a browser, in the keyring at rest, and **never** in `localStorage` — `setSession`'s native arm returns before the storage writes rather than beside them, and `webcheck` asserts the absence under all three names, including the two `LEGACY_STORAGE` ones a later launch would otherwise adopt. **What is never stored.** The person's password: `POST /v1/me/password` asks for the current one whichever credential presents, so a stored one would make that a formality. And any daemon credential — `REEMOAT_TOKEN` reaches a daemon with no grant at all, and a machine token is 300 seconds long and derived, so it belongs at `ensureToken` and nowhere near a keyring. **Durability is a probe, not a `cfg!`.** `credential::probe` writes a canary, reads it back, compares it and erases it, because a store that accepts a write and loses it is the failure that reads as working — a Linux box with no D-Bus session compiles and runs fine. A `false` draws **the sentence `cp.ts` already has** for a browser with storage disabled; two spellings of one state is a defect this repository has shipped before. **Status.** Current. ### Q1.641 — Why serving the web UI is an environment variable and not a setting **Question.** `REEMOAT_CP_WEB=0` makes the control plane an API and a relay with no public interface — a supported shape now that the desktop app carries its own copy of the client. `SETTING_KEYS` is where a value goes when an admin should be able to change it from the Server settings screen without a redeploy. Why is this not one? **Decision.** Environment only, restart to change, and the reason is mechanical rather than editorial: `createControlPlaneApp` registers `serveStatic` and the SPA fallback **once**, at construction, and Hono has no route deregistration. A row in `instance_settings` would be readable per request and would change nothing — the value and the behaviour would disagree until somebody restarted the process, which is a switch that lies. `app.ts` already makes this argument for `REEMOAT_CP_PLUGIN_CATALOGUE_URL`, where the value is compiled into a CSP built once; this is the same shape one directive over. **Rule.** The two switches deciding what this process *serves* spell themselves identically: `0`, `false` or `no` turn it off, `1`, `true` and `yes` mean **the default**, and anything else is a path. That is a trap rather than a nicety — an affirmative spelling read as a path resolves to a directory named `1`, `existsSync` fails, and every page answers 404 in a way indistinguishable from an image built without the bundle. **Measured.** `REEMOAT_CP_INSTALL` was given the three-and-three and `REEMOAT_CP_WEB` was not, and the comment beside the fix *named the other variable as carrying the same trap* — for two releases, while it stayed open. A paragraph that knows about a defect is not a check. `deploycheck` now reads both predicates out of `main.ts` and asserts they agree, which is what fails the day a third path-or-switch variable arrives with a fourth spelling. It also found that `REEMOAT_CP_INSTALL` was documented in **no** `.env.example` in the tree: the `SETTING_KEYS` sweep cannot reach an env-only value, so both are named in the `envOnly` list beside the other three. **Why they are two switches and not one.** Turning off the interface must not turn off `GET /install.sh`, which is how the next machine joins the fleet, and turning off the installer must not take the interface with it. Stated in `packages/control-plane/.env.example` because that is where somebody sets one and reasonably assumes the other follows. **Status.** Current. `relaycheck` asserts an API-only instance still answers `/health`, `/v1/instance` and `/install.sh` — with the substitution and the shell quoting, which until now was proved by `imagecheck` alone, in docker, with the bundle present — and that a browser at `/` gets the error envelope rather than a page or a bare 404. ### Q1.642 — What a device is, and why it is not an authorization subject **Question.** Somebody signs in from a laptop and a phone. Revoking the laptop without revoking the phone had nothing to act on: the only per-sign-in record was `user_session_origins`, whose two fields — an IP address and a `User-Agent` — are a claim the *caller* makes about itself and are documented as recognition, never identification. What should a first-class Device be, and what should it decide? **Decision.** A `devices` row per installation, owned by a user: an id, a name, a platform, when it was registered and when it was retired. `user_sessions` gains a nullable `device_id`. Retiring one ends every session bound to it and refuses any later sign-in that offers its id. **And a device decides nothing about access.** A grant stays `(user_id, machine_id)`, so every device of one person reaches exactly the same fleet, and `relay/authorize.ts` reads no device row. That is the requirement stated from the other end — *MacBook and iPhone signed into one account see the same machines* — and it is also what keeps the relay out of this feature entirely. **Why a device id is not a credential.** It is an identifier this service hands back. It is stored unhashed and returned in full, unlike everything in `keys.ts`, because holding one authorizes nothing: every request still carries the session token, and the id is read only *after* that token has resolved, to ask whether the installation has been retired. That single fact decides where the client keeps it — see Q1.643 — and it is why this table needs none of `keys.ts`' machinery. **What per-device revocation is, precisely.** A grouping key over sessions, plus a bind refusal. It is immediate at the control plane and it does **not** shorten the window on a machine token already minted, because nothing in the token-verifying half reads a device. Stated in `SECURITY.md` as a table rather than implied, because "revoke the laptop" reads as stronger than it is. **Rejected: a `public_key` column now.** The brief asked that a key be attachable later *without another schema redesign*, and `migrate()` is exactly that guarantee — so the column buys nothing today and costs something real: a column nothing writes reads as *these rows are key-attested*, which is an unenforced security claim sitting in the table per-device revocation is argued from. **⚠ Amended: the column exists now, and the rejection's own reason is the specification it was built to.** Phase 5 added `devices.public_key`, and what makes it right today is precisely what made it wrong then: it is written — by `registerDevice`, from the key the shell generated — and the claim is *enforced*, by the daemon, offline. A capability names the device's key in RFC 7800 `cnf.jkt`; the `Noise_IK` handshake proves the caller holds the matching private half; the daemon compares the two and refuses `wrong_device` otherwise. So "these rows are key-attested" stopped being an unenforced security claim and became a checked one, which is the only condition under which this entry ever agreed to the column. **And the framing that kept everything else above true.** The key is **authentication, not authorization**. A grant is still `(user_id, machine_id)`, still full access to the machine; `relay/authorize.ts` still reads no device row and must not learn to. What the key adds sits one layer down and changes nothing this entry decided: which devices exist, what a grant covers, and who may reach what are all exactly as they were. Q1.648 is where the binding itself is argued. **Rejected: a `last_seen_at` column.** It needs a writer and both available writers are failures this package already carries intervals to avoid — per-request is the fsync-per-request that `user_sessions.last_seen_at` and `api_keys.last_used_at` both exist to bound, and write-at-registration makes "last seen" a date that never moves. The screen reads `MAX(user_sessions.last_seen_at)` over the device's own sessions instead: a value that already exists, already disciplined, and no new write on the authentication path. **Status.** Current. `.claude/rules/cp-devices.md` is the area. ### Q1.643 — Where the client keeps its device id, and why not the keyring **Question.** The native shell has an OS keyring (`credential.rs`) and an ordinary preferences file (`config.rs`). A device id has to survive a restart. Which one? **Decision. The configuration file**, as a map keyed on the server's origin beside `server`. `credential.rs` is untouched. [⚠ reversed by Q1.651 in its key: per account, `#`, because a device row belongs to one user and two accounts on one server took turns overwriting one entry. `credential.rs` gained the same scope and nothing else.] **Why, and it is a measurement rather than a preference.** `credential::probe` exists because a store that *accepts* a write and loses it is the failure that reads as working — a Linux box with no D-Bus session or no unlocked collection is the ordinary case, and the app already draws a sentence for it. Put the device id there and that same machine registers a **brand new device on every launch**, never reading one back, and walks straight into the account's device limit — with the only evidence being a list of identically-named rows. `config.rs`'s own header had already written the rule: *"Not a secret, and deliberately not in the keyring. A server address is a preference; the credential for it is the secret."* A device id is on the preference side of that line, and Q1.642 says why. **Secondary cost avoided:** `host_boot` would go from one keychain read to two on the first-paint path, which on an ad-hoc-signed development build is two prompts per `cargo build`. **A map rather than one current value**, which is the single place this file's shape departs from the credential's. `host_set_server` erases the previous origin's *credential* because a credential this app will not present is one it has no reason to hold. The same act on a device id would be destructive rather than tidy: the row on that server is not deleted by anything here, so forgetting the id leaves an installation the person can no longer recognise in their own list and spends a second slot the next time they point back. Retaining it leaks nothing. **⚠ This reverses Q7.136, and only its narrowest half.** That entry refused a device id on the grounds that *"a value generated at first run and persisted is device identity, arriving by accident, and it would be a new fact about a person that nothing in this fleet has agreed to record."* What changed is that it is no longer an accident and the fleet has agreed: there is a `devices` table, the id comes from it rather than from a local generator, and a person can see and retire the row. The three refusals that entry makes **at the interface** all stand — no `list()`, no private key through a `String`, no first-run generation — and the keyring seam stays reserved for the device *key*, which has none of these properties. **Measured, and it is the failure mode this decision is really about.** `Boot` in `commands.rs` carries no `#[serde(rename_all)]`; every camelCase field names itself. So `pub device_id` without an explicit `rename` serializes as `device_id`, `boot.deviceId` is `undefined` for ever, and `tsc`, `cargo`, `cargo test`, `webcheck` and the command census are **all green** while the app registers a new device on every launch. `nativecheck` gained a `Boot` ↔ `NativeBoot` key-set census for exactly that, which is the shape it already ran for `LocalAnnounce` ↔ `Stored` and whose docblock names the class: *"A field renamed on one side is not a compile error anywhere… Nobody would find it."* **Status.** Current. ### Q1.644 — Why the device check is a second statement rather than a join **Question.** `resolveSession` runs on every authenticated request, on the process that in embedded mode also carries every relay tunnel. Checking whether a session's device has been retired is one more fact about the same row. Why not join it? **Decision. A second cached statement, run only when the row carries a `device_id`.** The audited single-table query is left byte-identical. **Measured, against `node:sqlite`.** `devices` shares `id` and `revoked_at` with `user_sessions`, and `resolveSession` reads its row by **bare key**. Written the natural way — `SELECT s.*, d.revoked_at FROM …` — the row object comes back with `revoked_at: null` for a session whose own value is set, because the device's NULL overwrote it. So **session revocation stops working for everybody**: signing out, sign-out-everywhere, a password change and both admin sweeps keep answering 200 while the revoked token goes on authenticating. Written with bare column names instead it throws `ambiguous column name: id` at `prepare` — which is lazy, so the container starts green and then answers a plain-text 500 to every signed-in request, there being no `app.onError` on this service. **What the second statement costs**: nothing for a browser, an API key or any session that predates devices, because it is conditional on `device_id` being non-NULL; one primary-key lookup for a native one. Cheaper than the join it replaces, and it makes the aliasing trap structurally impossible rather than a thing somebody has to remember. `callerAuth`'s own joined statement already writes `k.id AS key_id` for this reason, and `listSessions` qualifies every column. **The assertion that holds it.** `relaycheck` drives session revocation **on a session that has a live device** — the one case a join would break while every other assertion in the file stayed green. **Status.** Current. ### Q1.645 — Why a retired device id is ignored rather than refused **Question.** A client offers a device id that has been retired. Refuse the sign-in, or register a fresh device? **Decision. Register a fresh one, on both doors** — `POST /v1/login` and `POST /v1/me/devices` — and never answer an error for it. **Why refusing is a trap.** A client keeps the id it was given. Answer an error and it signs in, is refused on its very next request, signs out, signs in again with the same stored id, and the loop closes with no exit but somebody deleting a keychain entry or a JSON file by hand. Every variant of "refuse it" is that same loop wearing a different status code. **What it gives up: nothing.** The retired row stays retired and its sessions stay ended. Revocation retires *that installation's access*, not the computer's right to ask again with a password — which is also the honest description of what retiring a device means to the person doing it. **The same rule closes a second hole**, which is why it is one rule rather than two: an id belonging to *another account* takes the same path. Without it, one account could bind its session to a stranger's device row — after which the victim's Revoke signs the attacker out (harmless) and the attacker's session inherits the victim's `revoked_at`, so the victim can be signed out at will by somebody they have never met. Every lookup carries `user_id`, on both statements of both routes, and `relaycheck` drives both halves. **Status.** Current. ### Q1.646 — Why the device cap refuses where the session cap evicts **Question.** `MAX_SESSIONS_PER_USER` is 10 and evicts the oldest rather than refusing the newest, on the stated argument that *"being unable to sign in on a new device because of an old one is the wrong failure."* Should `MAX_DEVICES_PER_USER` do the same? **Decision. No — it refuses, `409 device_limit`.** **The sessions argument does not transfer, and the reason is one word.** A session *is* the thing you are trying to get, so refusing one refuses the sign-in. A device is not: a sign-in succeeds perfectly well with no device bound, so refusing the *registration* costs a sentence saying "retire one" and nothing else. `POST /v1/login` swallows the cap for exactly that reason and hands back a session with `deviceId: null`. **And eviction here would be a weapon rather than a convenience.** Anybody holding one live session — which is precisely the case per-device revocation exists to contain — could call `POST /v1/me/devices` twenty times and evict, retire and sign out every real device the owner has, while their own newest session survived the sweep. The owner's remedy is the one thing they would no longer be signed in to reach. **The two numbers differ and the relationship is stated** rather than left for somebody to "correct": 20 devices against 10 sessions. At most ten devices hold a live session at once; the eleventh sign-in retires the oldest *session* and leaves its device registered, which is right — that installation asks for a password again and keeps its identity. `session_revoked` and `device_revoked` are separate codes so the client can tell those two apart. **Status.** Current. ### Q1.647 — What the Authority is, and what stops the line moving **Question.** The control plane holds identity and the daemons hold the work. That was true by accident: nothing stopped the next feature putting a session title or a transcript index here "just for the list". Should the division be named, and can it be enforced? **Decision. Named in `docs/AUTHORITY.md`, and enforced by two ratchets in `relaycheck`** — not by renaming the directory. **The rename is refused and it is worth saying why.** `packages/control-plane` is named in the Dockerfile and its `.dockerignore` twin, `RELAY_INPUTS`, `compose.yml`, every deploy script, a dozen `paths:` globs and every relative import in two packages. That is the blanket sweep `CLAUDE.md`'s `remoslop` warning is about, for no behaviour at all. What was missing was never a name. **The two ratchets.** *What it imports*: `packages/control-plane/src/**` reaches the repository root for exactly five files — `src/{token,auth,cors,http}.js` and `src/relay/protocol.js`, all wire vocabulary — which is the **same list** `deploy/docker/Dockerfile` COPYs, so the two cannot drift without an image that fails at runtime inside a container. *What it answers*: no route path names `sessions`, `prompts`, `agents`, `worktrees`, `files`, `diffs` or `events`. **The exception is named rather than pattern-matched around.** The four `/v1/me/sessions` routes are exempt by literal, because a sign-in is a credential with an expiry — precisely this service's business — and it collides with the daemon's *agent session* on one English word and nothing else. Listing them is what keeps `/v1/sessions` refused: the exemption is strings somebody has to add to, on a line that says what it is. **Neither is a security boundary**, and both say so — `plugins.md` makes the same disclaimer about `manifest.scopes`. What they buy is that the shape stays legible, which for a division of responsibility is the whole of what a check can buy. **Three lightweight facts the Authority does keep**, named so the next idea can be measured against them: tunnel presence, what a daemon announced about itself on the handshake, and mail waiting to go out. Each is about reachability or is this service's own. **Status.** Current. `.claude/rules/authority.md` is the rule. ### Q1.648 — What the device key binds, and what it deliberately does not **Question.** A capability is a signed bearer token: anybody holding the string can spend it until it expires. Phase 5 required that one stolen off the wire or out of a log be worth nothing. What should the binding be, and where should it be checked? **Decision. The capability names the device's X25519 public key in RFC 7800 `cnf.jkt`, and the daemon compares that to the key the `Noise_IK` handshake just authenticated.** Both sides of the comparison are local: the key is inside the signed capability, and the handshake proved possession on this connection. No lookup, no fetch, no revocation list. **Why that shape and not a lookup.** `auth-and-tokens.md` states that the daemon makes exactly one control-plane request, ever, and that *"what must never appear is code that reads something it needs from the control plane"*. A binding checked by asking the Authority which key a device holds is exactly that code. Putting the key **inside the capability** is what keeps the rule literally true and what keeps an Authority outage from stopping a session that is already running. **Authentication, not authorization, and that distinction is load-bearing.** A grant is still `(user_id, machine_id)` and still full access. `relay/authorize.ts` reads no device row. Four documents said so — `cp-devices.md`, `docs/AUTHORITY.md`, `SECURITY.md` and Q1.642 — and none of them had to change, because the key decides *whether this caller is who the capability says*, never *what they may reach*. Getting this backwards would have meant per-device grants, which is a feature nobody asked for and a migration for every existing row. **The anti-downgrade rule, which is the half that is easy to miss.** A channel that authenticated a key refuses a capability carrying **no** `cnf` at all (`unbound_capability`), rather than falling back to bearer semantics. Without it the binding is advisory: a caller simply asks for an unbound capability and the whole mechanism is optional. ⚠ And `parseClaims` returns `null` for a `cnf` that is *present but malformed*, rather than treating it as absent — the same rule one layer down. **Why the channel decides and not a configuration.** A verifier *option* for "require a device binding" was written and taken back out the same day — named here without a backtick because it never shipped and a citation to it would grep to nothing. It made the binding something an operator could be wrong about, and a daemon with it off would have accepted bearer capabilities while every document said otherwise. `daemoncheck` went red across every route, which was the design error surfacing rather than a fixture problem. The **channel** decides instead: a channel that authenticated a key insists the capability names it, and a request with no channel is loopback — which is the trade `relay.md` already states and bounds. **What it does not defend against.** The Authority mints capabilities and holds the signing key, so it can name any device key it likes. This closes theft of a serialized capability, not a hostile issuer. **Status.** Current. `src/auth.ts` holds the check; Q7.37 is the phase. ### Q1.649 — Deleting the browser branch of the app **Question.** Phase 5 made remote access depend on a device key in the operating system's keyring: a client with none cannot open an encrypted channel to a daemon, and there is no other way in. A browser has none. So what is left of the app's browser build — the Telegram mini app, the `REEMOAT_CP_WEB` bundle, the arms that exist only outside the native shell — and should it be kept? **Decision. Deleted, and the reason is not tidiness.** A browser loading this app can reach **no machine at all**. It draws a machine list, a session list from nothing, and opens none of them. Serving that is worse than serving nothing: "unreachable" is at least a sentence somebody can act on, while a product that loads and then fails on the first tap is a support conversation. **What went:** - **`telegram.ts` and the mini app.** Seven exports, the `[data-telegram]` CSS rules, the `` marker `main.tsx` wrote, setTelegramBack's effect in `App.tsx`, the rule file, and the Telegram section of the privacy policy — a policy that describes behaviour the product no longer has is a false statement about data, which is the one document where that costs something real. - **`REEMOAT_CP_WEB`**, and with it the control plane's static mount for the app, its SPA fallback, and the asset-shape refusal that lived in it. `main.ts` reads no environment value for a web root and `deploycheck` asserts the absence of the *read* rather than of the name — two docblocks still say it, because that is where the deletion is argued. **What stayed, and each was checked rather than assumed:** - **`cp.ts`'s browser storage arm and `LEGACY_STORAGE`.** The **gate** signs people in — `Gate.tsx` calls `store.adoptSession`, which calls `cp.setSession` — and the gate is a browser bundle. So the browser arm is live, and the two pre-rename keys it sweeps are still what is sitting in somebody's tab. `noUnusedLocals` would have made a dormant constant a typecheck failure, which is how this was settled rather than argued. - **`platform.ts`**, which is not a browser branch: it answers what kind of device this is, and the shell needs that too. - **`localRoute.ts`**, which has no `inNativeShell()` of its own — `localDaemon()` answers `null` outside the shell and always did, so there was no arm to remove. - **`upFrom`**, whose only *caller* was Telegram's back button and whose only remaining reader is `LegalScreen`. Its table of assertions was re-homed rather than deleted with the caller that used to justify it: a pure function with one live reader and no coverage is how the next edit to it goes unnoticed. **What is genuinely given up.** The control plane can no longer serve the app to a browser for a demo or a checkout, and `pnpm web` in dev is what replaces that. `pnpm web:build` still exists, because `pnpm native:build` compiles its output into the binary — the bundle is still built, it is simply never served over HTTP. ⚠ **The security property does not depend on this deletion**, which is why it is recorded as a separate decision rather than as part of Q7.37. `deviceStaticKey()` answers `null` outside the shell, so a browser was already refused and already said so. What this removes is dead weight and a false promise, not a hole. **Status.** Current. Q7.37 is the phase this follows from. ### Q1.650 — Deleting the machine offer **Question.** Q1.632 drew a "Rent a machine" link under the one-line installer, off unless `REEMOAT_CP_MACHINES_OFFER_URL` named a shop — and the dev stand's env file named one, so it sat on the home screen of every empty fleet there. Switch it off, or take it out? **Decision. Deleted — the owner's call of 2026-09-23.** Unsetting the variable is the state Q1.632 was built to fall back to, and it is not enough on its own: it is a line in the dev stand's env file, and possibly prod's, which this repository cannot see, and it leaves a renderer, a URL builder, a config field, a wire field and a tint in the tree — about thirty driver checks — for a feature nobody runs. **What went:** the `MachineOffer` renderer (`ui/MachineOffer.tsx`) and `machineOfferHref` (`offer.ts`), both files; its three call sites in `MachinesSection`, `SessionBrowser` and `NothingSelected`; `machineOffer` and `InstanceConfig`'s `offer` field; `machines.offer` on `GET /v1/instance` and the option that carried it into `createControlPlaneApp`; the variable's read in `main.ts` and its block in `.env.example`; and `--color-offer`. Its ink, which the `(stopped)` task chip had already borrowed (Q3.603), stays under a name of its own, `caution` — a palette naming a feature it no longer has is a docblock that has stopped being true. `webcheck` counts both old names as retired and bounds the new one to that one chip. **What stayed.** `installCommand` and `machineQuotaNotice`, untouched: the command is still the only way a machine is added from this app, and the door-or-the-sentence pair is still what an empty fleet draws. The landing page's pricing and the checkout it links to are another deploy. The Terms' *Paid plans* section is a contract rather than a screen and is not edited here. **Skew, in both directions.** An app built before this reads the missing field as an instance that offers nothing — Q1.632's own fail-closed arm — so redeploying the control plane takes the link off every installed build, the only lever that reaches them. An app built after it ignores a `machines.offer` a control plane not yet redeployed still sends; `webcheck` feeds it one. Neither side has to ship first. **The variable is warned about rather than swallowed.** `RETIRED_ENV` in `main.ts` says once at startup that it is set and no longer does anything — `scripts/daemon.ts`'s rule for its own retired settings (`deadContainerVars`), applied to the control plane for the first time. `REEMOAT_CP_WEB` went without one (Q1.649); this one is known to be set on the dev stand, and it shipped in 0.8.0 as a documented setting, so a deployment this repository cannot see may carry it on purpose. Its value is passed to nothing; `deploycheck` asserts the list names it and that nothing reads it by name. **The way back is Q1.632, kept whole.** Its placement argument — inside the `mayAddMachine` arm, below `installCommand` — and its `URLSearchParams` measurement do not depend on the feature existing, and this commit's parent holds every file. **Status.** Reversed an earlier decision — Q1.632. ### Q1.651 — What an account is to this app, and why the host, not the page, decides which one a call is about **Question.** Q1.640 keyed the native credential on the server's origin, and Q7.148 made several servers ordinary. The owner then asked for Telegram's arrangement — several accounts in one app, switched from the drawer — and two of them may be on the same server. On one origin they collide on everything keyed by it: the keyring entry, the device id and key, the machine claim and the daemon root. What is an account, what is keyed on it, and who decides which one a given call is about? **Decision. An account is a pair — the normalized origin and the control plane's user id — and its key is `#`.** That key is the keyring scope, `credential##` and `device_key##`, both through `credential::account_for`: the extension Q1.640 reserved, taken rather than migrated to — no new `pub const`, and still no `list()`. It keys `server.json`'s `devices` and `device_keys` maps and `machine.json`'s claims. The list of accounts lives in `server.json` (`accounts`, `current`, `roots`, `legacy_root_holder`), because the keyring cannot be listed and that refusal stands. `accounts::is_user_id` admits 1–64 bytes of `[A-Za-z0-9_-]` — every id the control plane mints, `u_` and sixteen hex digits — so a user id can never carry the `#` that separates a scope, the `@` that names a guest root, or anything that walks a path; and a normalized origin carries no `#`, so a pre-accounts entry under the bare origin can never equal an account's scope. `MAX_ACCOUNTS` is ten (Q5's table). **The host decides, from the webview that asked.** A seat-scoped command takes the calling `tauri::Webview` and the `tauri::ipc::Request` it arrived on, and `Host::seat` resolves the account from the webview's label and the generation its document presents (Q5.120). No credential, device, daemon or `host_cp` command takes an account, an origin or a scope. The one command that names an account is `host_account_switch`, choosing among keys `host_accounts` listed, and nothing that names another account returns a credential. `host_cp`'s base is the seat's own origin; a probe override refuses a request carrying `authorization`, and so does a pending seat, both read through `proxy::carries_credential` — the same case-folding `proxy::send` forwards by, so the refusal and the allowlist cannot disagree. **The host proves whose a token is, itself.** `host_credential_set` takes `{value}` and nothing else. The host sends `GET /v1/me` with that token to the seat's own origin (`accounts::me`, through `proxy::get_json`) and keys on the answer, so a page that is wrong — or hostile — cannot file one person's token under another's account. `accounts::decide` settles the outcome before anything is written: - **`bound`** — a new account, or the seat's own signed in again. The page adopts the token. - **`adopted`** — the account is already here and signed out. The token is written into it, its webview reloads with it, and the page switches there. - **`existing`** — already here and signed in. **The host revokes the new session** with the token it holds (`proxy::revoke`, `DELETE /v1/me/sessions/current`), and the page switches. - **`refused`** — a signed-out account's seat, signed in to as somebody else. The host revokes, and the page says so (`WrongAccount`): taking the token there would register the first person's device key for the second and start the first person's database under the second's sign-in. A failed `GET /v1/me` — unreachable, or anything but a 2xx carrying a usable id — is an `Err`, and `cp.login` adopts nothing: `setSession` runs only on `bound`. So no page path ever holds a bearer the host did not bind. **A sign-in offers no device any more.** `POST /v1/login` carried the stored device id and public key, one round trip rather than two. In the shell that id is an *account's*, and the account is exactly what the sign-in finds out — so the request would offer the last person's key to whoever signs in next, and the control plane's owner clause, which makes a foreign id harmless, copies the offered key onto the fresh row and links the two. So `login` sends `{name, password}`, and the bootstrap that follows in the same document registers through `POST /v1/me/devices` (`ensureDevice`, whenever the id is missing **or** `Boot.deviceBound` is false). `deviceBound` is false from the moment a credential is written until `host_device_set` stores the answer, which also makes a failed first registration retry at the next bootstrap. `registerDevice` is single-flight, because `ensureDevice` and a mint refused `device_key_required` can meet with a null id and would otherwise register two rows. **Nothing is inherited without proof.** A `server.json` from before accounts has no `accounts`; `config::read_accounts` derives a list for it in memory and writes nothing — the one keyring read it needs per origin is made by `lib.rs` once and passed in — and the first act that changes the list writes it (`config::materialize_accounts`). `server` becomes a legacy entry only on evidence of a sign-in: `devices` names it, `machine.json` claims a machine on it, or `credential#` exists. Otherwise it is `Roster.pending` — a pending seat that keeps its `‹ Server`, rather than an account somebody would have to remove. The other bare `devices` keys and claim keys become legacy entries too. A legacy seat's page calls `host_account_confirm` at bootstrap (`Boot.legacy`); the host reads the bare credential itself, asks `GET /v1/me`, and moves it — written, read back, then erased (`accounts::bind` with a move). What sat beside it follows **only by proof** (`accounts::gather`, with the credential being bound, to the seat's own origin): - the device id and key, only if `GET /v1/me/devices` lists that id — `device::copy_key` runs on a proven move and nowhere else; - the server's own daemon root and its bare machine claim, only if the root's claimed or announced machine id is among the user's **owned** machines in `GET /v1/machines` — a machine somebody shared is listed there too, and a grant is not ownership of a database. Without a proof the account gets a fresh key, a fresh device and a root of its own, and the bare items wait for whoever can prove them. A proof that could not be reached sets `pending_proof`, which keeps `Boot.legacy` true, so the next bootstrap asks again. The same proofs apply when a new account meets a server root that is not empty and has no recorded owner. **`owner := roots[origin] == user` is recomputed on every bind**, never carried, so somebody who removed their account and signs in again gets their root — and the machine and database in it — back. A legacy entry removed while unconfirmed writes its root as owned by nobody (`""`), so the next new account on that server cannot take a root that may hold somebody else's database. **Why the user id and not the name.** A login name can be changed and then taken by somebody else; an id is never reused. The name is cached for the drawer (`accounts::clamp_name`) and refreshed by a confirm whenever `Me.name` no longer matches it (`confirmDue` in `slot.ts`). **Why the page still holds one account.** Each webview is a single-account app, so `cp.ts`'s one credential, the 401-by-identity rule (Q1.412) and the store singleton stand exactly as they were — Q7.149 has the argument against several credentials in one page. **Rejected.** - **An account argument on the commands.** It makes the page the thing that names whose credential, device and daemon a call touches — exactly what a wrong page gets wrong. - **The page supplying the user id** — `host_credential_set(value, user, name)`, the first design. It let a page file one person's token under another person's id, and the duplicate-account routing would then switch the real owner into it. One request per sign-in is the price of the host asking itself. - **A locally minted account id.** That is Q7.136's device identity arriving by accident, and it names nothing the control plane can confirm. - **The login name as the key** (above). - **Adopting the token when the bind fails**, the old setter's degraded-mode posture. A keyring that keeps nothing is still `durable: false` and still binds; what a rejection means now is a session nobody attributed. - **Handing a legacy seat's bare items to whoever signs in on it.** Upgrading while signed out, or a second person on a shared computer, would have given one person's device key and root to another. **Status.** Reversed an earlier decision — Q1.643's device id per server, which is per account now; Q1.640's keyring key is extended rather than reversed. ### Q1.652 — How does one of somebody's machines get a capability for another, when a daemon may ask the control plane nothing? **Decision.** The owner's app asks for it, per source machine, with `POST /v1/machines/:id/links`: owner-only, it answers one `lk_` link per other machine the owner has that is enrolled, keyed, live, granted and within the limit, finding the row or writing one and minting a capability fresh on every call. It carries `aud` the target, `sub` the owner, `scp` exactly `LINK_SCOPE`, `cnf.jkt` the source machine's key from this service's own pin, and `lnk`, `src` and `srcl` naming the link, the source and the owner's label for it; it lives `LINK_TOKEN_TTL_SECONDS`, 90 days. The app hands the answer to the source's daemon unread (`PUT /peers/links`). Nothing removes one link by hand any more: a machine switched off or isolated has every link it is either end of revoked (Q1.654, Q3.676). **Why the long life is safe.** `cnf` binds the capability to a private key that never leaves the source machine, so a copy opens nothing anywhere else; and revocation does not wait for expiry, because the relay reads the row at every channel (Q5.121). A short life would need the owner's app awake to re-mint, and the links would die with the owner's phone. The row is the authority, which is why a repeat keeps the link id and why the relay can key a budget on it. The scope is stored nowhere, since a grant is full access, and a daemon older than it drops the scope and refuses every route — it fails closed. **Rejected.** - *The sender's Authority minting for the receiver* — two control planes would then have to trust each other; with the receiver's own Authority minting, they need no trust at all (Q7.150). - *The source key taken from the request* — `cnf` would bind to whatever the caller claimed. - *Revoking links in the three machine-revoke paths* — the relay already refuses a revoked end, and the listing hides it. - *404 on a second DELETE* — a DELETE is the write a client may replay (Q5.18). **Known limitation.** The source's `owner_disabled` and `machine_revoked` refusals are answered but unreachable through the routes: sign-in refuses a disabled caller first, and revoking a machine releases its ownership. **Status.** Current. ### Q1.653 — What does a relay tell a daemon that dialled the wrong one? **Decision.** `421 wrong_relay` carrying `RELAY_URL_HEADER`, from `relay_tunnels` and `REEMOAT_CP_RELAY_URLS`, which `relay/main.ts` now reads too — only after authorize, and only where the presence row names another slot the map names. Everything else stays `503 no_tunnel`. The daemon follows one, never two. **Why.** A browser is routed by the `relayUrl` a fresh token carries (Q7.92); a daemon holding a 90-day link cannot re-ask, and dialling the shared name lands on the right relay one time in N. A redirect costs one round trip and no trust between relays, where a forward would put one relay on another's data path (Q4.35). Answering after authorize keeps where a machine is from becoming an oracle, and a row naming this relay is a tunnel it has just lost, which bounds a bounce at one. **Rejected.** *Exiting on a malformed map, as the API does* — the relay holds every tunnel, so it warns and answers 503. **Status.** Current. ### Q1.654 — Where is the permission to message between agents kept, and how does a daemon learn it? **Decision.** On the Authority, as two tables where no row means on — `account_permissions` for the account's switch and `machine_permissions` for each machine's — written by `PUT /v1/me/permissions` and `PUT /v1/machines/:id/permissions`, both owner-only. It reaches a daemon **with its links**: the mint answer carries `messaging`, the value in force for its source, and `policyAt`, and the app forwards both in `PUT /peers/links` beside the links they govern (`policyFromBody`). - **Switching off, or isolating a machine, revokes the affected `machine_links` rows in the same transaction.** A link token lives ninety days, and `linkIsLive` reads the row at every channel, so the relay refuses the next message between two machines before either daemon has heard anything — including one that is off. - **A machine with no key still hears its switch.** Off is answered before the key is asked about, as `200 {links: [], messaging: false}`. On is refused `machine_key_missing` as before, and the refusal's `detail` carries `messaging` and `policyAt`: the app hands that to the daemon with an empty set, since a keyless machine holds no link. `no_signing_key` carries nothing, because a machine refused for that may still hold good links. - **`policyAt` only grows** (`nextPolicyAt`), and a daemon keeps the newer of what it holds and what it is handed. Two devices, or a sync already in flight when the switch moves, would otherwise put back what somebody just turned off. The links in a stale push are still taken, since the relay already refuses the revoked ones, and the app that sees an echo newer than its answer mints again, once. - **A switch that has not landed retries after 30 s, not the links' 15 minutes** (`POLICY_RETRY_AFTER_MS`). Until then that machine's own agents are still messaging. A person's press also passes the relay check for a machine this app reaches over loopback. - **A push without `messaging` leaves the stored value alone.** That is an app that predates this, and it must not switch a machine back on by saying nothing. - **Isolation is a column of `machine_permissions`**, added by `migrate()` for a database that already had the table. An isolated source is answered `{links: [], messaging: true, isolated: true}` and an isolated target is left out, so the relay refuses both directions before the daemon hears. - **The listing carries the value in force, and link sync reads only the listing.** Compared against a `me` refreshed only on promote, a stale account flag would read as a change on every wake and mint again each time — the trap Q3.673 already names for the target count. **Why the Authority.** It is the one place every device reads, and the one place reachable while a machine is not. "Which machines may open a channel to which" is already one of its facts (`authority.md`). The alternative was a fan-out to every daemon, and that is a snapshot: a machine offline at the time misses it, one enrolled later arrives on, and switching back on erases every machine somebody had turned off by hand. **What the daemon's copy is not.** A fence. A grantee holding `machine:admin` can push its own, and a grantee with `session:write` runs agents as the owner. The relay reading the revoked row is the one boundary, and it covers only traffic between machines. **Status.** Current. ## Session lifecycle, questions and attachments ### Q2.1 — What happens to a live session when the daemon restarts? **Decision.** A session reads as stopped only when somebody stopped it. Everything else the daemon ended it brings back by itself, on the same conversation, at the next boot, over ACP's `session/resume`. **Why.** A deploy becomes something an operator waits out rather than something that ends their work. The mechanism was already present and nothing called it: `ManagedSession.resume` had always done the whole job, and what was missing was a caller, a correct notion of which sessions qualify, and driver coverage — `resume` appeared **zero** times in either driver. **Measured.** `claude-agent-acp` 0.63.0 advertises `sessionCapabilities.resume` and maps it to `claude --resume=`, where the uuid *is* the ACP session id and the transcript is at `~/.claude/projects//.jsonl`. kimi 0.29.2 implements it and ignores the incoming `cwd` entirely. **Status.** Current ### Q2.2 — Which ended sessions qualify for an automatic resume? **Decision.** `autoResumable` is a `switch` over `ExitReason` with no `default` arm, so adding a reason is a compile error rather than a silent `false`: | reason | at boot | on a prompt | |---|---|---| | `daemon_shutdown`, `daemon_restarted` | yes | yes | | `agent_exited` | no | yes | | `stopped`, `start_failed`, `start_timeout`, `agent_kill_failed` | no | no | **Why.** `agent_exited` is the one judgement call, and it splits because the boot pass has no recency fence: an agent that crashed on Tuesday would otherwise be handed a fresh process by Friday's deploy, for a conversation whose owner watched it die. A prompt is somebody explicitly asking, and "it crashed, let me carry on" should work whenever they say it. **Status.** Current ### Q2.3 — How does the daemon tell its own exits apart? **Decision.** `status` derives through `endedWithDaemon`, so `interrupted` means exactly *"the daemon ended this and it is coming back"*, and `doStop` keeps the caller's reason while recording `agentConfirmedDead: false` separately. **Why.** Two defects had to be fixed before any of the resume rules were expressible, and both were the same thing — the daemon could not tell its own exits apart. *The ordinary deploy never produced `interrupted`:* a graceful stop writes `daemon_shutdown`, which derived `exited` — the same value as somebody pressing Stop — while `interrupted` was reachable only through the hard-kill path that writes `daemon_restarted` at the *next* boot. So the commonest way a session is interrupted rendered as "ended", the documentation said the opposite in two places, and the warn-toned `interrupted` dot in `bits.tsx` sat on a branch a deploy never took. *`doStop` discarded the caller's reason:* it read `reason: confirmedDead ? reason : "agent_kill_failed"`, so a SIGKILL that took longer than `KILL_CONFIRM_MS` collapsed `daemon_shutdown` and `stopped` into one indistinguishable value — erasing, on a loaded machine, precisely the distinction the whole feature turns on. `agentConfirmedDead: false` already carried that fact *beside* the reason rather than instead of it, and nothing ever read `agent_kill_failed`: one write, one comment, two union members. A client can now render the state without learning exit reasons. **Status.** Current ### Q2.4 — Where does the boot resume pass run, and in what order? **Decision.** `SessionRegistry.autoResume` is the boot pass and is deliberately not part of `restore()`. It is started with `void` after the listener is up and *outside* its callback. Most-recently-active first, two at a time, three attempts, full jitter 2s→60s. **Why.** `restore()` has to stay synchronous — the orphan reaping is fenced on running before anything serves — so this is the async half. It runs outside the listening callback because `wait_healthy` polls `/health` for 30s during a deploy, and a boot sitting behind even two ACP handshakes would report a healthy daemon as a failed update. Most-recently-active first, because the session somebody was mid-turn on is the one they will open. Two at a time, because each resume is a node subprocess with a `claude` grandchild under it. One wasted spawn per agent binary is unavoidable — `supportsSessionResume` can only be asked *after* an agent has started — so an agent that refuses once is not asked again in that pass. Full jitter rather than ±20%, because the attempts start together and a narrow band keeps them synchronised. **Status.** Current, with two amendments from Q4.114. The pass has a second caller — the completion of every agent update — so `autoResume` queues a pass behind one still in flight rather than running two over one session; and a harness with no CLI on the machine is `agent_missing`: no attempt spent, no verdict, the reason on the snapshot as `waiting`, and the daemon nudges the installer to run now and starts the next pass when it completes. ### Q2.5 — What happens when a session's workspace is missing or unresponsive? **Decision.** The workspace is checked before the spawn, with three answers from `probeExists`. `false` means the worktree is gone: settled, costs no attempt, never retried. `null` means a mount did not answer, which is treated as a different fact entirely. **Why.** On `false`, claude's adapter would reject the `cwd` anyway. `null` is emphatically not the same thing: spending the retry budget on it would abandon somebody's work over a sleeping NAS, and treating it as present would park a fresh agent inside an uninterruptible kernel wait. **Status.** Current ### Q2.6 — Does resume retry state survive a restart? **Decision.** Retry state is in memory and resets on every restart, with exactly one exception: `resourceNotFound` (-32002) on a resume, which `session.ts` types as `SessionForgottenError` and which persists in `sessions.resume_gave_up`. That verdict costs **no retry budget** and gates both automatic paths. **Why.** Retry state is a fact about *this process's* attempts, the same category as `agentConfigState` and `contextUsage`, which `registry.ts` argues at length must not be restored. A restart is *new information* — a new binary, a re-signed-in agent, a remounted disk — and refusing to try because a previous process failed would make the very deploy that fixes the bug fix nothing. `resourceNotFound` is different in kind: it is a fact about the *agent's* disk. The agent has said the conversation does not exist, and no restart on this side changes what it holds — the same treatment `workspace_missing` gets, for the same reason — so neither a boot nor a typed message spawns an agent to be told the same thing again. `SCHEMA_VERSION` deliberately does not move for the new column: a nullable column an older daemon never selects is invisible to it, so a rollback keeps working, and bumping the version would make `refuseNewerSchema` refuse one to buy nothing. **Measured.** 2026-08-04, which is why the persisted verdict exists: ten sessions were costing three agent spawns *each*, on every single restart, for ever. The correlation was total — every session that resumed had a transcript on disk, every one that failed did not. The cause attribution was wrong the first time and the correction matters: it read "transcripts that did not survive the move off containers", which is true of *six* of them — their `cwd` has no project directory under `~/.claude/projects` at all, because the transcripts were written inside a container. The other three had a project directory, holding nine transcripts, with theirs missing. Those were `/clear`, and they are a live recurring bug rather than a one-off (see Q2.7). **Status.** Known limitation — the caveat is the adapter's rather than this daemon's and is written at the constant: `claude-agent-acp` maps *two* SDK failures onto this code, and one of them ("Query closed before response received") is a transport hiccup rather than a settled fact. The two cannot be told apart, so a session that a retry would have recovered can be stranded. The way back is one manual `resume`; the alternative was an agent spawned per dead session, per boot, indefinitely. ### Q2.7 — What does `/clear` do to a session's ability to resume? **Decision.** Nothing repairs it. `/clear` breaks resume for that session and it is not fixed: on the next boot, `session/resume` reattaches to the *pre-clear* conversation. **Why.** claude forks *underneath* the protocol — the ACP session id does not change, so the stored id keeps naming the conversation the fork left behind, and the live one has an id nobody reports. The consequence is the whole reason this is recorded: a codeword somebody asked the agent to forget comes back word for word, silently, on a deploy they did not ask for. Auto-resume did not create the bug, but it did turn it from something a human triggered by hand into something that fires on every restart. **Measured.** 2026-08-05 against claude 0.63.0, in this order, because the first two measurements produced a wrong theory and a reverted commit. `/clear` works — the agent answers `NO MEMORY` immediately after — and the ACP session id does not change. Verified by content rather than by inference: after seeding a codeword and clearing, the project directory held two transcripts, and the one named by the stored id still contained the codeword while the new one did not. **Rejected.** *A first fix was built and reverted.* The theory was that the adapter starts addressing the client by the new id and the router discards that traffic — it does key notifications on `sessionId: message.session_id`, and the SDK does emit a `conversation_reset` the adapter drops. Plausible, wrong: the id never moves, so the rename-following code could not fire. Machinery justified by an event that does not occur was removed, drivers and all. **Status.** Known limitation — not fixed. What is not yet tried is `session/list`: claude advertises `sessionCapabilities.list` and its adapter implements `listSessions({dir: cwd})`, so the agent can be *asked* what conversations it holds for a directory, and `AcpClient` does not implement the call. Before building on it, measure whether the fork appears, whether the ordering identifies it, and what happens when one directory holds several — and if it cannot be identified, the honest answer is to stop auto-resuming a cleared session rather than resume the wrong conversation. ### Q2.8 — Why does kimi refuse to resume a session left in plan mode? **Decision.** `Session.resume` retries **once** without file IO, through `LaunchOptions.fileIo`, when a resume answers `-32603`. **Why.** Mode is one tap from the composer and is reached for several times an hour, so "ended the day in plan mode" is ordinary rather than a corner case — on the development machine it was the last session that would not come back. The `fileIo` seam already existed for exactly this kind of deliberate declining, so no new one was added. What is lost when the retry succeeds is the `source: "fs_write"` half of a duplicated `file_change`. The rule is narrow twice over: only `-32603`, the code that was measured, because every other way a resume fails is already typed and widening this to "anything we do not understand" would be guessing; and only on resume, because `session/new` has never failed this way. **Measured.** 2026-08-05 against kimi 0.29.2, deterministically: `session/resume` answers `-32603 Internal error` when `clientCapabilities.fs` is declared and the session was left in `plan`; it resumes perfectly without the capability, and leaving plan mode before the session ends also cures it. The cost of declining `fs` was measured long before this need arose — with it, kimi made five reverse-RPC calls and claude none; without it, neither made any, and both edit files perfectly well themselves. **Status.** Current ### Q2.9 — What happens to a cleared conversation the agent never wrote to disk? **Decision.** Such a conversation is **opened rather than resumed**, decided up front, gated on `conversationKnownEmpty`. That predicate has two arms: a turn counter still at zero, or the tail of the log holding a `context_cleared` with no `prompt` after it. **Why.** `clearContext` mints an empty conversation through `session/new`, and claude writes its transcript **lazily, with the first turn** — so a restart landing between the clear and the next message finds an id naming nothing on disk. Deciding up front means the doomed `session/resume` is not attempted at all, which is one agent spawn instead of two and nothing about it in the log. Opening another empty conversation is identical rather than approximate, because there was nothing in the old one and empty is what clearing asked for. Only the log arm has a window, and it fails safe outside it — a marker older than the window is not found, so nothing is recreated. The two arms do not subsume each other: `turnCounter` and `agentSessionId` are written by the same row write, so a restore can never see one without the other, but they do not describe the same conversation — `clearContext` moves the id and leaves the counter alone, so after a clear the counter is still counting the conversation the fork left behind. Reading "persisted beside `agentSessionId`" as "the two always describe the same life" is the mistake that would delete the log walk and reinstate the bug it was built for. Every other lost conversation stays stalled, and must: silently handing somebody a fresh agent while they expect their history restored is the same class of quiet lie as handing back what they cleared. **Measured.** The hard way in production, minutes after the clear interception was deployed: the session came back `resourceNotFound` and could not be resumed at all, which is a *worse* outcome than the bug the interception was built to fix. **Rejected.** The log arm took three attempts on a live session to get right, and each wrong version passed its own driver case. *Comparing the marker's `agentSessionId` to the current one* worked once and then never again: the recovery opens another empty conversation and deliberately appends no marker for it, so the next restart found no record naming the new id. *Dropping the id check but returning on the first `prompt` after the first marker* was wrong the other way: a session cleared twice has an older marker with a whole conversation after it, and stopping there answers about a conversation two generations dead. What is correct is the simplest form: walk the window and let whichever came **last** decide, a marker or a prompt — a prompt resets rather than concludes, so the `/clear` that precedes its own marker costs nothing, and which id is current is not the question. Separately, *"no `prompt` in the log"* was tried as the second arm and rejected in favour of `turnCounter === 0`: an empty log is evidence of an empty log rather than of an empty conversation, since events are pruned and a restored session may keep its history where this store does not — that version broke twenty-four driver cases at once. `turnCounter === 0` needs no window at all, because a field on the row cannot age out of one. **Status.** Current ### Q2.10 — May a `forgotten` verdict written by a previous daemon veto a recovery? **Decision.** No. `resumeSettled` does not let a persisted `forgotten` verdict filter a session out of the queue when this process knows a recovery the previous one did not. **Why.** The persisted verdict exists because a restart cannot change what is on the agent's disk, which is true and is exactly why it must not also mean "nothing can be done". The very first session the empty-conversation recovery was written for had already been marked on the boot before, so it was filtered out of the queue and the new code never ran. **Status.** Current ### Q2.11 — What happens when a message is sent to a session the boot pass did not resume? **Decision.** `POST /sessions/:id/prompt` resumes first — in the route, not in `ManagedSession.prompt`, which is synchronous by contract. `resume()` is memoised like `stopping`. `REEMOAT_AUTO_RESUME=0` turns off both paths. **Why.** This covers the session the boot pass missed, gave up on, or has not reached — and `agent_exited`, which the boot pass deliberately leaves alone. A failure there falls through to the existing `409 session_terminal` rather than inventing an error surface for a request that, from the caller's side, is the one they always sent. Memoising means two prompts arriving together join one launch instead of the second losing with `409 session_not_ready`. The environment switch covers both paths because an operator who stopped agents spawning at boot did not mean "spawn them on a keystroke instead". **Status.** Current ### Q2.12 — Is the interrupted turn re-run after a resume? **Decision.** No. Resume restores the conversation and says nothing; the agent comes back *idle*, holding everything that was said, not carrying on where it was cut off. **Why.** Re-sending the last prompt was considered and refused: the agent may have half-applied its edits and cannot tell how far it got, and synthesizing text into the model's context is the thing the attachment rules already forbid (see Q2.29). A pending approval is still gone as well — it holds a live `resolve` closure that cannot be serialized. **Status.** Known limitation ### Q2.13 — How does the client tell the restart states apart? **Decision.** Four pure predicates in `wire.ts`, asserted as a partition: `waitingForDaemon`, `resumeStalled` and `showsAsEnded` — exactly one holds for any terminal session, none for a live one — plus `countsAsLive`. Every one keys on `exit.reason` and never on `status` alone. **Why.** A `status === "interrupted"` test would have been right for a hard kill and wrong for every graceful restart there has ever been. `countsAsLive` is a separate question on purpose: a stalled row belongs in Active because a human must act, but must not inflate a count drawn beside a green dot. Three consequences follow in the UI. `Composer.tsx` early-returns on `showsAsEnded` rather than `isTerminal`, so **only a session the operator ended loses its composer** — and that one line revived the `disabled` on `AgentConfigBar` and the paperclip's own note about staging a file for a terminal session, both dead code until then. `StatusDot` takes the session and goes through `statusTone`, because a `Record` cannot express a distinction that lives on `exit`. And `POST /sessions/:id/prompt` joins `slowRoute` unconditionally, because `request` sees only a method and a path — a deadline that depended on session state would be state leaking into the transport, and a deadline that is too short here is a *transport* failure, which renders a healthy machine "unreachable" over the message somebody just typed. `scripts/client.ts` stays attached through a resume: `reportIfEnded` hand-rolled the terminal check as a three-way `!==` chain, which is exactly the copy that would have missed `daemon_shutdown`, and it reads the imported `endedWithDaemon` now. When the resume lands, the `status: "starting"` the daemon appends arrives on the next frame and the transcript carries on. **Status.** Current ### Q2.14 — Why could the agent not ask the operator a question? **Decision.** The daemon declares `clientCapabilities.elicitation.form` and registers one handler for it. No custom tool is written. **Why.** The tool was always there and this daemon was deleting it: claude's own ask-the-user tool was stripped from the model's toolset before the CLI started, on every session, and the model's only remaining move when it needed a decision was to guess. Declaring the capability also enables questions forwarded from MCP servers and the CLI's model-fallback consent prompt, both through the same route. **Measured.** Against `claude-agent-acp` 0.63.0: `disallowedTools = elicitationSupport.form ? [] : ["AskUserQuestion"]` (`acp-agent.js:4109`), where `elicitationSupport.form` is just `clientCapabilities.elicitation.form`. The daemon declared `fs`, `terminal: false` and `session.configOptions` and nothing else. **Rejected.** Building an MCP ask-the-user tool was the obvious wrong answer: the tool exists, the model is trained to reach for it, and a second one would mean two ways to ask on one agent. **Status.** Current ### Q2.15 — Can kimi ask the operator a question? **Decision.** Yes, through the permission channel rather than the elicitation one. kimi has its own `AskUserQuestion` and surfaces it as a `session/request_permission` titled `AskUserQuestion`, with each answer an `allow_once` option and a `reject_once` called `Skip`. **Why.** The identical question arrives down the permission path on one agent and the elicitation path on the other, so searching for one channel proved nothing about the other. **Measured.** kimi's bundle carries the ACP `elicitation_create` constant, the SDK's caller and the MCP elicitation schemas, with **zero call sites of its own** — the same "parses it, never emits it" shape as `usage_update`. That half is still true. Measured 2026-08-06 against a live session, the permission-path behaviour above. **Rejected.** The conclusion *"kimi cannot ask you anything"* was previously recorded on the strength of the elicitation search alone, and the correction matters more than the measurement did. **Status.** Reversed an earlier decision ### Q2.16 — Should a permission that is really a question be detected as one? **Decision.** No. Nothing detects it. What is unified is the *chrome*, by sharing a component rather than by resembling one: `ui/AskCard.tsx` is the frame and `PermissionCard`/`ElicitationCard` are two bodies inside it. **Why.** Recognising a permission as a question by its title, or by "every option is `allow_once` bar one", is the id-keyed guessing this codebase refuses everywhere else, and it would be wrong for the first agent that words it differently. For one release the two cards were separate components carrying the same class strings, which is "unified" exactly until somebody edits one of them. They look alike because they are the same card, not because one was recognised as the other. **Status.** Current ### Q2.17 — How large is a real `AskUserQuestion` form? **Decision.** The bounds are set against a measured form rather than a guessed one: 8 fields is the real ceiling. **Why.** The tool's own schema caps questions at 4, and each question brings its own free-text `_custom` box, so four questions is eight fields. **Measured.** 2026-08-06 against live claude, a two-question `AskUserQuestion` through `pnpm harness --agent claude --json`: 2 questions → **4 fields**; 4 options each; longest option value 19 characters; longest description 155; `message` is the adapter's own "Please answer the following questions."; nothing `required`; no `format`, no `default`, no `preview`; ~2.5 KiB in total. **Status.** Current ### Q2.18 — Can the elicitation capability be withdrawn? **Decision.** It is gated rather than merely undeclared. `LaunchOptions.elicitation` is required with no default, the handler is registered unconditionally and answers `methodNotFound` when declined, and `REEMOAT_ELICITATION=0` withdraws it. The daemon prints which way it is set. **Why.** Unlike the `fs` gate — whose sandbox does not exist — this one has a live consequence: it changes what the *model* does, not what a client renders. On a machine nobody is watching, a session that used to finish now parks on a human until the turn is swept. It is the mirror of `REEMOAT_AUTO_RESUME=0`. **Status.** Current ### Q2.19 — How is a capability declined on the wire? **Decision.** By omitting the key entirely. `ElicitationCapabilities.form` is a marker with **no `false`**. **Why.** This is a third capability shape read a third way, in one payload: `promptCapabilities.image` is a declared boolean, so `acceptsImages` compares `=== true`; `sessionCapabilities.resume` is an empty-object marker, so `supportsSessionResume` compares `!= null`; `form` has to be absent. Reaching for `{form: false}` is the obvious mistake and the compiler does catch it — `form` is a named property rather than something `_meta` absorbs, so it is `TS2559`. **Status.** Current ### Q2.20 — Which elicitation modes and scopes are refused? **Decision.** `url` mode is refused, and so is request scope (`requestId` with no `sessionId`). Both are `invalidParams` and never `methodNotFound`. **Why.** A URL is a URL on *this* host — most often an OAuth callback on loopback — and this daemon is driven from a phone somewhere else; opening one would mean launching a program named by an agent-chosen string, one door along from what the "login command is a table lookup, never a request field" rule forbids. It would also need `elicitation/complete`, a second settle path no human drives. Request scope exists for auth phases before any session, and this daemon has none — there is no session to block, no transcript to write it into, no row to find it on. `methodNotFound` would claim the whole capability is absent, a statement the next form request contradicts. **Status.** Current ### Q2.21 — What is answered when a request cannot be rendered? **Decision.** A JSON-RPC error, never a fabricated user action. **Why.** `{action: "decline"}` is the tempting answer and it is a lie: nobody declined. The error is also the kindest outcome for the model. **Measured.** `handleAskUserQuestion` turns the error into `{behavior: "deny", message: "Could not present the question to the user."}`, so the model is told why and carries on, where a decline tells it a person chose to skip. **Status.** Current ### Q2.22 — How is an elicitation property schema validated? **Decision.** The tag chooses the arm and each arm validates only what it reads — strict about what is used, lenient about what is ignored. **Why.** This is what `toCommands` does and what ACP's open unions are designed for. Validating the whole payload refuses a form for reasons unrelated to what the client can draw. **Measured.** A `type: "string"` carrying one numeric `const` among good ones took the unknown-type arm and refused everything. **Rejected.** The SDK's `ElicitationPropertySchema.is*` guards were used and taken back out, which is worth recording because reaching for them is the obvious move and they sit next to the types. They validate the whole payload rather than the tag, so *any* unexpected field — a `format: "hostname"`, valid JSON Schema and not one of ACP's four; one `oneOf` entry whose `const` is a number — makes a property match no variant and refuses the form. **Status.** Reversed an earlier decision ### Q2.23 — What bounds an elicitation form? **Decision.** Structure is refused and prose is truncated. Too many fields, too many options, an option value too long, an unknown property type, or a projected total over 32 KiB refuses the whole elicitation with the cap named in the message; a long `message`, `title` or `description` is clipped. `pattern` is dropped at ingest and never carried. ⚠ **The second half of that first sentence was taken back in Q2.214.** Prose is carried whole now — the three character caps are gone and the 32 KiB projected total is what bounds it. The half that stands is the first: structure is still refused, and for exactly the reason given below. **Why.** This is the `MAX_COMMAND_NAME_CHARS` asymmetry one level up. A form missing a question is not a smaller form, it is one whose answer *means something different*, and an option's value round-trips to the agent so a clipped one is a value it will not recognise. `clampBlob`'s `{truncated: true, bytes}` is a fine thing to show above an Approve button and useless above a form. An agent-chosen regular expression run in this process is a ReDoS on the event loop, and carrying it for a client to enforce only moves the hazard into a tab. **Status.** Reversed in part — see Q2.214 ### Q2.24 — Does an elicitation form ride the session snapshot? **Decision.** No. The snapshot carries only `message` and a field count, and `GET /sessions/:id/elicitations/:id` serves the fields when a card opens. It is not on the *event* either. **Why.** A pending permission earns its 8 KiB on `SessionSnapshot` because *a blocked session has to be answerable from the list*. A question is not answerable from a list — the form has to be read and filled in — so this is exactly where a command list lives, for exactly the same reason. An unanswered request needs only its prompt to draw, and a resolved one is self-describing. **Status.** Current ### Q2.25 — How does a transcript draw an answered question? **Decision.** `ElicitationResolvedEvent` carries the answer already rendered into `label`/`value` pairs — the field's title and the option's *title*, never its wire value — so a transcript draws it with no join back to the request. Each `value` is clipped for the log alone. **Why.** This is the `permissionDecisions` lesson pinned rather than repeated: `PermissionResolvedEvent` carries only an `optionId`, which is how a refused command came to be drawn with a check mark once the request row was merged away. What reaches the agent is verbatim, because a shortened answer is a wrong answer, and an over-long one is refused on the route rather than cut. **Status.** Current ### Q2.26 — Where do permission and elicitation ids come from? **Decision.** One counter mints both kinds. `perm-N-salt` and `elic-N-salt` come from the same `askSeq`/`askSalt`. The SQL columns are still `perm_seq`/`perm_salt`; in TypeScript the type is `AnswerResolvedBy` and the fields are `askSeq`/`askSalt`. **Why.** The question the salt answers — *is this id from this session's this life* — is identical for both, and the prefix already separates the spaces. A second pair would have cost a second persisted column, i.e. a `migrate()` ALTER and the `SCHEMA_VERSION` argument reopened, to buy gaps in each kind's numbering that nothing reads as a count. `looksLikeOurs` takes the prefix, so *"too old to report" must never come back as "never existed"* is one rule with two callers. The SQL columns keep their old names because SQLite cannot rename a column without rewriting a table that holds every transcript on disk, which is the trade `owner_subject` is already left dead for. The TypeScript names avoid saying `Permission*` on an elicitation, which is the `owned` → `sessionOf` failure. **Status.** Current ### Q2.27 — Are `decline` and `cancel` the same act? **Decision.** No, and the route offers both. The card offers Skip and Send; cancel is what stopping the session does, and what every sweep sends. **Why.** Collapsing them takes one of the two away from whoever is holding the phone. **Measured.** Declining runs the tool with empty answers and the turn *carries on* — the model is told the person skipped — while cancelling throws and the tool call dies. **Status.** Current ### Q2.28 — Does anything answer a question on the operator's behalf? **Decision.** Nothing, anywhere. A session with no resolver never declares the capability, so the agent is never handed the tool. **Why.** `Session.onPermission` falls back to allow-once when no resolver is attached, because that is a defensible default. A question has no defensible default answer, so it must not quietly decline instead. `scripts/client.ts`'s auto-answer loop deliberately does not learn about questions for the same reason, and `harness.ts` prints the form and declines — which is what made the form measurement in Q2.17 possible. **Status.** Current ### Q2.29 — May a message be files only, with no text? **Decision.** Yes. The prompt route validates `attachments` *before* `text`. Empty with nothing attached is still refused, and `session.ts` drops the text block entirely when there is no text rather than sending an empty one. **Why.** A message that is only a screenshot is an ordinary thing to send, and validating text first made it impossible. An empty message with no attachment is a mis-tap, and answering it starts a turn about nothing. The client is deliberately not allowed to paper over the gap by synthesizing "here is a file": that puts words in the operator's mouth inside the model's context, where the model reads them as instructions. Two things follow that are easy to get wrong. `deriveSessionTitle` still reads the text alone, so a files-only first prompt leaves the session **unnamed** and the client falls back to its path — which is the outcome that comment always argued for, though it used to argue for it from "a files-only prompt is refused anyway", which is no longer true. And `canSend` on the client has to agree with the route, or Send is enabled onto a `400`. **Status.** Current ### Q2.30 — When is an attached file uploaded? **Decision.** On select rather than on send. `POST /sessions/:id/uploads?name=` streams a raw body to `~/.reemoat/uploads///` and answers `{uploadId, …}`; `POST /sessions/:id/prompt` then takes `{text, attachments: [uploadId, …]}`. **Why.** The daemon has to mint the id before a prompt can name it, so uploading at send time would turn Send into a thirty-second operation with no way to tell the network from the agent, and a limit refusal should arrive while somebody is still at the picker. **Status.** Current ### Q2.31 — What content blocks does an attachment become? **Decision.** Every attachment becomes a `resource_link` block with a `file://` URI. An `image` block with base64 bytes is added *on top* when the agent advertised `promptCapabilities.image`. **Why.** ACP requires every agent to support `resource_link`, which is exactly why the paperclip needs no capability gate. The image block is added on top rather than instead, because the link is what lets the agent re-read the file with its own tools. `promptCapabilities` had never been read anywhere before this; `AcpClient.acceptsImages()` is the one accessor, and it compares `=== true` rather than `!= null` because `sessionCapabilities.resume` beside it is an empty-object *marker* while this is a declared boolean — two capability shapes in one payload, read two ways on purpose. **Status.** Current, amended by Q2.247: a file dropped from its session's budget can no longer be re-read. ### Q2.32 — Is a text fallback needed to make an agent read an attachment? **Decision.** No. `resource_link` alone is enough, and no text fallback is built. **Why.** The alternative — appending the absolute paths into the text block — would have put words in the operator's mouth inside the model's context, and it is not needed. **Measured.** 2026-08-04, against both live agents: a `secret.txt` was staged and named by a prompt asking for the codeword inside it; claude issued a `Read File` tool call and answered `TANGERINE-47`, kimi issued `Read` and answered the same. **Status.** Current ### Q2.33 — Why does claude ask permission to read an attachment? **Decision.** Because the upload root is outside the workspace. Nothing here suppresses the prompt. **Why.** It is the permission machinery doing its job on a path outside the session's tree — correct behaviour rather than a defect, though surprising the first time, and the price of not writing attachments into somebody's real checkout. A client that pre-approved its own uploads would be deciding on the agent's behalf about a path the agent chose to read. **Measured.** The same 2026-08-04 run: claude asks `Read /…/uploads///secret.txt`, with an `allow_always` option scoped to that upload's directory, while kimi reads it without asking. **Status.** Current ### Q2.34 — Does `promptCapabilities.image` ride the session snapshot? **Decision.** No. The honest home for the fact is `inlined` on the recorded attachment. **Why.** The capability only exists while an agent is running, so it would be `boolean | null` on a list that is mostly terminal and restored rows; nothing acts on it; and the snapshot's admission rule is tiny *and* helps answer *does anything anywhere need me*. `inlined` is a result rather than a prediction, and durable in the log. **Status.** Current ### Q2.35 — What can be read back out of a session? **Decision.** Any regular file under `workspace.root`, plus the session's own uploads: `GET /sessions/:id/files?path=` and `GET /sessions/:id/uploads/:uploadId`. **Why.** The first fills a gap that was narrow and real: `changes/diff` deliberately refuses binary, so a chart or a build artifact was visible in the transcript as a path and unreachable as bytes. It is **not** restricted to the git change set the way `changes/diff` is — somebody asking an agent for a file often means one that was already there — and it widens no real authority, because the agent can `cat` anything under that root already. The second route is not optional: uploads live *outside* the workspace on purpose, so without it a chip on a message somebody sent themselves would be the one thing in the transcript that cannot be opened. **Status.** Current ### Q2.36 — Where does the upload index live? **Decision.** In SQLite, as a new table. `SCHEMA_VERSION` stays 6. **Why.** The argument is accounting rather than describability. A `prompt` event carries name/mime/bytes, so a transcript describes an attachment from the log alone — an in-memory registry in `agentauth.ts`'s shape would pass that test. It fails the per-session byte budget: a restart resets every total to zero, and a restart is the ordinary outcome of `deploy.sh`. `agentauth.ts` gets away with memory because a pty dies with its parent; a file does not. The version stays at 6 because bumping it would make `refuseNewerSchema` refuse every rollback, to buy nothing. **Status.** Current ### Q2.37 — Why is an upload's name sanitized where a path would be refused? **Decision.** An upload's name is treated as a **label, not a location**: it is sanitized and capped at 200 bytes rather than refused, and the response echoes the original. **Why.** `safeRelPath` refuses because it locates a file in an existing tree, where a bad path has nothing salvageable in it. An upload's file is created inside a directory named by 64 fresh random bits, so containment comes from the path and never from the name. What is still refused is what would be *dangerous* rather than ugly — control characters above all, because that string is echoed into a `Content-Disposition` header where a CR is response splitting. The 200-byte cap is a third answer beside this codebase's existing two: not `clip`'s `…[truncated N bytes]` marker, which is right for prose and wrong for a name, and not the command-name *refusal* either, because a filename is neither invoked nor read as prose. **Status.** Current ### Q2.38 — How does the browser download a file from a session? **Decision.** `fetch` with the credential in a header, into a `Blob`. The filename comes from the requested path. **Why.** The download path was very nearly ``, and it lost on four counts of which the third decides it: a token lives 300s, so an href baked into old transcript DOM 401s; `download` is *ignored* cross-origin anyway; it would **widen the `?token=` exception**, which `machine.ts` justifies narrowly as "a browser cannot set headers on a WebSocket" and here it can — and which nothing *enforced* at the time this was written, so the third reason was an argument about discipline rather than about the code; `readCredential` enforces it now (Q1.45); and the URL lands in history and logs. Two consequences follow from `cors.ts` sending no `Access-Control-Expose-Headers`: `Content-Disposition` is **unreadable** cross-origin, so the filename comes from the requested path — which also means no RFC 5987 parser has to exist in a browser — and `Content-Length` **is** safelisted, so an oversized file is refused before it is resident. **Rejected.** The `` form, for the four reasons above. **Status.** Current ### Q2.39 — What else may talk to the agent while a `/clear` is in flight? **Rule.** Nothing. `ManagedSession.clearing` is set before `await session.clearContext()` and released in a `finally`, and **all four** methods that reach the agent test it: `prompt` and `clearContext` answer `busy` → `409 turn_in_flight`, and so do `setConfigOption` and `setMode`. **Why.** A clear is a `session/new` followed by a `session/close` — measured at ~600ms to 15s — and for that whole window the session holds **no turn**, so every guard written as `this.turn !== null` waved everything through. Three concrete outcomes, none of them visible: a prompt was issued against the ACP session id about to be unregistered and closed, so its updates hit `router.sessions.get()` and were dropped and the turn died with the close — a message sitting in the transcript that reached no model; two concurrent clears both captured the same `previous` and left the intermediate conversation live inside the agent with nothing left to close it; and a mode chosen inside the window raced `restoreConfig`, which re-applies a `wanted` snapshot captured *before* the change, so the selection was silently reverted to the pre-clear one — which is the exact failure `clearContext`'s own docblock says the restore exists to prevent. **Why all four and not the two that start a turn.** The first version of this guarded `prompt` and `clearContext` and its docblock said "nothing else may talk to the agent while it runs". That is the `sessionOf` shape: a property asserted in prose that the code does not enforce, and `AgentConfigBar` sits beside the composer, so `/clear` and then a mode change is one tap apart. Making the sentence true cost a `busy` arm on `AgentConfigResult`, which is the honest price of it. **The marker is deliberately not a turn**, so `status` still reads `idle` beside a `409 turn_in_flight`. It looks like a bug and is not; `daemoncheck` pins the pair for that reason. **Consequence.** A clear that hangs on a slow agent refuses prompts for up to `NEW_SESSION_TIMEOUT_MS` rather than accepting them into the void. **Measured.** `daemoncheck` holds `session/new` for 200ms against a registry, an app and a second agent rig: `prompt` is `busy`, a second `clearContext` is `busy`, the route answers `409 turn_in_flight` with `status: "idle"`, and then the clear still lands, closes the old conversation, the session takes messages again — the `finally` control — and exactly two conversations were ever opened. Pre-fix the prompt is accepted, the second clear opens a third, and the route answers 202. **Status.** Current ### Q2.40 — Can a launch that came back late be adopted as the live session? **Rule.** No. `launch()` passes its own promise to `onStarted(starting, session)` and `onStartFailed(starting, error)`; both return early when `this.startPromise !== launch`, and `onStarted` **disposes** the session it declines before `this.session` is assigned. **Why.** `startAbandoned` was the only guard, and `armForStart()` clears it on the very next resume — so a launch that timed out at `START_TIMEOUT_MS` (45s) and resolved at 48s was adopted as the live session and then overwritten by the retry's agent. Nothing bounds `session/new` or `session/resume` end to end, so that is ordinary rather than exotic. The displaced agent is spawned `detached`, referenced by nothing, survives the daemon's exit, and is invisible to the next boot's reaper because no row names its pid: it holds the worktree for the machine's uptime. The mirror case closes with it — a stale *rejection* used to write an `exitRecord` onto the freshly re-armed life, marking a starting session terminal for ever. **Why identity rather than another flag.** This is the `this.session !== session` rule every other late-notification path in the registry already applies, extended to the one path that had only a mutable boolean. `startPromise` is written in exactly two places (`launch`, `armForStart`), which is what makes the promise the identity. **Consequence.** Any future code that reassigns `startPromise` without meaning to supersede the launch would have its agent disposed rather than adopted. **Measured.** `daemoncheck` counts disposals through `endStdin`: a `stallMs: 150` agent against a 20ms budget times out and restores `daemon_restarted`, the retry re-arms the session (clearing `startAbandoned`, the window the old guard could not see) and brings it back idle, two agents are really launched and both really resumed, and `disposed() === 1` **before** anything is stopped. Adopt it instead and that reads 0 with everything else green. **Status.** Current ### Q2.41 — What does a worktree removal do when it could not tell? **Rule.** It refuses, with a code that says so. `removeWorkspace` reads `unpushed`/`commitsAhead`/`dirty` three-valued: `null` raises a `counts_unknown` refusal carrying `about: "dirty" | "commits"`, and only a real number is compared with `> 0`. git declining the removal in its own words raises `remove_refused` carrying git's stderr. `--force` overrides both. **Why.** `count()` and `countStatus` collapse a 5s/15s timeout, a 128 off a stale gitfile, oversized output and a parse failure into one `null`, and `?? 0` turned every one of those into "there is nothing to lose". So a non-forced `DELETE …/workspace?deleteBranch=1` reached `git branch -D` over commits that existed in no other ref and on no remote, and a failed `git status` skipped the dirty refusal in front of the one `rmSync` in this codebase. The rule "`null` means could not tell and must never read as zero" was already written down; nothing enforced it. **Why `remove_refused` matches git's words rather than "the call failed".** git declining because the tree holds work was pushed onto `warnings` and execution carried on into `rmSync(root, {recursive: true, force: true})`, deleting exactly what git had refused to delete and answering `200 {removed: true}`. Gating the guarded `rm` on git having *succeeded* would disable its actual job — a stale gitfile, an unregistered directory, half-written admin metadata — so the match is on `contains modified or untracked files|use --force`, the technique `classifyAddFailure` already uses. The prune is skipped on that one path, which is safe: a worktree git just refused to remove is still registered and still present. **And the sentence at the boundary had to be derived, or the fix was undone.** The route answered every refusal `409 workspace_dirty` / "this worktree still holds work", which is the opposite of what `counts_unknown` means — and `scripts/client.ts` prints `error.message`, walks nothing else, and then suggests `--force`. So an operator was told a stale-gitfile worktree *definitely* holds work and invited to force-delete it on that evidence: the `?? 0` defect restored at the only place anybody reads it. `removalRefusalAnswer` keeps the 409 and keeps `--force` as the remedy for everything, and splits only the code and the sentence — `workspace_dirty` while any definite refusal is present (the string `scripts/client.ts` keys its hint on), `workspace_uncertain` when only uncertain ones are. **Consequence.** Removals that used to succeed on repos where git is slow or the worktree metadata is broken now refuse — deliberately, since those are exactly the cases where the count was unknowable. **Measured.** `daemoncheck` drives a scripted `GitExec` against real checkouts, because what has to be driven is git *failing to answer*: an unknown commit count refuses, leaves the file on disk and issues neither `worktree remove` nor `branch -D`; an unknown dirty count refuses and leaves the checkout alone; git's own "use --force" wording refuses, carries the stderr, leaves the work on disk and skips the prune; a clean removal still removes, deletes the branch and prunes — the control, without which every refusal above passes for a function that refuses everything; and `--force` still overrides both and passes git the flag. Reverting `?? 0` deletes the file and the branch in the first case. **Status.** Current ### Q2.42 — Can you stop the agent without ending the session? **Rule.** Yes, and it is a different verb from `stop`. `POST /sessions/:id/cancel` → `ManagedSession.cancelTurn` sends ACP's `session/cancel` notification, sweeps anything parked on a human, and changes nothing else: the agent process stays up, the conversation stays loaded, no `exitRecord` is written and the next message is an ordinary prompt rather than a resume. `DELETE /sessions/:id` remains what kills the agent. **Why.** There was no way to stop an agent mid-turn short of killing the session, which costs the loaded conversation and turns the next message into a resume. The one signal a person has when an agent starts down the wrong path is the one this daemon did not carry — and `session/cancel` was already implemented, sent from exactly one place (`doDispose`) as part of teardown, so the protocol work was already done and only the verb was missing. **Why "cancel" and not "interrupt".** `interrupted` is already a session *status* in this codebase, meaning "the daemon ended this and it is coming back", derived through `endedWithDaemon` and asserted as a partition in `webcheck`. A feature called interrupt would have made four passages of `CLAUDE.md` ambiguous about which of two states they described. **Why the order is send → sweep → watch.** ACP requires a client that has cancelled to answer any pending `session/request_permission` with `cancelled`, and until it does, an agent parked on one is not executing anything that could notice the notification: it sits in the pipe behind a reverse-RPC the agent is still waiting on. Sweeping *first* would tell the agent its tool call was abandoned while leaving it free to choose another. Waiting *before* sweeping would spend the whole budget and report `settled: false` on every blocked session — which is the session most worth cancelling. Both wrong orders are green against an agent that answers immediately, which is why the driver's stub does not. **Why it asks rather than forces.** Cancellation is a notification: no response, no acknowledgement, nothing that obliges an agent to act. `Session.cancelTurn` returns `void` to say so, and whether the turn ended is a separate observation (`awaitTurnEnd`, 1500ms) reported as `settled`. `false` means "had not finished when we stopped watching", never "refused" — the turn ends into the transcript whenever the agent gets there. The escalation already exists and is `stop`. **Why no new event type.** The turn's own `turn_end{stopReason: "cancelled"}` is already drawn by the transcript as a turn that did not finish, and a `permission_resolved` records anything that was swept. A dedicated event would put a second row on screen for one act. What the log genuinely cannot say — that an agent has been asked and has not yet answered — rides the snapshot as `cancelRequestedAt`, in memory rather than in SQLite because after a restart there is no turn to have cancelled. **Why `no_turn` is a 200.** Nothing was stopped and nothing is wrong: the caller asked for an agent that is not working and it is not working. It is also what an ordinary lost race looks like, since the tap and the turn's own end are ordered by nothing, and a red error there makes the control look broken at the moment it did its job. `terminal` and `not_ready` stay 409s, because those report something the caller does not know. **Consequence.** `sweepPending` has a second caller, so `CLAUDE.md`'s sentence attributing it to `doStop` alone is no longer true; `AnswerResolvedBy` gains `turn_cancelled`, distinct from `session_stopped` (which claims the session is over) and from `turn_ended` (which the pump writes afterwards, and which may never come); and `clearing` now guards five methods rather than four, with `cancelTurn` testing the marker *before* `turn` — the other order answers `no_turn` about a session mid-`/clear`. **Measured.** `daemoncheck` drives three agents through the route. One that answers `cancelled` when asked: 200, `settled: true`, the session idle with no exit, `turn_end` recorded as cancelled, and the notification really sent — checked explicitly, because `session/cancel` carries no `id` and a stub without an arm for it discards it in silence, which would make every other case pass against a daemon that sends nothing. One that answers only once the client settles its permission: `settled: true`, the agent handed `{outcome: "cancelled"}`, and the resolution logged `by: "turn_cancelled"` — this is the case that fails under either other ordering. And one that never answers at all: 200, `cancelled: true`, `settled: false`, the session still `running`, and `cancelRequestedAt` still a number. Cancelling an idle session sends nothing; cancelling twice sends twice; a `/clear` in flight answers `409 session_busy`; an ended session answers `409 session_terminal`. `webcheck` pins `canCancelTurn` being wider than `showsWorking` by exactly the blocked row, `cancelInFlight` reading `undefined` as no cancel, and the route being absent from `slowRoute` deliberately. **Three defects an adversarial review of this change found, each now a rule.** *The ordering was stated and not driven.* Every assertion in the parked block was satisfied by a sweep that ran first — `outcome: "cancelled"` is this daemon's own constant and says what we sent, not what the agent heard — so one more line asserts the *stop reason*, which is the agent's word and reads `cancelled` only when the notification arrived before the answer that freed its turn. Measured by putting one yield between the two statements: the wire order becomes answer → `turn_end{end_turn}` → cancel, and that line is the only one in the file that goes red. *`canCancelTurn` read `isTerminal` alone*, and `stopRequested` reaches the wire as `status: "stopping"`, which is not terminal — and `turn` is not cleared until `pump`'s `finally`, which waits on `dispose()`'s 5s cancel grace, so the composer drew an armed Stop for seconds onto a guaranteed 409. *`stopping` had the late-write gate and not the reset.* Shared React state on a component the two-pane layout never remounts, cleared only under `onScreen()`: a cancel outliving a session switch left the send slot a spinner for every session afterwards, with no path back — the Stop button is not drawn while it is set and `cancelTurn` early-returns on it. `webcheck` now asserts the gate/reset *pair* for every shared flag on that effect's own region. **Status.** Current ### Q2.43 — claude has a setting ACP cannot carry. How is it offered? **Decision.** As a seventh row on the agent's own effort control, added by the daemon and unknown to the browser. `ultracode` is claude's own session setting — *"xhigh effort plus standing dynamic-workflow orchestration"* — and the SDK says it is provided by `--settings` or an `apply_flag_settings` control request, both of which are outside ACP. `sessionMetaFor` puts it on `_meta.claudeCode.options` of `session/new`, `session/resume` and the `session/new` inside `clearContext`; `withUltracode` adds the choice to the snapshot; `setConfigOption` intercepts the value above the ACP call. `REEMOAT_CLAUDE_ULTRACODE=1` is the machine's default for a session nobody has decided about. **Measured.** `@anthropic-ai/claude-agent-sdk` 0.3.220 `sdk.d.ts:6319` for the setting and its own note about how it is normally supplied; `claude-agent-acp` 0.63.0 `acp-agent.js:4091` — `userProvidedOptions = sessionMeta?.claudeCode?.options`, spread into the SDK options beside `settings` at `:4144`. The effort control itself is built at `:4838` from the *current model's* `supportedEffortLevels`, which is why `xhigh` among the published choices is the capability test rather than a model name. **Why the row rather than a switch of our own.** It is where claude's own interface has it, and the strip beside the composer is where somebody reaches for it. The cost is that this is the single place in the product where a control on screen is not something an agent published — accepted, and confined to the daemon so that the rule `packages/web` lives by ("the values are never hardcoded") stays true there. **Why the overlay is snapshot-only.** `setConfigOption` validates against the live `agentConfigState`, which never grows this choice, so the value cannot reach the agent as an ordinary selection even if the interception were removed. `daemoncheck` asserts the input config is not mutated, because that is the one mistake that would open the path. **Why the *selection* is ours too, and the prediction that was wrong.** The first version of this entry expected the chip to flip to `Xhigh` by itself once the flag landed, and said so as the way to verify it. Measured 2026-08-11 on a live daemon: it does not. The effort option's `currentValue` is built from `Settings.effortLevel` (`acp-agent.js:4405`), a *different* key from `ultracode`, so a session running with the flag on still publishes `effort=default`. Hence `withUltracode` reports the selection itself — otherwise the row somebody just chose would be permanently unticked, i.e. a control that says "nothing happened" every time it works. The consequence worth stating plainly: **nothing over ACP confirms the flag took effect.** The evidence is behavioural. What is assertable is what the daemon *sent*, so `daemoncheck` stands in as the agent and reads the `_meta` off the `session/new` it received — a request parameter no listener can otherwise see. **Why choosing it restarts the agent.** The setting is read when a conversation is opened. Turning it *off* therefore means opening one without it, and there is no live channel for either direction — `apply_flag_settings` is an SDK control request the adapter does not expose. `applyUltracode` writes the choice, then `stop("config_changed")` + `resume()` on the same `agentSessionId`, which is the machinery a deploy already uses and keeps the conversation. A turn in flight refuses with `409 turn_in_flight` — the one refusal on `/config` that really is about a turn, since every other option is applied live. **Rejected — writing a settings file.** `settings` can also come from `~/.claude/settings.json` or a `--settings` path, and the daemon controls neither the CLI's argv (the adapter builds it) nor anybody's home directory. Writing there would be a machine-wide change made on a per-session tap, and would collide with whatever the operator has already put in that file. **Rejected — typing the keyword for them.** The `ultracode` keyword works in a prompt today: the adapter stamps `origin: {kind: "human"}` on every ACP prompt specifically so the CLI's `isHuman()` gate accepts it. A client that appended the word to each message would be putting words in the operator's mouth inside the model's context, which is the same refusal the composer makes about synthesizing "here is a file". **Why the stored choice is three-valued.** `sessions.ultracode` is nullable on `owner_subject`'s grounds rather than `pinned`'s: "nobody chose" follows the environment at every launch and "chose off" outranks it for ever. A `0` meaning both would leave every session that predates the column — and every session created before somebody flipped the switch — permanently disagreeing with the machine's own setting. **Status.** Current ### Q2.44 — The conversation goes quiet, then a message produces five minutes of unrelated dialog. Why? **Symptom.** A session waiting on background work read as **finished** — no spinner, no working line, an offered Send. Sending anything made a wall of dialog appear at once, none of it about the message just sent, so the operator was interrupting a live turn without knowing. **Measurement.** Live log, session `s_019d3a30`, agent claude. `turn_end` at seq 835 (13:37:23.445), then **294 907 ms** of silence, then a `prompt` at seq 836 (13:42:18.352) followed by **57 events stamped inside a 2 ms span** — content: *"Reviewers still running"*, *"Waiting on the reviewers"*, *"Two of five reported. Waiting."* A second burst repeated it: 394 694 ms, then 64 events in 4 ms. `store/sqlite.ts` stamps at append, so those are the times the daemon first learned, not the times it wrote. Neither backlog contained a `turn_end`, which is what rules out "hold the turn open": the turn genuinely ended. **Cause.** `EventQueue` is single-consumer and its only consumer was the generator returned by `Session.prompt`, which returns on `turn_end`. Everything the agent emitted afterwards was buffered with nobody reading. The queue's own comment stated the premise — *"Nothing drains this between turns"* — written about idle stderr and equally true of `session/update`. **Ruled out first**, each against the code: `Session.toolDraft` (flushed before `turn_end`, holds at most one event for one call); a dead WebSocket (the events would carry their real timestamps and real seqs); a client render or memo bug (the events were not in the store at all during the silent window); the daemon's own `4401` expiry close. **Decision.** Drain the queue between turns, in `ManagedSession` rather than in `Session` so a bare `Session` — `harness`, the Session-level drivers — is untouched. Ownership of the queue becomes a checked monotonic claim: `next()` had parked its resolver unconditionally, so a second consumer silently overwrote the first and the displaced one never settled, which is a **hang** rather than a mismatch. **What was deliberately not done.** `status` is untouched — a clock in it would break *"Status is derived, never stored"*, and a new `SessionStatus` member falls silently through `statusTone`'s `default` with no mirror assertion. The turn is not held open, which would make `canCancelTurn` true for a turn that ended and answer `409 busy` for ever. `showsWorking` is not widened, because it is what refuses Send: widening it removes the control in the one state whose only exit is using it. Send stays enabled — the complaint was not knowing, not being unable to type. **The second half of the bug, which nobody reported.** Past `MAX_BUFFERED_EVENTS` the backlog was not delayed but **evicted** — the head shifted out and replaced by a placeholder. The driver reproduces it: with the drain disabled, a 2500-event burst comes back as `burst 1998`. **Cost, stated.** `agent_log` and `other` are dropped out of turn rather than recorded. That is exactly what the queue already evicted first — a count over five of this fleet's database snapshots, 95 618 events, holds **zero** `agent_log` rows — so it preserves today's behaviour rather than losing anything new. Recording them would put an unbounded stderr stream into a per-session log that is deliberately `Infinity`/`Infinity`, make `REEMOAT_LOG_EVENTS` harmful (prefix eviction takes the first prompt), spend the tab's 16 MiB ceiling, and bury a reattaching phone behind `ATTACH_REPLAY_MAX`, which is a seq window. **The indicator, and what it cannot see.** `outstandingTasks` draws `waiting for N tasks` at the foot of the transcript, from the tail rather than the snapshot. ⚠ **It would have read 0 for the whole of the outage above** — at seq 836 the number of open calls of any kind was zero, because the work being waited on was behind a call that had already reported `completed`, and no ACP message describes it. It is the only honest client-side signal available; the drain is the fix. **Status.** Current Amended by Q2.233: `showsWorking` now also reads work nobody prompted, which no longer refuses Send. ### Q2.45 — Choosing `ultracode` moved the mode to Manual. Why, and what fixes it? **Symptom.** Two independent controls, and one moved the other: turning on `ultracode` — a row on the *effort* chip — put the **mode** back to `Manual`. **Cause.** `Manual` is claude's own name for mode id `default`, so the report means the fresh conversation's default came back. `applyUltracode` is `stop("config_changed")` then `resume()`; `doStop` clears `agentConfigState` and `onStarted` reassigns it from whatever the new process published, with nothing in between. `/clear` has had `Session.restoreConfig` for exactly this since it was written — `clearContext` captures `this.config` before overwriting it — and this path did the structurally identical thing with the capture missing. **Decision.** Capture `agentConfigState` before the stop and replay it through `restoreConfig` after the resume. Awaited inside `applyUltracode`, so the restored mode is already in the snapshot `setConfigOption` returns and the screen never flashes `Manual`. ⚠ **`snapshot().agentConfig` is the wrong capture** and was rejected: it is composed through `withUltracode`, which rewrites the effort value to the invented `ULTRACODE_CHOICE` that this daemon guarantees never reaches an agent, and through `dedupeAliasChoices`, which rewrites the model value off its `default` placeholder onto a concrete model — silently pinning the model on a session whose operator chose to let the agent decide. **Two latent bugs fixed on the way**, both in `restoreConfig`'s withdrawal guards. The option guard read `option.choices` — the list off the *same object the value came from* — so the predicate was true by construction and the rule its own docblock describes had never fired. The mode branch had no guard at all, which is the sharper omission, since that docblock's own cited example (`bypassPermissions` under root) is a *mode*. Benign while this only ran after a `/clear`, where the two conversations are one agent moments apart; not benign on a restart path, where the agent may be a new binary. **Scope, deliberately narrow.** Three other paths reach a fresh agent and lose the mode the same way — the boot/auto-resume pass, `POST /sessions/:id/resume`, and a prompt that resumes first — because `agentConfigState` is in memory. They are unchanged rather than regressed. Closing them needs a schema migration, and replaying a mode onto a new agent binary after a deploy is a policy decision nobody asked for. **Status.** Current ### Q2.46 — Should a folded run of tool calls open itself? **Decision.** No. `GroupRow`'s disclosure is `override ?? false` — nothing but a tap opens it. **Two facts have been tried in that slot and both were reported as bugs**, which is why the assertion now pins the constant rather than whichever fact is currently allowed. `failed > 0` came first: `override` is component state and dies on reload while a failure is permanent, so a group somebody deliberately collapsed came back open on every refresh for ever. `node.live` replaced it and failed the other way — the newest run drew expanded until the agent stopped calling tools, so machinery a reader had folded away unfolded itself on every turn, and the row whose height nobody chose was the one at the foot of the page. **What `node.live` is spent on instead.** The hollow pulse on the collapsed row. Without a consumer it would be a field three `webcheck` assertions describe and nothing renders — the `sessionOf` failure, in the direction this repository names it for. A run of one is never wrapped, so a lone call keeps its own spinning `Loader`; only a run of two or more was relying on being open to say so. **Status.** Current ### Q2.200 — Which environment variable does a pasted codex key go in? **Behaviour.** `CODEX_API_KEY`, and **not** `OPENAI_API_KEY`. A key pasted into it reaches the model's API calls and does **not** by itself start a session. **Why.** The obvious variable name is not always the right one, so every entry in `AGENT_LOGIN[agent].envNames` is measured rather than assumed. The incomplete paste path is survivable only because the login probe runs *with* the pasted credential in its environment: `codex login status` still saying "Not logged in" is a clean `false`, and a clean `false` beats a token we know we handed over. Whether `codex login --with-api-key` closes the gap is Q7.65. **Measured.** The two names are told apart by *which* rejection comes back: with `CODEX_API_KEY` set the API answers `invalid_api_key` — it saw a key and refused it — while with `OPENAI_API_KEY` it answers "Missing bearer", i.e. nothing was sent. The same run measured the limit of the paste path there: with a key in the environment and no `auth.json`, `codex-acp` still refuses `session/new` with -32000. **Status.** Current ### Q2.201 — Does kimi read `KIMI_API_KEY` out of the environment? **Behaviour.** Both, about different code paths. Its raw model client reads the variable; the provider manager does not, so which one applies is a fact about that installation's config file rather than about kimi. **Why.** `resolveAgent`'s `authHint` said kimi does not read `KIMI_API_KEY` from the environment while `AGENT_LOGIN.kimi.envNames` said it does, and the contradiction stood because neither side could have won by reading harder — they were describing different paths through the same binary. **Measured.** Against kimi 0.29.2: the raw model client reads `options.apiKey ?? process.env["KIMI_API_KEY"]`, so the slot is real; the **provider manager** — the path a `managed:kimi-code` provider takes — resolves the key from `provider.env`, a TOML table in `~/.kimi-code/config.toml`, and never looks at the process environment. **Status.** Current. The sign-in card no longer says so (Q3.686); the measurement stands. ### Q2.202 — Is "can this agent be logged in" a question about the host? **Rule.** About the agent as well, and it is answered before the button is drawn. `loginSupport(agent)` folds three facts into one answer: `script` is on PATH, that agent's CLI resolves (a different binary from the adapter), and whether its flow reads stdin at all. **Why.** The daemon-wide `SessionRuntime.loginSupported` reported only the first, so an agent whose CLI was missing got an enabled button and a `503 login_unsupported` after the tap, with the reason only in the daemon's own log. `loginSupported` is still sent, for an older client. **Status.** Current ### Q2.203 — What does the agent screen draw for an agent that is already signed in? **Decision.** The word **Signed in** and exactly one control, Sign out. Switching accounts is sign out then sign in, and pasting a token sits behind "Paste a token instead". **Why.** Every control on that screen has to be true in the state it is drawn in. An accent "Sign in to claude" over a signed-in agent proposed what had already happened, and a demoted "Sign in again" beside the sign-out was the same mistake smaller: two buttons for one state. Two equal-weight unfamiliar options is what made the card read as a developer tool in the first place. The subtler failure is that `agentEnv` merges pasted secrets **last**, so a card reading "Signed in" may be describing a credential the agent is not using. **Measured.** A sweep for controls that were not true in the state they were drawn in found five. Three were on a permissions panel that has since been removed; two stand. "Paste a token **instead**" was rendered over a signed-in agent with no sign-in on offer — instead of what — and hid the fact that a token was stored at all. The section heading said "Sign in" over a Sign out button. Neither was a crash; both were the screen stating something it could not know. **Status.** Current ### Q2.204 — Why does the codex wizard pass `--device-auth`? **Decision.** Because codex says so, and `daemoncheck` pins the flag. **Why.** A bare `codex login` binds a local server on port 1455 and waits for a browser; the CLI itself prints *"On a remote or headless machine? Use `codex login --device-auth` instead."* — and this daemon is exactly that machine. The device form prints a URL and a code that expires in 15 minutes, which is the shape kimi's flow already has and the one the wizard's poll-and-display loop was written for. Dropping the flag leaves a login that still *starts* and then times out, indistinguishable from a network fault. **Status.** Current ### Q2.205 — How is the binary a login drives resolved? **Rule.** `resolveLoginBinary` reads the agent's `executableEnv` first, from the `AGENT_LOGIN` table rather than from a name written out at the call site, and falls back to `findOnPath` on the same table's `command`. It used to fall back to the vendored resolver — a `switch` with no `default` arm — and that went with the vendored copies (Q4.114). It answers only whether a binary exists at all; which copy a login, a logout, the probe or a session actually runs is `agentCli`'s answer (Q6.106, Q4.114). `daemoncheck` pins the pair by name. **Why.** The lookup used to be an `agent === "claude"` test, and **two** of the four agents have such a variable: `CLAUDE_CODE_EXECUTABLE` and codex's `CODEX_PATH`, both read by the adapter's own `startAcpServer()` and therefore deciding which binary *sessions* run. Under that test codex's override picked the session's binary while the login and the probe went on resolving the vendored copy — a login that appears to work and changes nothing, arriving through the very door the vendored preference existed to close — and that one resolver, `agentCli`, still closes. What made it survive review is that the thing which looked right was the count, which is why the driver asserts the pair by name. The wider rule underneath is that `available` is about the adapter while `loggedIn` and `login` are about the CLI under it; conflating the two made a documented remedy unrunnable. **Status.** Current ### Q2.206 — Which stream does each CLI answer its login status on? **Behaviour.** `claude auth status` prints JSON on **stdout**; `codex login status` prints a sentence on **stderr** and writes nothing to stdout. The stream is a field in the table rather than an assumption in the reader. **Why.** Reading the wrong one costs no error anywhere: the probe sees an empty string, answers "cannot tell", and a signed-in codex reports `status unknown` for ever. Neither answer is read from an exit code, for the reason claude's entry has always given — a non-zero exit cannot be told from a crash or a missing binary. `readLoginAnswer` is pure and owns both formats, so `daemoncheck` asserts branches no CI machine could otherwise reach, including that "Logged in" is a substring of "Not logged in" and the negative pattern must therefore be tested first. **Measured.** By getting it wrong first: codex was read from stdout and reported `status unknown` on a session that was signed in. **Status.** Current ### Q2.207 — Why is the login transcript's 64 KiB cap re-applied after the carry flush? **Rule.** The bound runs after **every** mutation, the carry flush included, and the carry goes through `scrub()` like everything else — the replacement chain lifted out of `sanitize`, which now calls it. The flush is appended **after** the body text, so a transcript reads in stream order. **Why.** `LoginRun.append` used to return early on `text === ""` — which is precisely the shape a chunk that is entirely an unterminated `\x1b]` produces, since the whole write lands in the carry — so those bytes were appended and then returned past the only statement that bounds the buffer: raw, with ESC and the C0 range going into a string the client renders in a `
`. A CLI emitting one per
write grew the transcript without a ceiling for the run's 10-minute TTL.

**Status.** Current

### Q2.208 — Why did the login wizard not run on macOS at all?

**Rule.** BSD `script` reads its *own* stdin's termios to copy onto the new pty,
so a pipe there kills the run before the agent is reached. `loginStdio` spawns a
non-interactive flow with `/dev/null` on stdin on BSD; **Linux keeps its pipe
unconditionally**, because util-linux `script` works with one today, an immediate
stdin EOF is a plausible way for it to end the session, and Linux is where this
deploys.

**Why.** `LocalRuntime.login` spawned `script` with `stdio: ["pipe", "pipe",
"pipe"]`, so it exited 1 with `script: tcgetattr/ioctl: Operation not supported
on socket` — identical on claude, so the defect was pre-existing and not codex's,
and invisible because Linux is where this deploys and util-linux's `-qec` form
does not ask. `/dev/null` fixes kimi and codex; claude's flow needs the pipe that
is the problem, so what changed for claude is that the failure is a sentence —
`ui/login.ts` recognises that exact string and says "paste a token instead" —
rather than a `tcgetattr` line in a `
`.

**Measured.** Found 2026-08-07 while verifying codex's login and reproduced
outside the daemon. Confirmed on macOS 2026-08-08, both halves in one run:
`POST /agent-auth/kimi/login` exited 0 with `Logged in to managed:kimi-code.`,
and `POST /agent-auth/claude/login` exited 1 with the `tcgetattr` line, from the
same daemon. Still unverified is whether BSD `script` survives a full 15-minute
device flow with `/dev/null` on stdin — the run that proved the spawn works was
against an already-signed-in kimi, so it finished immediately. Q7.63.

**Rejected.** Taking the pipe away unconditionally, which would fix two agents by
breaking the third.

**Status.** Known limitation

### Q2.209 — Does `available: true` mean an agent can start a session?

**Rule.** No. `available` only ever meant "on PATH", and the login probe parses
`claude auth status`'s JSON rather than its exit code.

**Why.** A logged-out agent reported `available: true` and the person found out
at `502 agent_auth_required`, after a worktree had already been made. The exit
code does track the answer — `claude auth status` exits 1 when logged out — but
exit 1 is then indistinguishable from a crash or a missing binary, so it cannot
be the thing that is read.

**Status.** Current

### Q2.210 — What happens if the wrong `script` form is used?

**Behaviour.** Nothing loud. util-linux takes a shell string after `-qec`; BSD
takes argv after the typescript file; the BSD form on Linux writes a file called
`claude` and records nothing.

**Measured.** `claude auth login` prints the URL wrapped in an OSC 8 hyperlink
and waits on a paste prompt, so it needs the input box and **no inbound port**;
`kimi login` and `codex login --device-auth` are device-code flows and their
input box is never used. A lone `\r` becomes a newline — it means "redraw this
line", and dropping it concatenates every spinner frame into one.

**Status.** Current

### Q2.211 — What answers input posted to a login run that has no stdin?

**Rule.** `POST /agent-auth/login/:id/input` answers `400
login_not_interactive`.

**Why.** It was a silent no-op, which on the one screen where a device code is
typed once reads as a client that sent the code successfully. Whether a run has a
stdin pipe at all is decided by `AGENT_LOGIN[agent].interactiveStdin`, the same
field that decides whether the client draws the box — which is why it is in the
table rather than in either reader.

**Status.** Current

### Q2.100 — How many sessions may be running at once?

**Rule.** Two bounds, kept apart: **64 live**, and **16 creations then one per two
minutes**. Both answer `429`, and the refusal is made *before* the cwd is resolved,
so it costs no filesystem probe. **A resume is never refused by either** — putting
an agent back in front of an existing conversation is not manufacturing a session,
and telling somebody who has just typed that their own conversation is unavailable
is worse than being briefly over a soft ceiling. They are in memory;
`REEMOAT_MAX_LIVE_SESSIONS` and its siblings move them.

⚠ **What the live ceiling *means* changed with parking (Q2.224), and the number did
not.** It counts sessions holding an agent, which used only to be bounded from one
end — `create()` refused past it and nothing ever released anything — so it read as
"how many conversations you may have open" and was never a memory bound at all: at
the ~397 MB a live session was measured to cost, 64 of them is ~25 GB. Since the
daemon lets an idle agent go, the population it counts is bounded from both ends,
and what it caps is **agents resident at once** rather than conversations somebody
may have. The enforcement moved with the meaning: crossing the
ceiling — by waking a conversation or by creating one — releases the least recently
used *idle* session first, so the bound is kept by eviction rather than by refusal.
The two ends differ only in the last resort: a wake proceeds anyway, a `create`
answers `429`, and it does so only when every live session is mid-turn or waiting
on a person.

**Why.** `create()` had no bound of any kind, and the only thing counting sessions
anywhere was the startup prune — which counts in order to **delete**, keeping the
newest 200 by creation and taking every other transcript with it at the next boot
(only inactive rows now, and never under fifty — Q2.222 — but still a deletion). So the sole
consequence of creating sessions without limit was that somebody else's
conversations disappeared. `sqlite.ts`'s own comment beside that cap had already
written the precondition down — *"with one person there is nobody to take it
from"* — and a grant is exactly what makes that false. Both bounds are needed
rather than one: the ceiling bounds what is *running*, while the burst bounds
create-and-stop, which never touches the ceiling and still writes every row the
prune later deletes.

**Status.** Current

### Q2.101 — What bounds an agent's stderr before there is an event to bound?

**Rule.** 64 KiB per line without a newline, flushed as its own line past that.

**Why.** The accumulator in `pumpStderr` had no ceiling, and every bound downstream
of it is on the *event* — which does not exist until a line does. An agent writing
without a newline therefore grew a string that nothing in the system measured, past
the per-event cap, past the per-session byte budget and past the queue's own byte
ceiling, none of which had anything to charge.

**Status.** Current

### Q2.102 — What happens when two consumers want the same event queue?

**Rule.** Ownership of `EventQueue` is a checked monotonic claim rather than an
assumption. Taking a claim wakes the previous holder with `null`, and `next()`
answers `null` for a claim that is no longer current, so a reader already resumed
cannot take one more event on its way out. A turn outranks a drain — `claimForIdle`
refuses while a turn holds it — and **nothing displaces a turn**: `claimForTurn`
refuses rather than displacing. `release` is identity-checked.

**Why.** `next()` used to park its resolver in `waiting` unconditionally, so a
second consumer silently overwrote the first and the displaced one never settled —
a **hang** rather than a mismatch, and a hang inside `harness` or the Session-level
drivers. The asymmetry is not tidiness: a `claimForTurn` that displaced instead of
refusing can strand the hold with no owner able to clear it, which silently returns
the session to buffering — the very state the idle drain exists to end. And a stale
`release` under a live turn would route that turn's events to the drain, park its
generator and pin `ManagedSession.turn`, i.e. `409 turn_in_flight` for the rest of
the session's life.

**Status.** Current

### Q2.103 — Can a cancel arrive before the prompt it is meant to stop?

**Rule.** Yes, and it is honoured rather than overtaken. `pump` tests the cancel
marker *after* reading a prompt's attachments and writes its own
`turn_end{cancelled}`.

**Why.** `prompt()` sets `turn` synchronously, but `Session.turnActive` is not set
until `pump` first pulls the generator — and reading an inlined image's bytes is a
real `readFile` between the two. A cancel landing in that window reaches an agent
holding no prompt, which every adapter discards, and the loop would then send the
message anyway: the turn somebody stopped runs to completion. The daemon writes the
`turn_end` itself because the agent never gets to send one, and a prompt with no
turn end at all is the shape this codebase calls a message that reached no model.

**Status.** Current

### Q2.104 — What does `Session.EventQueue`'s 2000-event bound still protect?

**Decision.** 2000 events, evicting only `agent_log` and `other`, and never
drop-oldest — dropping `text` or `file_change` yields a contiguous log that is
missing content. What it *bounds* is now narrow: the gap between `adopt` and
`onStarted`, plus any bare `Session` — `harness`, the Session-level drivers — where
nothing drains between turns at all.

**Why.** With `ManagedSession.startIdleDrain` attaching a reader between turns, the
thing this cap used to bound is no longer reachable through a managed session: the
agent talking after its turn ended is drained rather than buffered. The number is
kept rather than lowered because the two remaining windows are real, and kept rather
than raised because the eviction policy — not the size — is what makes an overflow
survivable.

**Measured.** Before the drain: 57 events held for 295 s on one session, with
content past 2000 silently evicted through the head placeholder. Q2.44.

**Status.** Current

### Q2.105 — Why does the registry still append permission events now that the queue drains between turns?

**Decision.** The rule is unchanged — the registry appends permission events, not
`session.ts` — and its reason is not.

**Why.** The reason recorded at Q5.57 was that `Session`'s `EventQueue` drains only
while a prompt generator is being consumed, so an event pushed there outside a turn
is stranded. A `ManagedSession` no longer permits that, so the old argument has
lapsed. What replaces it is stronger: `settle()` appends **synchronously**, in the
statement after the agent's own promise is resolved, so routing a
`permission_request` through the queue would put a microtask between the two — and a
client answering inside that microtask could beat its own request into the log.

**Status.** Current — supersedes the reasoning given at Q5.57, which the drain made
obsolete.

### Q2.106 — Does codex resume a conversation end to end, or only advertise that it can?

**Behaviour.** End to end. `codex-acp` 1.1.9 advertises `sessionCapabilities.resume`
— which `AcpClient.supportsSessionResume` reads — and it was measured under the
daemon rather than only off the handshake: a session restarted by the daemon
answered a question about its own earlier turn.

**Why it is recorded.** The claim "all three agents implement `session/resume`" is
what `autoResumable` and the whole boot pass rest on, and Q2.1's measurement covers
claude and kimi only. Advertising a capability and honouring it are different facts
here — the same distinction kimi's plan-mode `-32603` makes (Q2.8) — so the third
agent's is written down separately.

**Status.** Current

### Q2.107 — Code arrives as an archive. Where does the containment argument come from?

**Question.** `POST /fs/import` takes a `.zip` or `.tar.gz` and unpacks it into a
directory somebody picked. Every other path in this daemon deals in paths a person
chose out of a listing of what already exists. An archive member is a string a
remote party wrote, used to create a file. What authorises the write?

**Decision.** Nothing does, and that is the point — the argument is **refusal
first, and it is purely syntactic**.

`paths.ts` already says this in as many words: `atOrUnder` "is correct only where
the path is ours and merely not created yet, and nothing may use it to authorise
an action on a path somebody else chose." `atOrUnderReal` — the primitive that
would have been reached for here — was deleted with its last caller rather than
kept, precisely so that a future caller would have to make this argument instead of
inheriting one.

So `safeMemberPath` is pure, touches no filesystem, and runs before anything is
created. It refuses NUL and control characters, a backslash (a member path is POSIX
or it is refused, never translated), a leading `/` or a drive prefix, any `..`
segment, `.git`, and anything past a depth or length bound. **Both readers go
through it**, which is what stops zip and tar drifting into disagreeing about what
is safe — the failure that would otherwise be found one format at a time.

**`..` is refused rather than normalised**, and that is the whole of the zip-slip
answer. A member reading `a/../../x` is not asking for `x`, it is probing for
whether this code normalises; every surviving zip-slip works by the check running
on the pretty form and the write running on the raw one. There is no pretty form.

**The filesystem half adds exactly one thing: `O_EXCL`.** Every member is written
`"wx"`, the flag `Uploads.receive` already uses, which never follows a link and
never truncates. A link planted between the check and the write is an `EEXIST`.

**Measured.** `daemoncheck` builds both formats itself — shelling out would not
have worked, since GNU tar refuses to *write* a `../` member at all, which is
exactly the member worth being sure about. Every refusal is asserted in a pair: the
refusal, and that nothing at all was created, staging directory included.

**Status.** Current

### Q2.108 — Why is a `.git` inside an archive refused, when cloning a hostile repository is accepted?

**Question.** `CLAUDE.md` is explicit that git hooks run as you and that this is
the intent: "Cloning a hostile repository is exactly as dangerous here as in your
own terminal — and no more." An imported `.git` is the same bytes. Why treat it
differently?

**Decision.** Refused, because the sentence above has a clause that does not
survive the move: *in your own terminal*. The danger there is one you initiated and
are watching.

The daemon runs `git worktree add` on the directory somebody picks, and `git.ts`
deletes `GIT_NO_EXEC_CONFIG` on purpose so that a repository's own `post-checkout`
and its LFS smudge filters run. For a repository you cloned, that is correct and
was measured — neutralising it checked out LFS pointer files instead of content.
For an imported one it makes "upload a file" mean "the daemon executes what was in
that file", with no agent in between and nobody on screen. Every other route to
executing a hostile repository in this system goes through the agent, which is the
thing a person is watching.

**Nothing legitimate is lost**, which is what makes this cheap rather than a trade:
the export skill excludes `.git` anyway, and somebody who wants history asks the
agent to clone — the existing path, on screen while it happens.

**Rejected.** Allowing it and stripping `hooks/`. That is a second, weaker rule
about the same directory, and `.git/config` still carries `credential.helper` and
`core.sshCommand`.

**Reversible, and deliberately so.** It is one clause in `safeMemberPath`, and
`webcheck` pins the export skill against it so the two cannot come to disagree —
a skill that packed a `.git` would produce an archive refused *whole*, and the only
symptom would be a 400 at the end of the slowest step somebody takes.

**Status.** Current

### Q2.109 — An import writes into a directory the daemon does not own. What may it not do to it?

**Question.** This is the only route that creates files inside a folder somebody
else made. What happens when it fails halfway?

**Decision.** Nothing in the target is touched until one `rename`. Extraction goes
to `/.reemoat-import-/tree/`, so a failure at any point leaves the
folder exactly as it was.

**Staging sits inside the target rather than beside the worktree and upload roots**,
for two reasons that are both requirements rather than preferences. The `rename` is
then within one filesystem by construction, where a root under `~/.reemoat` would
be an `EXDEV` copy for anybody whose projects live on another volume. And a third
remover tree would owe `scripts/daemon.ts` a proof that it nests with neither of
the other two (Q5.74) — a startup refusal that is currently a two-way check.

It also buys the thing that makes the rest of the file ordinary: **because the
daemon created the staging directory, every path below it is one it made**, so the
rule about bounding filesystem calls through `stall.ts` — which is about paths
somebody else named — does not apply below that line. `resolveCwd` on the target is
the bounded half.

**The destination is `lstat`ed before the rename, and anything there at all is
refused.** Measured on macOS: `rename(2)` answers `ENOTDIR` for a destination that
is a file or a symlink, `ENOTEMPTY` for a non-empty directory, and **succeeds for
an empty one** — implicitly `rmdir`ing it. That loses no data and is still a
removal of a directory this daemon did not create, which is exactly what it may not
decide to do. The errno mapping below the check is the backstop for the race, not
the rule.

**Known limitation.** A daemon restart mid-import leaves a `.reemoat-import-*`
directory behind. It is dot-prefixed so the picker hides it and named
unmistakably; sweeping directories the daemon does not own would be worse than the
defect.

**Status.** Current

### Q2.110 — Two archive formats. What did each actually cost?

**Question.** Zip is what Finder's Compress and Windows' "Send to" produce; tar.gz
is what the export skill writes and is the better archive. Accepting both means two
readers. What was not obvious?

**Decision.** Both, chosen by **magic bytes rather than by filename** — a filename
is the least reliable thing in the request. Five things cost a measurement:

**A zip states every member twice and the copies may disagree.** Only the central
directory is read. Every extractor ever walked past a check was walked past it by
validating one copy and reading the other; the local header is consulted for
exactly one thing, its own name and extra lengths, being the only way to know where
the data starts.

**The zip64 extra field is positional.** Its members are present only when the
corresponding 32-bit field is saturated, always in the same order. Reading it at
fixed offsets yields a garbage offset out of a valid archive — the first draft did
this and it was caught by reading it back rather than by a test.

**A zip name without general-purpose bit 11 is CP437, and is refused.** Decoding it
as UTF-8 anyway turns undecodable bytes into U+FFFD and collapses distinct names
onto one — a check passing on one string while a file is created at another. Pure
ASCII is identical in both, so only a genuinely ambiguous name is refused.

**pax headers are read, not refused.** macOS ships bsdtar, which writes typeflag
`x` ahead of members whose metadata does not fit the 1979 layout, routinely rather
than rarely. Refusing them refuses most archives made on a Mac.

**`__MACOSX/` is skipped.** Finder writes a parallel resource-fork tree beside the
folder it compressed, so *every* zip made that way has two top-level directories —
which would fail the single-root rule and refuse the most likely archive there is.

**And one that is about Node rather than about archives.**
`handle.createReadStream` per member registers a `close` listener on the shared
`FileHandle` and releases none until the import ends, so a zip of twenty thousand
members registers twenty thousand. Node warns at eleven, which is how it was found
— from `daemoncheck`'s output, on an archive of eleven files. `readRange` reads
positionally and has no such bookkeeping.

**Status.** Current



### Q2.212 — A plugin child is not detached and nothing reaps it. Why is that not the gap it looks like?

**Decision.** A plugin's child process is spawned **without** `detached`, no pid
is recorded, and there is no reaper. `runner.ts` exits when its IPC channel
closes, and that is the whole of plugin cleanup.

**Why it is the opposite of an agent's.** An agent is spawned `detached` because
`claude-agent-acp` runs the CLI as its own child and cleans up only on
`process.on("exit")`, which does not run under SIGKILL — so the process *group* is
the only thing that can be killed reliably. That is why `sessions` has a pid
column, why the reaper exists and why it is fenced by `os.uptime()`: a pid
recorded before a reboot names somebody else's process afterwards.

A plugin has none of those problems. It spawns nothing, holds no conversation, and
its channel dies with the daemon — gracefully or not. A daemon that crashes leaves
children whose `disconnect` fires, and they go. The next daemon start spawns fresh
ones from the rows.

**What this means for a change.** If a plugin ever needs to outlive the daemon —
it should not — the pid column, the fence and the reaper all come back with it,
and this entry is where the argument for the current shape is.

**Status.** Current

### Q2.213 — What may a failed install destroy, and when?

**Question.** `PluginHost.install` publishes the new tree, writes the row, starts
the child, and then removes what it replaced. Its catch is supposed to put
everything back. Which of the two orders is load-bearing — the order things are
committed in, or the order they are destroyed in?

**Rule.** The way back is destroyed **last**: after every statement that can
throw, and after the plugin being replaced is certainly stopped. The commit point
stays where it is.

**Why the reinstall path is the only one that matters here.** The target path
carries the version, so on a version-changing update nothing is moved aside at
all — the new tree goes to a directory that does not exist yet. A plugin is moved
aside on exactly one path: reinstalling the version already installed, which is
the documented way somebody iterates on a plugin they are writing. So this is not
a rare window in a rare path; it is the only path on which the machinery runs.

**What went wrong.** The old tree was discarded immediately after the row was
written, with three fallible statements still to come — the second discard,
seeding the new plugin with the sessions this daemon already has, and the second
stop of the incumbent. On the reinstall path the thing discarded *is* the running
plugin's directory and the newly published tree is the same path, so a throw ran a
catch that removed the new tree and found the old one already gone. Both trees
lost, the row naming a directory that is not there, and the plugin permanently
failed on a budget spent against a missing file. The catch's own order — remove
the new tree, then rename the old one back — was always right; it simply had
nothing left to put back.

**A second reason, independent of any throw.** The incumbent can come back
through the hook queue's drain between the first stop and the swap, which the code
above that stop already explains. If it did, it is executing out of the directory
that was renamed aside — and deleting that from under a live child is its own
defect. Hence *after* the stop specifically, rather than merely later.

**Rejected.** Moving the row write below the cleanup. The filesystem and the row
part company earlier than that, when the incumbent's tree is renamed out from
under a row still naming it, so this moves the window rather than closing it — and
it costs the flag the catch reads in two places to know whether it must restore.
The row write is the commit point and it is correctly placed; the defect was in
the order of destruction.

**And the data.** The same catch removed the row of a failed *first* install
without dropping that plugin's stored keys, which `doRemove` never does — it pairs
the two and says why. Unpaired here is permanent: afterwards there is neither a
row nor a directory, so an uninstall answers 404 and `dropPlugin` can never be
reached again. The keys then reappear under the next install of that id, because
plugin data is keyed by id rather than by version — deliberately, so that an
update keeps it. Safe to drop for the reason that same comment gives from the
other side: a first install that failed is not an update, and before it there was
no data of that id to keep.

**Deliberately still open.** A hard crash between the two renames leaves the same
divergence, and no in-process ordering closes that. Reconciling it at open — a
missing version directory beside exactly one directory this daemon moved aside —
is a separate change with a separate justification (power loss, not logic), and it
should not ship without a driver that can walk it: a recovery path nothing
exercises runs for the first time exactly when everything is already wrong.

**Status.** Current

### Q2.214 — Prose was clipped so a person would not be shown a wall of text. What did that cost?

**Decision.** The three character caps on an elicitation's prose are gone —
`MAX_ELICITATION_MESSAGE_CHARS` (512), `MAX_ELICITATION_TITLE_CHARS` (100) and
`MAX_ELICITATION_DESCRIPTION_CHARS` (300). `message`, a field's `title` and
`description`, and an option's `label` and `description` are carried exactly as the
agent sent them. `MAX_ELICITATION_FORM_BYTES` — 32 KiB over the projected form — is
the only bound left, and it **refuses** rather than shortening.

**Why. Because that is where the question lives.** `askTitle` reads a step's
heading from the field's `description` first, the form's `message` second and the
field's `title` last, and the order is not arbitrary: with several questions on one
form the adapter puts each *question* in its field's description and leaves
`message` as a preamble. So `MAX_ELICITATION_DESCRIPTION_CHARS` was a 300-character
cap on the sentence somebody is being asked to answer, and on the sentence
explaining what each answer means. The asymmetry Q2.23 argued — *structure is
refused because a form missing a question means something different* — applies word
for word to a question missing its second half.

**Measured 2026-08-24 against `~/.reemoat/reemoat.db`.** Over every
`AskUserQuestion` in the log: questions 26 median and **155** longest, option labels
29 median and **48** longest, option descriptions 139 median and **318** longest.
One of fifteen option descriptions was over the cap and was being cut on screen —
not a pathological case, an ordinary one, and the caps sat close enough to real
values that the next one along would go too.

**Why a byte backstop is enough, where three character caps were not.** The form
does **not** ride `SessionSnapshot` (Q2.24) — it is fetched by
`GET /sessions/:id/elicitations/:id` when a card opens — so the thing being bounded
is one response to one request, not sixty rows every four seconds. 32 KiB was
already the number for that, already a refusal, and already named in its own
message. What it did not have was an assertion against a *single* enormous string,
which the per-item caps used to catch; `daemoncheck` now drives both shapes.

**What was kept.** Every refusal: 24 fields, 24 options, an option `value` over 512
characters, an unknown property type, a list with no choices. A `value`
round-trips to the agent verbatim, so a clipped one is an answer it cannot
recognise — that argument is untouched and is why the split now runs between
*structure* and *prose* rather than between refusing and clipping.

**And the same measurement, one channel over.** `MAX_PERMISSION_TITLE_CHARS` and
`MAX_PERMISSION_OPTION_NAME_CHARS` went with them, replaced by
`MAX_PERMISSION_SNAPSHOT_BYTES` — 8 KiB over `{title, options}`, refused. Those two
*do* ride the snapshot, which is why they were bounded at all (Q7.82), but the
amplifier was never the string length: it was the pair being re-sent for sixty
sessions every four seconds, and one whole-object number says that where two
per-string ones only approximated it. Clipping `option.name` also had a cost nobody
had noticed: kimi's `AskUserQuestion` arrives down the permission channel, so that
name is a model-written *answer*, and `askedQuestion` recovers the question by
matching it against the same string in `rawInput` **by identity** — `rawInput` is
bounded by bytes and never by characters, so past 200 the two sides disagreed and
the whole question fell back to a row of buttons. Measured maxima on the same log:
title **14**, option name **31**.

**Status.** Reversed an earlier decision

### Q2.215 — An assembled session that had never been prompted came back bare

**Rule.** One option bag for every launch. `ManagedSession.launchOptions` is a
private getter typed as `SessionOptions`, and all three launch sites read it:
`start()`, and both arms of `doResume`. A resume adds `agentSessionId` and nothing
else.

**What was wrong.** `doResume`'s empty-conversation arm wrote its bag out by hand and
left `...this.assembled` off it. So a session assembled as *Claude Code · Kimi K2*
came back on claude's own account and claude's default model, while
`sessions.custom_agent` and the snapshot went on naming the assembled agent — the
right name on screen and the wrong vendor on the bill.

**Why it was silent, which is the whole entry.** The invariant written to make
exactly this refuse — *routable and un-pinnable must refuse* — is enforced inside
`applySystem`, and `applySystem` returns at its first line when `options.system` is
absent, before `client.routing()`, before `hostable`. `spawnEnvOf` is the same shape:
no system, no `routedModelEnv`, `{}` returned, no variable set. An omission therefore
does not produce a *wrong* route for the guard to weigh; it produces **no route at
all**, which is the one input every check on this path is built to pass straight
through, because it is what every session started before assembled agents existed
looks like. `SystemRoutingError` cannot fire on a bag that asked for nothing. **This
is a guard bypassed by a door it does not sit on**, and no amount of hardening inside
`applySystem` would have reached it.

**The arm was ordinary, not an edge.** `conversationKnownEmpty()` answers `true` for
every session at `turnCounter === 0`, so *every created-but-unprompted session* took
it: a daemon restart, a tap on Resume, an ultracode toggle, `restartAgent`, and the
clear-then-resume path the arm was actually written for. A preset assembled and
started from the New session sheet is unprompted by construction until somebody
types, so this was the first launch of a routed session rather than a rare one.

**Why the bag was hoisted rather than the one arm patched.** The three sites were
compared field by field first: byte-identical apart from `agentSessionId`. Nothing is
per-arm, so a shared bag obscures nothing — it removes the only place an omission can
hide. It is typed `SessionOptions` rather than as an inline shape, so a field added to
that type is a compile error at one site instead of a silent omission at three. And it
stays a **getter**: `assembled` goes back to the preset store, `ultracodeWanted` folds
in the machine default and `elicitations` is a thunk, so a captured bag would
reintroduce exactly the staleness the resolver exists to prevent.

**What makes a fourth launch site loud: nothing does, and that is worth saying
plainly.** A fourth site calling `Session.start` with a literal of its own would be
exactly as silent as the third one was, because the silence is a property of a bag
with no system rather than of how many bags there are. What was bought is narrower.
There is now one expression of the bag, and `daemoncheck` drives a real
`ManagedSession` down **both** arms of `doResume` and asserts the two launches agree
on the agent, the spawn environment, whether `providers/set` was sent and the model
pin. A fourth site is caught only if something drives it.

**The driver's first attempt at that assertion was false coverage, and only the
revert discipline found it.** It compared the ACP `session/new` and `session/resume`
request params minus `sessionId`, and stayed **green** with the fix reverted: losing
the system and the model changes none of `cwd`, `mcpServers` or `_meta`. What it
changes is the spawn environment and whether `providers/set` was sent, and neither of
those is in the ACP request. An assertion that reads as covering the defect it cannot
see is worse than no assertion, because it is believed; it compares `shapeOf` now,
and the reason is written at the helper so it is not simplified back.

**Status.** Current

### Q2.216 — A preset re-pointed at another harness, under a session already on it

**Decision.** `ManagedSession.assembled` **compares** the harness and does not follow
it. The resolver answers `{harness, system, model}` now, and the getter returns `{}`
when `one.harness !== this.agent` — the same demotion the deleted-preset arm one line
above takes, for the same reason: the preset has stopped describing this session, and
the honest answer is the bare harness `sessions.agent` has named all along. The
harness travels out of the store to be compared and is used for nothing else.

**Why nothing anticipated it, which is the useful half.** Q7.116 chose a *reference*
over a copy precisely so that "editing a preset changes what its sessions come back
as", and Q3.493 built the edit that makes the reference worth its cost. Both were
reasoning about a **model** change — the same CLI, a different model, resolved at the
next launch, which is what somebody expects of a preset. But `PATCH
/custom-agents/:id` is a *replace*, for the reasons Q3.493 gives, so all four fields
move and the harness is one of them. The reference was bought for a change of model
and delivered a change of vendor.

**Two repros, and the quiet one is worse.** Preset `{claude, anthropic, opus}` edited
to `{codex, openai, gpt-5-codex}` left a claude session resuming with
`system: "openai"`, whose `nativeHarness` is codex, so `applySystem` reached
`hostable` and every resume from then on answered `502 system_not_routable` —
permanent, and connected to the edit by nothing on any screen. Edited instead to
`{kimi, moonshot, kimi-k2-thinking}` it left the **claude** harness routed at Moonshot
on that model: a triple `hostable` permits, that `POST /sessions` could never have
produced, that starts and runs and bills somebody's Moonshot key, under a tile saying
Kimi Code.

**Why a demotion and not a refusal at the route.** `sessions.agent` is immutable
because the agent *process* is spawned from it, and the transcript, the resume id and
the credential on the other side all belong to that CLI. So a session whose preset has
changed harness has exactly two honest outcomes — the bare harness, or a refusal that
never expires — and the deleted-preset arm had already chosen the first. A refusal at
`PATCH` would also be the wrong shape: it lets a sleeping session veto an edit to a
list that is somebody's own scratchpad, on the machine they are looking at.

**Two comments asserted the opposite invariant and are corrected.**
`CreateSessionOptions.customAgent`'s said the harness and the preset's harness can
never disagree; it now says they agree *at creation* and names `PATCH` as the thing
that parts them. `readAssembledAgent`'s said the shared validator exists so that no
edit "puts the unstartable row into the store by the back door" — true of the row and
false of the sessions pointing at it, since `hostable` is weighed against the PATCH
**body**, which says nothing about a session that already exists. Its guarantee is
scoped to the store now, with `assembled` named as what keeps an existing session
startable. Prose arguing for what the code no longer does is the failure this
discipline exists to prevent, and both of these were one edit away from being it.

**A fact about driving it.** The quiet repro's resume **succeeds**. An assertion that
reads only the outcome stays green against the broken code; only one that reads the
launch *shape* — no system, no model, no routed variable in the spawn environment —
goes red. `daemoncheck` pins the shape, and pins the matching-harness case beside it,
because a guard that demoted every assembled session would pass every other assertion
in that section.

**What is still dishonest, and it is on screen rather than in the daemon.**
`SessionSnapshot.customAgent` is a join key, so a session that has degraded is drawn
under the preset's name, glyph and pairing regardless. This is Q7.120's gap reached
through a second door, and it is not closed by this.

**Status.** Current

### Q2.217 — A resumed session on a native pairing ran the agent's own default model

**Measured.** A real `Session` driven against a fake ACP peer publishing a
`category: "model"` select — `currentValue` sonnet, choices sonnet and opus — under
`{system: "anthropic", model: "opus"}`. `Session.start` sent
`session/set_config_option` on that option's own id carrying `opus`. The resume sent
**nothing at all**, and the conversation came back on sonnet while the tile, the
snapshot and `sessions.custom_agent` all went on naming the preset. That the resume
*had* the list to weigh the model against is not an inference either: the SDK's
`ResumeSessionResponse` carries the full `configOptions`, and `claude-agent-acp`
0.63.0's `getOrCreateSession` populates it, so `modelOption` is already there by the
time `adopt` returns. Nothing was missing but the call.

**Why it was silent, which is the half worth reading.** For a *native* pairing there
is no second door. `spawnEnvOf` answers `{}` — there is no `ROUTED_MODEL_ENV` for a
harness talking to its own vendor — and `applySystem` returns at its first line
because `SYSTEMS[system].nativeHarness` is the harness already running.
`session/set_config_option` is therefore the whole mechanism, and skipping it produces
**no wrong pin for any guard to weigh**: it produces no pin, which is what every
session started before assembled agents existed looks like and is the one input every
check on this path is built to wave through. **That is the second post-mortem this
week on a guard bypassed by a door it does not sit on**, and the first is Q2.215 —
same shape, one door along. Q2.215 lost the whole option bag at one of three launch
sites and was caught by hoisting the bag; this lost the one half of it that is applied
by a *call* rather than by an environment variable, at one of two launch sites, with
the bag entirely correct at both.

**The asymmetry that made it findable, and it is a fact rather than an aside.** Resume
accepted a triple create refuses. With the model gone from the agent's published list,
`Session.start` throws `SystemRoutingError` and `POST /sessions` answers `502
system_not_routable`; the resume of that identical pairing came back happily on
whatever the CLI defaults to. A pairing that cannot be created but can be resumed is
not a state anything here intended, and the two arms disagreeing is what said the pin
was on one path only.

**Decision.** The resume path pins when it can and reports when it cannot. Three
options were weighed and two of them are the two failures this file already records.
*Refusing on resume the way create refuses* strands a conversation for ever the first
time a CLI retires a model — a permanent 502 on a session that worked yesterday,
connected to nothing on any screen, which is exactly the stranding shape Q2.216
deliberately designed away days earlier when it chose a demotion over a refusal that
never expires. *Demoting silently* is the defect above, relocated. So neither: pin it
while the agent still offers it, and put a sentence in the transcript when it does
not.

**The mechanism is one this daemon already has, and that is deliberate.**
`pinNativeModel` **answers** the refusal sentence rather than throwing it
(`Promise`), because its two callers disagree about what an un-pinnable
model means and only the *reaction* is theirs — the wording stays in one place.
`Session.start` wraps the answer in `SystemRoutingError` inside the dispose-and-rethrow
it already had, so the start path is byte-for-byte what it was, 502 included.
`Session.openResumed` resumes anyway and pushes one `error` event onto the session's
own queue, naming the model that was asked for and the one the session is running,
with `data.code` of `model_not_pinned`. No new event type, no wire field and no
`registry.ts` change: `error` is the event this daemon already writes about *itself*
when it carries on after something it could not do — `abandonResume` is the same shape
— it is already drawn by `EventList`, already sized and truncated in `events.ts`, and
the registry's `startIdleDrain` already records it, which is the channel
`session_started` reaches the log by.

**`error` rather than `onWarning`, and the precedent is the point.** `onWarning`
reaches whoever is reading the daemon's stdout; the person this concerns is holding a
phone. It is the loud red row on purpose, because the alternative that was actually
considered was refusing the resume outright. And a `set_config_option` the agent
*rejects* on a resume is announced the same way rather than thrown — `restoreConfig`
already swallows exactly this failure, for exactly this reason, since a live
conversation is not torn down over a config option; what is new is that this one is
not swallowed silently. On start that call still throws, unchanged.

**How it has to be driven, because the resume succeeds either way.** The sweep is
{start, resume} × {native, routed} × {model offered, model gone}, and the assertion
reads the calls made on the wire rather than the outcome — Q2.215's false-coverage
lesson and Q2.216's quiet repro for the third time. Native/offered on the resume arm
is the defect's own cell; native/gone on the resume arm asserts the resume did *not*
throw and that the log carries exactly **one** notice, counted rather than merely
present, so a second launch may not append a second copy; start/gone stays a 502 with
the client disposed, and a driver asserting only one of that pair records nothing
about the asymmetry that is the decision. The mutations that must turn a cell red are
`pinNativeModel` answering `null` unconditionally and the pin deleted from
`openResumed`; both left every pre-existing assertion in that section green. The rig's
fake peer had to publish a model select on **both** responses before any native cell
was reachable at all — until then every native pairing took the `modelOption === null`
arm, which is why the sweep could be written and still see nothing.

**What it costs, and neither cost is closed here.** One notice per resume: a session
whose model has been retired and that is resumed after every daemon restart accrues a
row each time. That is honest and matches `abandonResume`'s per-give-up shape, and if
it proves noisy the fix is a dedupe against the last such event, never a demotion back
to silence. And `SessionSnapshot.customAgent` is still a join key, so a session that
has just been announced as demoted is still *drawn* under the preset's name, glyph and
pairing — Q7.120 and Q2.216's own remaining dishonesty, reached through a third door.
The transcript now disagrees with the chip out loud instead of silently, which is not
the same thing as closing it.

**Status.** Current


### Q2.219 — An OpenRouter session offering OpenCode Zen models

**Question.** Reported twice. Q3.519 divided opencode's one 362-row model control
into `OpenRouter` and `OpenCode Zen` headings, which is what "separate them" was
read as. The second report was sharper: *"if the session is through openrouter,
then the models there are openrouter's, zen is not needed."* A heading was never
the ask.

**Decision.** `narrowToSystem` filters the **snapshot's** model choices to the
session's own system, keyed on `nativeModelPrefix`. The log keeps everything the
agent published.

**Why it is a correctness fix and not tidying.** A session is a
harness × system × model triple, and the preset names it. Choosing a Zen model in
an OpenRouter session leaves the session running a model from a system its own
preset does not describe — while the chip, the tile and the glyph all go on saying
OpenRouter. That is Q2.216's dishonesty exactly, reached through the model menu
instead of through a preset edit, and unlike a preset edit there is nothing to
announce it.

**⚠ The namespace is the *pairing's*, never the current value's** — which is the
whole reason this is on the daemon rather than in the browser. The browser holds an
`AgentConfigOption` off a snapshot and knows nothing about systems: it could only
infer the namespace from whatever model is selected, and a session already switched
to the wrong system would then be offered **only** that system, for ever. The
daemon reads it through `assembled`, so a preset re-pointed at another harness
answers `null` here for the same reason it answers no system there.

**⚠ The selected choice is never removed, whatever namespace it is in.** A list
missing the value its control is set to makes `chipValue` fall back to a raw id and
makes `pinNativeModel` refuse the next resume with *"has no model called …"* — so a
session somebody had already switched by hand keeps a way back to itself instead of
being cut adrift by the fix.

**Narrow twice, and both exits are by identity.** `category === "model"` only,
since no other control's values are namespaced; and only where the system sets a
`nativeModelPrefix`, which is opencode's two and nothing else. Every other agent's
list is returned as the same object.

**Where it is not.** Not in `toConfigOptions`, which is a faithful ACP flattening,
and not in the log — the transcript records what the agent said. `snapshotConfig`
is the right seam and already shapes this payload for a stated reason:
`dedupeAliasChoices` removes a placeholder from it, and `withUltracode` adds a row.

**Known limitation.** `POST /sessions/:id/config` still *accepts* a model outside
the namespace; nothing offers one any more, but the route does not refuse it. The
guard belongs beside `pinNativeModel`'s own refusal and is not built.

**Status.** Current

### Q2.218 — Four prompts, three turn ends

**Question.** A session's log held four `prompt` events and three `turn_end`s. The
missing one belonged to a turn that ended in an `error` — a provider refusing the
model, surfaced verbatim. Is a turn that failed a turn that ended?

**Decision.** Yes, and `pump` writes the end itself:
`turn_end{stopReason: "agent_error"}`, immediately after the error and only when
the turn actually failed.

**Why.** The argument is Q2.103's, already made a hundred lines up in the same
file for the cancel path: the daemon writes the end because the agent never gets
to send one, and **a prompt with no turn end at all is the shape this codebase
calls a message that reached no model**. `Session.prompt` converts a rejected
`session/prompt` into an `error` event and returns on it exactly as it returns on
a `turn_end`, so the turn was over and nothing on the wire said so.

**What it cost, none of it a spinner.** Nothing was stuck — `pump`'s `finally`
clears `turn`, `showsWorking` reads the snapshot, Stop comes down and the composer
stays open. Three quieter things broke instead. `Tail.taskFloor` is raised by
`session_started` or a non-`end_turn` `turn_end` and by nothing else, so a turn
that failed **mid-delegation** left its pending calls counted for the rest of the
session under a permanent "waiting for 1 task" — the exact false line that floor
exists to prevent. The `turn.ended` plugin hook never fanned. And the turn's origin
claim was never spent, so a plugin that started the failed turn had the **next**
turn's hook suppressed instead — a bug that outlives the turn it came from.

**`agent_error` is a sixth reason, and none of ACP's five would do.**
`TurnEndEvent.stopReason` was ACP's closed union; it is `TurnStopReason` now.
`refusal` is the *model* declining and `cancelled` is something a person did, and
either would be a lie in the one row a reader trusts about what happened. The
client needs no change to accept it — `wire.ts` has always typed this field as
`string` and `stopReasonText` falls through to the identifier — which is this
wire's standing rule for an unknown value, exercised rather than assumed.

**⚠ And the predicate is not "an `error` was seen", which is where the first draft
was wrong.** The turn generator yields `CLOSED` — `{type: "error", message:
"session closed"}` — whenever the queue closes under it, which is this daemon
disposing the agent on a stop, a restart or a replacement. Writing `agent_error`
there would blame the agent for a teardown we performed, and fan a plugin hook on
a path no plugin has seen. `isSessionClosed` is exported from `session.ts` and
compares by **identity**: the message is prose, and matching it would make an
agent that happens to say "session closed" indistinguishable from us saying it.
`drainBetweenTurns` already recognised the sentinel; the turn loop did not.

**Rejected: drawing a row for it.** `showsInTranscript` answers `false` for
`agent_error` as it does for `end_turn`, and for the opposite reason — the row
immediately above is the agent's own error in the agent's own words, so a line
under it saying the turn ended states one fact twice, the second time worse. The
floor is raised regardless, because `taskFloor` runs before that gate: "nothing is
drawn for it" and "nothing happened" are different sentences and `webcheck` pins
both.

**Rejected: fixing `taskFloor` alone, client-side, with no wire change.** It is
the cheaper repair and it fixes one of the three — the plugin hook and the origin
claim are on the daemon and cannot be reached from a browser. It would also leave
the daemon's own log still saying four prompts and three ends, which is the thing
that was actually wrong.

**Not claimed: that every prompt now has exactly one end.** Four places in this
codebase say out loud that turns without one exist — an agent that died with its
turn, a daemon interrupted, a `/clear` that burns no turn number — and each builds
its own compensation. What closed is the one case where the daemon *watched* the
turn end and wrote nothing down.

**Measured.** `daemoncheck` drives a stub that answers `session/prompt` with
`-32603` carrying the upstream's own prose and **no** `errorKind` — the shape read
off the live log, and the reason `isAuthFailure` correctly ignores it and text
matching was never an option. It asserts the order (`error` then `turn_end`), the
reason, one end per prompt, and that no exit is recorded: a bad turn is not a dead
agent, and `onAgentUnusable` must not fire on one.

**Status.** Current


### Q2.220 — "It has no model called X. It is running X."

**Reported from the app, as a screenshot of one row.** A resumed opencode session
drew the error notice:

> opencode has no model called "opencode/hy3-free" — it offers
> openrouter/aion-labs/aion-2.0, … and 352 more. The conversation was resumed
> anyway, running opencode/hy3-free.

Both halves are true. The row is still wrong, and the way it is wrong is the
interesting part.

**`pinNativeModel` tests the wrong thing, and the two facts it conflates have
different answers.** It asks whether the model is in `option.choices` — what the
agent currently *offers* — and refuses when it is not. What the pin is *for* is
that the session runs the model. `option.value` is what it is running, and it can
sit outside `choices`: opencode resumed the conversation on the model it was
started with, while the list it publishes now reflects whichever provider keys the
machine holds today. So the session was on exactly what its preset named, and the
daemon announced a demotion that had not happened.

**This daemon already treats that state as normal at the other end**, which is what
makes the conflation a defect rather than a judgement call. Q2.219 narrows the
model menu to the session's own system and then says: *"The selected choice is
never removed, whatever namespace it is in"* — precisely so a session somebody had
switched by hand keeps a way back to itself. An agent that stopped listing the
model it is running is the same fact seen from the agent's side.

**Two consequences, and the second is the worse one.** `openResumed` drew a loud
`error` row — `agent-systems.md` calls it "the loud row on purpose" — about a
session that was fine, contradicting itself in one sentence. And `Session.start`
turns the same refusal into `SystemRoutingError`, so **a preset whose model the
agent is currently running answered a permanent `502`**. That is Q2.216's stranding
reached from the opposite side: that entry chose a demotion over a 502 that never
expires, and this produced the 502 anyway, for a pairing that works.

**Fixed by asking the right question first**: `option.value === wanted` returns
`null` before the list is read. Nothing to do, nothing to say — and one fewer
`session/set_config_option` round trip on every resume of a session already on its
model.

⚠ **The driver had a whole column for this and never drove the case.** The pinning
rig hardcoded `current: "sonnet"` while the pinned model was `"opus"`, so every
"the model is gone" cell was a genuine demotion — and the assertion *"the demotion
names both models"* is correct about the fixture and silent about the shape that
shipped. `current` is a parameter now and the four cells are pinned for
`current === wanted` too, including `start` answering `(started)` where it used to
answer `SystemRoutingError`.

**Status.** Fixed.

### Q2.221 — Is a refused `session/new` a fact about the credential?

**Reported from the app, as a screenshot of the New session sheet.** The strip
drew a tile reading `Gemini / from Bring yo…` — a harness the `byo` plugin adds —
and pressing Start answered:

> Gemini (gemini) rejected session/new: authentication required. Gemini refused
> this session. Run `gemini` once in a terminal on this machine to sign in with
> Google, or paste a GEMINI_API_KEY below.

The sentence is correct and the tile should not have been there. What made it
persistent is that nothing this daemon knows could have taken it away:
`readLoginState` answers `pasted ? true : null` for every harness with no status
command — which is opencode and every harness a plugin adds — so `loggedIn` was
`null`, `agentStance` answered `no_login`, and `offersTile` draws a tile for that.
Each press cost a worktree, a branch and a session row before the agent declined,
because the refusal happens *after* the spawn.

**Question.** The daemon now remembers the refusal. Does it belong on `loggedIn`?

**Decision. No, and the reason is not fastidiousness — it is that writing it there
closes the only door out.** `AgentAskRuns.admit` refuses on `loggedIn === false`,
and `admit` guards `claim`, which is what `GET /agents/capabilities` uses to start
a harness and read what it offers. That sweep is the **one** live re-measurement a
harness with no status probe ever gets: it performs a real `session/new` and caches
only successes. Route the refusal through `loggedIn` and the record blocks the
spawn that would have discovered the harness had been fixed — a lockout closing its
own last door, and one nothing on any screen could explain.

The narrower argument stands beside it. ACP's `auth_required` is answered by the
*adapter*, and Q7.65 is this repository's own counterexample: a pasted
`CODEX_API_KEY` that reached the model's API and was accepted, while `codex-acp`
went on refusing `session/new`. `loggedIn` answers "is this CLI signed in"; a
refusal answers "would this harness open a session, configured this way, a moment
ago". They are different questions and the second one is the one a tile is about.

So `AgentAvailability.lastStartRefusal` is its own field, carrying `at`, `routed`
and the message. `readLoginState` is untouched, and its own ⚠ now names where the
negative went — the docblocks at `runtime/types.ts`, `local.ts` and
`daemoncheck.ts:1525` all say a harness with no probe cannot report itself signed
out, and all three stay true.

**Two places write it and a third deliberately does not.** `Session.start` and
`Session.openResumed`, both on `isAuthRequired` — ACP's *typed* code rather than a
message match. Not the event pump's `errorKind: "authentication_failed"`: Q7.99
measured that against a session idle 5h36m whose token had 1.4 hours left, where a
fresh agent worked four minutes later. What is stale there is the process, and
`onAgentUnusable` carries a comment saying so at the one site that could have
written this.

**`routed` is what stops one refusal condemning a pairing it never tested.**
`applySystem` runs before `session/new` and returns whether it actually routed — a
native pairing configures nothing, because the agent already reaches its vendor on
its own credential. A refusal measured while routed has survived `providers/set`
and is evidence about every way of starting the harness. One measured bare is not:
that is the signed-out Claude Code on OpenRouter this repository documents as
working, and a fence reading a bare refusal as a fact about every start would have
deleted it. `registry.create` fences before `createWorkspace` — the same paragraph
the `available` check above it already carries, applied to the axis it did not
cover.

⚠ **And `customAgent == null` is not the whole test, which cost a draft.** A
**native** pairing carries a system and configures nothing: `applySystem` returns
at `spec.nativeHarness === options.agent`, before `providers/set` and before any
key is read, so the session runs on the harness's own credential exactly as a bare
start does — which makes a bare refusal precisely as dispositive. Written as two
disjuncts, the fence let through the one press that had just been refused, and
every repeat cost a worktree, a branch and a session row again. It resolves the
preset and asks the catalogue now. The case needs no plugin: `hostable` accepts
every native pairing, so a signed-out Claude Code with a native Anthropic preset
reproduces it.

⚠ **And the route that undoes it must answer the same row shape the listings do.**
`availability()` carries no `login` object — that is built in `loginSupportOf` and
spread onto `GET /agents` and `GET /agent-auth` by hand — so `POST
/agent-auth/:agent/recheck` returning its lookup verbatim dropped the one field
whose absence makes every reader fall to *cannot check*, which is the permanent
wrong badge `local.ts` answers `no_flow` to prevent. Measured: `pnpm client agents`
printed *no sign-in needed* and `pnpm client agents recheck` printed *cannot check*
for the same harness in the same state.

**Status.** Fixed.

### Q2.222 — A restart deleted five of the six conversations it was stopping

**Question.** What may the startup prune delete, and when may it delete anything
at all?

**Measured, 2026-09-04, host `cloud-09fce7b571`, read-only from the prod session.**
The daemon restarted at 14:46:18 UTC for the v0.6.0 deploy. The journal holds
`SIGTERM: stopping 6 session(s)` at 14:46:17 and `restored 1 session(s)` at
14:46:20, and nothing between them. The database now holds one row —
`s_bd274666`, opened 2026-08-31, last written 14:46:41 — and the file is
51,060,736 bytes on disk against `page_count × page_size` of 0.6 MB: the shape of
~50 MB of transcripts deleted in one operation and then vacuumed, which is
`reclaim()`'s trigger met in one boot. `reclaim()` runs only from `prune()`, after
its COMMIT, and `prune()` runs only from `openStores`. So the five were not lost on
the resume path. They were deleted by the store, in the three seconds between the
two journal lines, before `restore()` ever saw them.

**Root.** The age sweep read `SELECT id FROM sessions WHERE created_at < ? AND
pinned = 0` with `cutoff = now − 7d`. A conversation was condemned by the date it
was *opened*, however much it had been used since. Five of the six live sessions
had been opened before 2026-08-28 14:46; every one of them had been written to
seconds earlier by `doStop` under SIGTERM, carrying `daemon_shutdown` — the
daemon's own record that it meant to bring them back — and every one went with its
transcript. Nothing was logged: the ids `prune()` returns went up to `daemon.ts`
only to sweep upload directories, and the one `console.error` in the method fired
for the cap alone.

**Decision. Three rules, and a floor.** The owner's requirement, 2026-09-05: the
prune deletes *only inactive sessions*; a session the daemon itself stopped and
meant to restore is never deleted at any age; and nothing at all is deleted while
the table holds fewer than fifty. **And D27, the owner, 2026-09-05, after the
verification round**: the fix round's two calls stand — the floor is *kept*, a
prune never leaving fewer than `minSessions` rows ranked active first, then pins,
then most recently touched; and the cap bounds *inactive* rows only, so a live or
daemon-ended row is cut by neither sweep and the table may exceed the cap by its
active rows — and the hole those two left is closed: a row the daemon has
given up restoring on its own (`resume_gave_up` holding a value this build
honours) is not active, whatever its exit says — once it has one.

1. **Only an inactive session is ever deleted, by either rule.** A row is
   *active* — and the prune never touches it, at any age, under any cap — when
   it is live (no `exit_json`), when its exit `endedWithDaemon` (the daemon's own
   promise to bring it back), or when its exit cannot be read: not JSON, not an
   object, or a `reason` `isExitReason` cannot vouch for. `isExitReason` is new,
   a `Record` so that a reason a newer build wrote is a value
   this one may not act on — `compatibility.md`'s rule for which way an unknown
   must fail, applied to a deletion. Unless the daemon has given it up: a row
   whose `resume_gave_up` holds a value this build honours (`isPersistedGiveUp`,
   beside `isExitReason` and for its reason — one it cannot name keeps the row)
   is one the daemon will not put an agent back on by itself — the column is
   written only when the agent says it no longer holds the conversation, the
   one give-up `resumeGiveUpPersists` lets outlive a restart, and
   `resumeSettled` keeps such a row out of the boot pass and the prompt route
   alike; only a manual `POST /sessions/:id/resume` still can, and it clears
   the column when it succeeds — so once it has an exit it is inactive whatever
   that exit says, while a live row carrying the column is read as live first,
   since the next boot comes back to it; the promise was the daemon's, and this
   is the daemon recording that it cannot be kept on its own (D27).
   `isActiveRow` is the one predicate, for both sweeps, reading both columns in
   that order, and `DAEMON_EXIT_REASONS` has no copy in SQL. Every other reason
   is inactive by decision rather than by remainder: `stopped` and
   `agent_signed_out` were ended by a person (the sign-out has one writer,
   `signOutSessions` under the logout route, so signing in again is that person
   asking), `agent_exited` by the agent, `start_failed` and `start_timeout`
   never had a conversation, and `agent_kill_failed` is a legacy value
   `autoResumable` brings back on no trigger, so no promise is broken by taking
   it. The age sweep takes an inactive, unpinned row whose `updated_at`, which
   `put` stamps on every write that changes the row, is older than `retainMs`.
   Activity, never creation.
2. **A prune leaves at least `DEFAULT_MIN_SESSIONS` rows.** Fifty. Rows are
   ranked active first, then pins, then most recently touched first, and a row
   within the floor is taken by neither sweep — so a table under fifty loses
   nothing, and one over it keeps at least fifty: its active rows first, then
   its pins, then the most recently touched of the rest, however stale.
   The orphan sweeps still run, since they delete nothing anybody can see, and
   `reclaim()` still runs, since its trigger — `freelist_count` against
   `page_count` — counts pages freed by any delete since the last `VACUUM`, not
   what the prune just took. `REEMOAT_MIN_SESSIONS` moves it, through
   `positiveInt`, so `1` is the nearest thing to off and `0` reads as unset.
3. **The cap bounds the rows nobody is coming back to, and never the others.**
   Among inactive rows, pins first, then most recently touched first, and the
   rank past `maxSessions` goes: the least recently touched inactive row, a pin
   only once every unpinned inactive row is gone, a live row or one the daemon
   is still coming back to never. The table may exceed the cap by exactly its
   active rows, since no cap could bound those without breaking rule 1. It used
   to rank by `created_at`, so the newest-*opened* two hundred survived and a
   conversation opened a month ago and used this morning was cut before an
   empty one opened yesterday.

**And it says so.** One sentence through `onPruned` after the COMMIT — the count,
the split between idle and over-the-cap, every id, and that the transcripts went —
and nothing when nothing was removed. Its own callback rather than `onDegraded`,
because a prune is the store doing what it was configured to do: `daemon.ts`
prints one as `store degraded:` on stderr and this as `store:` on stdout with the
other facts about the boot, and the driver that collects degradations must not
find a routine deletion among them. Said outside the transaction's `try` and
guarded on its own, so a sink that throws cannot turn a committed deletion into
an empty list.

**Why fifty.** A quarter of `DEFAULT_MAX_SESSIONS`, and since the floor is kept
rather than gated the two bounds never meet whatever either is set to — a floor
above the cap leaves the cap nothing to cut. ~8 MB per session measured here, so
fifty is ~0.4 GB — what the floor lets sit on a disk, and nothing beside the
working trees those sessions were about. One person's dense week is 20–40
sessions, so a machine in ordinary use stays under it and the sweeps never run
across the list somebody is still scrolling. Below fifty a list *is* scrolled and
pruned by hand from the row's own menu; the sweeps are for the tail nobody will
scroll to.

**What still bounds the file.** The cap, on the rows nobody is coming back to:
two hundred, pins last, and it is still a deletion — the creation bound in
`registry.ts` is what keeps a loop from filling it. The rows the daemon is coming
back to sit outside every bound here, which is rule 1 and cannot be otherwise —
and the rows it has *given up* coming back to sit inside them, which is D27 and
closes the one class rule 1 had left unbounded. So the file holds at most the cap
in inactive rows plus the active ones, and those have bounds of their own: a live
row counts against `MAX_LIVE_SESSIONS`, and a daemon-ended row was a live one
under that ceiling, which the next boot restores or gives up.

⚠ **That last sentence stopped being true when parking landed, and this is the
correction rather than a second opinion.** A `parked` row is active —
`keepsItsConversation` is what `isActiveRow` reads — and it satisfies neither
bound the sentence names. It does not count against `MAX_LIVE_SESSIONS`, because
`liveSessionCount` counts `!terminal` and a parked session is terminal; and the
boot pass neither restores it nor gives up on it, because `autoResumable` answers
`parked` on a prompt alone. Nothing else moves it: `markInterrupted` returns early
on an existing exit record, `shutdown` iterates the live ones, and only a person
pressing Stop or a sign-out relabels it (`RELABELS_PARKED`). **So the active class
is unbounded again, by a different door than D27's.**

What made the old shape self-limiting was the thing parking removed on purpose: an
abandoned conversation used to hold ~400 MB and a slot, so a machine ran out of
ceiling long before it ran out of disk. A parked row costs neither, so nothing
pushes back. Measured on the development machine 2026-09-10, hours after the
feature landed: 5 parked rows of 12. Second-order, and the sharper half — `active`
counts parked rows into the floor (`if (active + rank <= options.minSessions)`),
so a machine holding fifty parked conversations has spent the whole 50-row floor
on rows that were never at risk, and it stops protecting the inactive ones it was
written for.

**Deliberately not bounded yet, and that is the decision rather than an
oversight.** A parked conversation is one somebody is *expected* to return to,
which makes it the last row a prune should take — an age bound here is Q2.222
pointed at the quietest sessions instead of the busiest, and there is no
measurement behind whatever number it would pick. At ~8 MB a session the runway is
years. What is owed is this paragraph, so the next person to read "the file holds
at most the cap" does not believe it, and a look at real numbers before a bound is
chosen. If one is wanted, the shape to weigh first is excluding parked rows from
the floor's arithmetic — it loses nothing recent and restores what `minSessions`
was for. At ~8 MB a session
the cap is ~1.6 GB of transcripts nobody is coming back to, and the floor's
~0.4 GB is the part of that which may sit there however stale. And `reclaim()`,
on bytes, whose condition is independent of what the prune removed and says so in
its docblock.

**Read back, 2026-09-05, the verification round.** Three verifiers drove the first
cut of these rules against stores of their own and found the code short of the
sentences in four places, each of which is a pin now — and the owner's read of
the fix round found a fifth, and the follow-up's own verifiers a sixth:

- *The cap cut what rule 1 kept.* It ranked `pinned DESC, active DESC` across the
  whole table and cut past two hundred regardless, so "cut last" was "cut": two
  hundred pins put every live row and every row the daemon had just stopped over
  the cap at the next boot, and a hundred and fifty interrupted rows beside
  fifty-one live ones cut the least recently touched interrupted row. And its
  `active` was a SQL `CASE` — `json_valid`, then a reason list — so a reason this
  build cannot name, `null`, a bare string, `{}` and `{"reason":null}` all ranked
  inactive and were cut while the sweep kept them. The cap ranks inactive rows
  only now, through the sweep's own predicate, and nothing reads the JSON in SQL.
- *The floor was a gate.* `count >= minSessions` before any delete: a table of
  exactly fifty lost forty-nine in one boot, and a cap set under the floor cut
  under it, while the rule file said "never under 50 rows" and the constant's
  docblock said the two bounds "never meet". A floor that is *kept* implies the
  gate and not the other way round, so it is the floor.
- *`onPruned` was called inside the transaction's `try`.* A sink that threw ran
  ROLLBACK against a committed transaction, skipped `reclaim()` and returned
  `[]` over rows that were gone — the list `daemon.ts` sweeps upload directories
  from. Latent, since `console.log` does not throw.
- *Four of the nine reasons were swept with nothing saying so*, and the replay
  was not row for row: its sixth row was live and eight days idle where the
  incident's was a `daemon_shutdown` opened four days before. Both read as
  stated above now.
- *A row the daemon had given up on was active for ever.* Found after the fix
  round, and closed by the owner's decision D27 (2026-09-05) that kept that
  round's two calls — the floor kept rather than gated, the cap on inactive
  rows only — and named the hole they left. `isActiveRow` read `exit_json`
  alone, so a `daemon_shutdown` row whose next boot wrote `resume_gave_up` (the
  agent said it no longer held the conversation, the one give-up
  `resumeGiveUpPersists` keeps across a restart, after which `resumeSettled`
  takes the row out of both automatic paths) still read as the daemon's promise
  to come back: swept by no age, counted under no cap, a class nothing bounded
  and one that grew by a row for every conversation an agent forgot.
  `isActiveRow` reads both columns now — `resume_gave_up` non-NULL is inactive
  whatever the exit says — and is driven under both sweeps beside a twin with
  the column NULL. The one such row the boot pass does still touch is a
  conversation `conversationKnownEmpty`, which it recreates rather than
  restores, since the conversation the agent is asked for is empty by
  construction; what a deletion loses there is the empty conversation the pass
  would have reopened under the same title and worktree and, for the cleared
  arm, the events before the marker, which `/clear` truncates nothing of —
  they go with the row as every inactive row's log does.
- *Two rows the column alone misread, and a sentence half pinned on nothing.*
  Found by the follow-up's verifiers, the same day. A live row carrying the
  column — the shape `doResume` leaves on disk for the length of every
  recreate, exit cleared and column not yet — was inactive, so a crash inside
  that window left a row the next boot's prune deleted under the sentence
  saying a live session never is; `isActiveRow` reads live first now. And any
  non-NULL value was inactive, on the argument that the store may not read the
  registry's vocabulary, while the registry reads a value it cannot name as
  "not given up" and restores the row at boot — so a downgrade from a build
  persisting a second value would have deleted exactly what the boot pass was
  coming back to, the rollback case `isExitReason` exists for on the other
  column. `isPersistedGiveUp` sits in `events.ts` beside it now, and both
  readers go through it. And the floor half of the one sentence the journal
  carries was pinned on nothing, so the wording it replaced came back green
  under a mutation; it is pinned. Both rows are driven under both sweeps.

**Alternatives taken out.**

- *Keying the age sweep on `last_event_at`.* It is NULL for a session that never
  produced an event and does not move on a rename, a pin or a stop, all of which
  are somebody touching the conversation. `updated_at` moves on every `put` that
  changes the row.
- *Filtering the daemon exits in SQL with `json_extract` in the `WHERE`.* A second
  copy of `DAEMON_EXIT_REASONS`, and one malformed `exit_json` would have thrown
  the whole prune into `ROLLBACK`, orphan sweeps included. The cap's ranking read
  the JSON that way for one commit, guarded by `json_valid` and parameterised from
  the constant, and that guarded copy is the one that disagreed (above).
- *Ranking active rows first under the cap and cutting past two hundred
  regardless.* Bounds the table to exactly two hundred, and makes whether a pin
  survives depend on how many sessions were live at the last restart — and it is
  a cap that deletes what rule 1 says is never deleted.
- *Clamping `REEMOAT_MAX_SESSIONS` up to `REEMOAT_MIN_SESSIONS`.* Unneeded once
  the floor is kept: a row within it is taken by neither rule, whatever the cap
  says.
- *Reporting through `onDegraded`.* The prefix would have read `store degraded:
  pruned 5 session(s)` over the store doing exactly what it was told, and the
  `degraded` array `daemoncheck` asserts on would have carried a routine line.
- *Skipping `reclaim()` under the floor.* Its trigger is about the file, not the
  transaction; a quarter of the file can be free from `DELETE /sessions/:id` on
  any day since the last `VACUUM`.

**Driven.** `daemoncheck`, in `daemoncheck.store-and-worktrees.ts`, against a
store of its own: the incident replayed row for row (five opened eight days ago
and one four days ago, all six written a minute ago with `daemon_shutdown` — none
pruned, with and without the floor); every `ExitReason` under a floor of zero, off
a `Record` so a tenth reason is a compile error until
it is placed; forty-nine stale rows under a floor of fifty, then the fiftieth, then
sixty; a cap under the floor and a floor above the cap; two hundred and eight rows
whose `updated_at` and `created_at` run in opposite directions, beside eight the
cap may not touch and each touched less recently than any of them; more active
rows than the cap, and more pins than the cap; a `daemon_shutdown` row the daemon
has given up on, under the age sweep and under the cap, each time beside its twin
with `resume_gave_up` NULL, a live row carrying the column, and a
`daemon_shutdown` row carrying a value this build cannot name; the one sentence,
its split and its floor half; a
sink that throws; the floor and the window through `openStores` with neither
option given; and the daemon's wiring off the entry script's text. Every pin was
proven red against the old statement, against the first cut's store, or under a
one-line mutation.

**Status.** Fixed, 2026-09-05.

### Q2.223 — "this machine already has 6 live sessions", on a daemon that may well have had eight

**Question.** The owner hit the live-session cap on a provisioned cloud box and
asked the fair question: *"почему у нас существует какой то лимит сессий? его не
должно быть, либо обоснуй понятным языком, зачем он нужен"*. Two things had to be
answered — where the 6 came from, and whether the bound should exist at all.

**Where 6 came from.** Not from this repository. `MAX_LIVE_SESSIONS` is 64;
`services/premium/cloud-init/provision.sh` writes `REEMOAT_MAX_LIVE_SESSIONS='6'`
into `~/.reemoat/daemon.env` once, at provision, with its own arithmetic above it:
*"The default is 64 and this machine has 8 GiB, so 64 is not a bound — it is a
promise of an out-of-memory kill. Six is arithmetic… Revise it against a real
box."* Nothing in `deploy/` ever rewrites that file, so an update never touches it,
and `maxLiveSessions` is on no route and in no snapshot — **the refusal string is
the only place in the product this number is visible.** Which is why the number
could not be found from the phone that hit it.

**Decision on the bound.** It stays, and it is not one bound but two — Q2.100's
pair. Of the four reasons behind it, exactly one still applies to a single-owner
machine, and it is the decisive one: **memory**. Agents are spawned `detached`, a
new process *group* and not a new cgroup, so they live in the daemon's own cgroup
and no cgroup arrangement protects the daemon from them; the premium box runs
`earlyoom` with `node` among its preferred victims and the daemon is itself a
`node` process. Removing the ceiling trades a refusal that names its remedy for an
OOM kill of an agent mid-turn, on a phone, with nothing on screen explaining why.
The honest fix is a bigger box or `SessionRuntime`, both of which are somewhere
else.

The other three are weaker than the docblock reads, and saying so is the point of
this entry. The prune taking somebody's transcripts is largely defused by Q2.222's
floor and rules, and was never the live cap's job anyway — the *burst* is that
half, and it survives independently. The shared-machine case needs a grantee. And
the multi-tenant container case does not exist in this product any more.

**What changed instead.** The sentence. It interpolated `maxLiveSessions` — the
*limit* — inside a clause claiming a *count*, and the two coincide only at the
boundary: **resume is deliberately outside the bound**, so a restart that brings
eight conversations back leaves a daemon truthfully holding eight while the
refusal insists on six. It now states the limit as a limit and names
`REEMOAT_MAX_LIVE_SESSIONS`, because a number visible in exactly one string should
say what to search for.

**Not done, and each for a reason.** Removing the live cap: it is the last named
bound on a plugin whose `session.created` handler calls `ctx.sessions.create`, and
three separate docblocks quote it as the worst case they escaped. Raising the
default from 64: a no-op for the machine that reported this, which reads the env
var. Making it settable from the UI: *the daemon's config is env only*, and there
is no per-machine settings table or route to put it in.

**Status.** Active

### Q2.224 — A session nobody had touched in two days was still holding 476 MB

**Question.** What bounds the memory a machine spends on agents, and how long may
a conversation nobody is using keep one?

**Measured, 2026-09-09, the development machine (darwin 24.6.0, Apple silicon),
against the live daemon `i_b71a1ed2` and its own event log.** Nothing here had ever
been measured; there is no memory harness in this repository and never has been.

macOS `phys_footprint` — the honest figure, excluding shared framework pages and
including compressed memory. Five live sessions:

| Session | Idle for | Processes | footprint | peak |
|---|---:|---|---:|---:|
| `s_e3cc64` claude | 1.5 h | acp bridge 80 MB + `claude` 261 MB | 341 MB | 396 MB |
| `s_5d26f9` claude | 6.4 h | acp bridge 82 MB + `claude` 177 MB | 259 MB | 403 MB |
| `s_a55fb1` opencode | 48.7 h | one process | 476 MB | 760 MB |
| `s_1bf6a1` opencode | 48.7 h | one process | 447 MB | 674 MB |
| `s_1fa7f6` opencode | 48.7 h | one process | 461 MB | 736 MB |

**1 984 MB for five conversations, of which 1 384 MB — 70% — belonged to three
nobody had touched in 48.7 hours.** All three were `idle`: no turn, no unanswered
permission, no question. Sampled again twelve minutes later they had moved by under
2%, so an idle agent does not give the memory back on its own. The daemon itself was
105 MB and its plugin child 31 MB.

The other half, from the same log: `status:interrupted` → the next `agent_config` on
that session is exactly a spawn, an ACP `initialize`, a `session/resume` and the
config arriving. 120 real reattaches:

| Agent | n | p50 | p90 | min | max |
|---|---:|---:|---:|---:|---:|
| claude, resuming alone | 28 | **1 292 ms** | 2 376 ms | 487 ms | 5 619 ms |
| kimi, resuming alone | 21 | 2 806 ms | 2 861 ms | 713 ms | 12 945 ms |
| opencode (only ever in 3-way boot storms) | 44 | 3 298 ms | 26 929 ms | 1 442 ms | 52 843 ms |
| claude, in a 3-way boot storm | 27 | — | — | 541 ms | 90 182 ms |

A cold `session/new` is 695 ms mean for claude and 1 065 ms for opencode, so a
resume is about twice a fresh start and most of both is process startup. **The trade
is therefore ~397 MB against ~1.3 s.** The boot-storm rows are the second finding:
resumes contend badly, and three at once turned a 1.3 s reattach into 90 s.

**Root.** `MAX_LIVE_SESSIONS` was checked in exactly one place, `create()`. Nothing
ever released an agent, so the ceiling bounded how many conversations somebody could
*open* and never how many agents a machine ends up holding. Its own docblock had
conceded the point for a year — *"a machine running 64 agents at once has run out of
memory long before it runs out of slots"* — and then done nothing about it.

**Decision. Release the agent, keep the conversation.** A session idle for
`REEMOAT_IDLE_PARK_MINUTES` (30, `0` off, **on by default**) is stopped with a new
`ExitReason`, `parked`. The process goes; the transcript, the `agent_session_id`,
the worktree, the branch and the title do not. The next message starts an agent and
carries on over the same `session/resume` the daemon already performs after each of
its own restarts.

⚠ **That sentence was one case too wide, and the case is now guarded.** Work the
agent left running in the background dies with the process — the CLI kills what it
backgrounded when it is shut down, and a resumed agent is told nothing about it —
and `parkable` could not see it, because a session whose turn has ended reports
`idle` honestly. Measured on this machine: `s_5d26f98e` released 60m14s after its
last event with nothing moving the clock in between. A clause refuses over work
claude *reports*, and the rest of the fleet is still covered only by the margins.
Q2.228.

Almost none of this is new machinery, which is the argument for doing it this way.
`terminal` is `exitRecord !== null`, so a stopped session already holds no process,
is already skipped by `liveSessionCount`, already keeps its conversation and already
comes back through `resume()`. Parking is `stop("parked")` plus a rule about when it
returns.

1. **The preconditions are one comparison: `status === "idle"`.** (Three clauses
   have been added beside it since — a `/clear`, a queued message, and live
   background work, each a state `status` reports as an honest `idle`.) The three states
   parking must never interrupt — a turn in flight, an unanswered permission, an
   unanswered question — are exactly the ones the derivation reports as something
   else, along with the two where there is nothing to release yet or already. A
   hand-written list would fall out of step with `status` the day a fourth waiting
   state is added; this cannot. Plus `agentSessionId !== null`, or parking is a
   one-way door.
2. **`parked` is deliberately not in `DAEMON_EXIT_REASONS`.** That list means "the
   daemon owes this back **by itself**", and it drives the boot pass. Un-parking
   everything at the next restart would hand back all the memory at once, in the
   contention the measurement above found. So `autoResumable` answers `true` on a
   prompt and `false` at boot, and `markInterrupted`'s existing `if (this.exitRecord)
   return;` leaves a parked row parked across a restart with no new code.
3. **It derives its own `SessionStatus`, and the reason is a defect avoided rather
   than a preference.** `ManagedSession.status` reads `endedWithDaemon` and then
   falls through a `switch` whose `default:` answers `exited` — the word for a
   conversation somebody ended. A reason outside that list lands there silently and
   compiles clean, because unlike `autoResumable` this switch has a default. The
   client had the identical hazard one layer up: `statusTone`'s own `default:`
   answers `idle`, which would have drawn a live dot over a machine running nothing.
4. **The ceiling releases a slot rather than refusing**, which is what turns it
   into a memory budget — see Q2.100. Least recently active first, one slot, on
   **both** ends: a wake that would cross it, and a `create`. They differ only in
   the last resort: a wake goes one over on purpose, because somebody typing into a
   conversation they already have must never be told it is unavailable; a `create`
   refuses, because new load is exactly what a ceiling has to be able to say no to.

   ⚠ **The threshold does not apply at the ceiling, and that is a correction.**
   Both paths first measured candidates against the full 30 minutes, so six
   sessions that had finished five minutes ago blocked a seventh for the rest of
   the half-hour — and the refusal told the person to *stop* one, which ends the
   conversation and files it under Ended. The daemon was asking somebody to do
   destructively, by hand, what it could do losslessly one line earlier. **The
   sweep releases by age, because nobody asked; a ceiling releases by need, because
   somebody is asking for capacity now.** What stays untouchable is what must be —
   `parkCandidates` takes only sessions whose derived status is exactly `idle`, so
   a turn, an unanswered permission and an unanswered question are safe at any
   ceiling — and a machine whose owner set `REEMOAT_IDLE_PARK_MINUTES=0` is refused
   rather than having an agent taken anyway.
5. **A message is the only way back, and nothing on screen says so.** No Resume
   control is drawn — an active exclusion, since `canResume` is `isTerminal &&
   isResumable` and a parked session satisfies both. The composer is unconditional
   (Q7.103), so the affordance already exists; a button offering to undo
   housekeeping invites somebody to sit on a session list waking agents one at a
   time.
6. **It is drawn as an ordinary quiet session — same dot, no notice.** `statusTone`
   answers `idle` for it, explicitly rather than by fallthrough (the fallthrough
   answers `ended`), and `sessionNotice` returns `null` before the catch-all that
   would otherwise draw `exitText` in the shape of a conversation that ended.
   Whether a process is resident is not a fact about the conversation, and not
   something a reader can act on. What it is *not* drawn as is equally deliberate:
   not `ended`, because nobody ended it, and not counted in the machine's live
   count, because nothing is running. The dot answers what the *conversation* is
   doing; the count answers what the *machine* is doing, and parking is the case
   that separates them.

7. **Its controls stay live, and a tap is a choice rather than a wake.** (⚠ And
   its **command list** stays too, which this point missed and Q2.228 repaired: the
   same argument applies word for word, and withdrawing it left a parked session
   with a working model picker and an empty `/` menu under a composer reading *Type
   / for commands* — 111 commands live, 0 parked, measured.) Every
   other stop clears `agentConfigState`, which is right — the options describe a
   process that is gone. Parking keeps them, because the process is coming back to
   the same conversation, and clearing them made the strip fall to the client's own
   memory, where it is drawn faint and refuses a tap: a session you could re-model
   at 29 minutes and not at 31, with nothing on screen saying why, since parking
   deliberately shows nothing. So the options stay, `setConfigOption` and `setMode`
   **record** against them instead of sending, and `doResume` puts the choice to
   the fresh agent through `restoreConfig` — which sends only what differs and only
   what the returning agent still offers. The owner's rule, and both halves are
   load-bearing: recording without applying is a control that lies, and applying by
   waking would spend ~400 MB on a glance at a settings row and give the feature a
   second way back when its whole answer is *send a message*. In memory, so a
   daemon restart drops a pending choice and the strip reads faint again — the
   honest state for a daemon that no longer knows what that agent offered.
   Validation is the live path's, run against the remembered options, so a value no
   agent offers is refused without one rather than recorded and silently dropped at
   the wake.

**The trap, and it is the one worth remembering.** `isActiveRow` — the prune's
predicate — ended `reason === null || endedWithDaemon({ reason })`. Since `parked` is
deliberately outside that list, adding the reason made **every parked session
inactive**: swept by age, ranked under the cap. That is Q2.222's incident exactly,
re-aimed at the quietest and most-likely-to-be-returned-to conversations on the
machine. `events.ts` now exports `keepsItsConversation`, one member wider, and the
store reads that; `endedWithDaemon` is untouched, because the boot pass and the
client's warn tone still want the narrow question. Nothing in the type system would
have caught this — the function has no `switch` — and what did catch it is the
`Record` Q2.222 left behind in `daemoncheck`, which
refused to compile until the new reason was placed.

**What the wake actually costs, measured 2026-09-09, and why none of it is
recoverable.** Spawning the ACP bridge and completing `initialize` is **197 ms**,
timed directly against `claude-agent-acp` with no session opened. `session/new` on
top of that is ~500 ms — the `claude` CLI's own start — and `session/resume` ~1 100
ms, so re-reading the conversation is the remaining ~600 ms. Two thirds of the wake
is inside the agent's own binary.

⚠ **The transcript this daemon holds cannot shorten any of it, and the protocol is
where that is settled rather than the implementation.** `ResumeSessionRequest` is
`{sessionId, cwd, …}` and `LoadSessionRequest` is `{mcpServers, cwd, …}` — neither
carries history, so there is no verb for handing an agent a conversation. What
`sessions.events` holds is what the *browser* draws; the agent's context has to come
from the agent's own file, which for claude is `~/.claude/projects//.jsonl`
(Q2.1). The daemon's resume *is* the CLI's own: the live fleet shows
`claude … --resume=` on the command line.

**And that is also the answer to "what do claude and codex do about this".** They
have no resident process at all — a CLI session lives exactly as long as its
terminal, and coming back is `--resume=` re-reading the transcript, at the
same ~1.3 s. **The daemon was the deviation**, holding processes indefinitely
because it had no notion of the person having closed the terminal. Parking is what
makes it behave like the tools it wraps.

**Alternatives taken out.**

- *A warm spare process.* It cannot help with the expensive part: the CLI is bound
  to a conversation at spawn (`--resume=` is a launch argument), so a spare
  cannot be pre-pointed at one. Only the *bridge* could be kept warm, saving the
  197 ms — about 15% of the wake — for a resident process per spare. Paying memory
  to buy back latency is the trade this whole entry exists to make in the other
  direction.
- *A mark and a sentence of its own.* Built first: a dashed dot, a screen-reader
  label, and a quiet notice saying the agent had been released and a message would
  bring it back. All of it removed. The person cannot act on any of it, the wait it
  would have explained is already covered by the composer's spinner, and a state
  with a name is a state somebody has to interpret. See point 6.
- *Adding `parked` to `DAEMON_EXIT_REASONS`.* It fixes the prune in one word, and
  then the boot pass un-parks everything and clients draw the warn tone
  `interrupted` carries. Two wrong answers to buy one right one.
- *Refusing a wake at the ceiling, the way `create` used to.* It is the same 429
  and a completely different sentence: one says "you have too many sessions open",
  the other says "the conversation you are typing into is unavailable". Releasing a
  slot is refusing something nobody is watching instead.
- *Keeping the full idle threshold at the ceiling.* Argued for a round, on the
  ground that a machine with nothing idle for half an hour is genuinely that busy.
  It was wrong about who is asking: at a ceiling somebody is asking for capacity
  now, and what would be taken is an agent doing nothing. Q2.223's complaint
  survived the whole of Q2.224 because of it.
- *Waking on opening a session.* Asked for, and declined by the owner: it hides the
  1.3 s behind reading the transcript, and it means tapping through five rows spawns
  five agents. Typing is the signal that somebody actually wants the agent.
- *Scaling the sweep interval to the threshold.* Somebody who sets five minutes
  would silently get a coarser sweep than somebody who sets an hour. A minute of
  slack on any threshold is noise; the interval is a constant.
- *A `resume_gave_up` precondition as a live gate.* It is written, and it cannot
  fire: `onResumed` clears the verdict on the resume that succeeded, so a session
  live enough to be idle has just proved it can be brought back. Established by a
  driver fixture built to exercise it, which parked anyway. Kept as a statement of
  intent, with the reason recorded at the site and both halves pinned.

**Driven.** `daemoncheck.restart-and-resume.ts`: the `autoResumable` row on both
triggers; `statusOf("parked")` against `"parked"` *and* against `"exited"`, since
the failure is two states collapsing into one; a real open turn the sweep must not
take, against a rig that never answers the prompt; the four facts "released" means;
a restart that finds it still parked and a boot pass that leaves it alone; a wake
through the real HTTP route, on the same `agentSessionId`; eviction at the ceiling,
least-recently-used first, from both ends: a wake taking a slot and proceeding
anyway when there is none, and a `create` taking one — losslessly, the session
ending up `parked` rather than ended — and still refusing before it touches the
filesystem when every live session is mid-turn; and the schedule itself through an
injected clock, including that it re-arms.
`daemoncheck.store-and-worktrees.ts`: `parked` placed as `"kept"`, driven under both
sweeps. And the deferred controls, against a rig that publishes an option and
accepts `session/set_config_option`: that a released session still offers it, that
choosing is accepted, that the choice is on the session at once and **no process
was started for it**, that a value the agent does not offer is still refused, that
the wake sends exactly what was chosen — and the negative that keeps the arm
narrow, a session somebody *stopped* keeping no controls and answering `terminal`.
⚠ The rig was written in this daemon's own `kind`/`value`/`choices` shape rather
than ACP's `type`/`currentValue`/`options`, so `toConfigOptions` produced an option
with no kind and no choices, both validation guards were skipped, and a model no
agent offers was accepted and then sent. The driver caught it; nothing else would
have. `webcheck`: the three-way exit-reason partition with `parked` its own part;
that `endedWithDaemon` is *not* exact about it and `isParked` is what runs first;
the four-way presentation partition over the whole matrix; its own tone, asserted as
distinct from `ended`, `idle` and `waiting`; its dot read off disk and asserted
different from the ended one, because a `Record` cannot catch a copied string; that
it lands in Active and not in the machine's live count; and the absence of the
Resume control. Every one was proven red under a one-line mutation.

**Status.** Fixed, 2026-09-09.

### Q2.225 — The first daemon setting with a control on a screen

**Question.** How long a conversation keeps its agent (Q2.224) is one person's
trade between memory on their own machine and a ~1.3 s wait. It shipped as
`REEMOAT_IDLE_PARK_MINUTES` — an env var, edited over SSH. Should it be on the
settings screen, and if so what happens to *"the daemon's config is env only"*?

**Decision.** It gets a control, and the rule does not move. `REEMOAT_*` is still
read only in `scripts/daemon.ts`, nothing in `src/` touches `process.env`, and an
operator provisioning a fleet still writes an env file. What is new is a **narrower
class** with a different owner: settings whose subject is the person *using* the
machine rather than the one who deployed it. `agent_strip` — which agents the New
session row offers, and in what order — has been in that class since it was built
and only escaped the question because one preference needed no general home.

- **`machine_settings`, a key/value table**, and a new *table* so `SCHEMA_VERSION`
  stays 6 for the reason `uploads` and `plugins` already give. Keys are enumerated
  in `MACHINE_SETTING_KEYS`, a `Record<…, true>` exhaustive in both directions, so
  a row this build cannot name is left alone rather than half-understood — the
  stance `isExitReason` takes on a different column. A column per setting would
  have made that a migration.
- **`GET /settings` and `PATCH /settings`.** `PATCH` and not `PUT`, which is the
  opposite call from `/agent-strip` one route above: that body is a whole list and
  replacing it wholesale is the only coherent write, while this is a table of
  independent settings where a client sending the keys it happens to know would
  silently reset the ones it does not. An older client must be able to write this.
- **A stored value overrides the env var**, which is therefore the default for a
  machine nobody has set rather than a policy anything has to explain itself
  against. `0` is a stored answer of "never".
- **Applied to the running daemon before the route answers**, not at the next
  restart, so the value a caller reads back is one already in force.
- **A store-less daemon still answers `GET` and refuses `PATCH`.** The number is a
  fact about the machine either way; what a person cannot do is change it, and a
  `503` says so where an empty form would say nothing.

⚠ **The provenance line was built, shipped into review and cut, and the reasoning
that put it there is worth keeping because it looked correct.** The screen first
drew a line under the field saying whether the number was set here or came from
this machine's configuration, with a link back to the latter — on this
repository's own rule that a control must not show a value a configuration has
silently answered, which is why the agent settings screen reads
`~/.claude/settings.json` and says so. The owner cut it on sight, and the rule was
being misapplied: it is about an operator and a user disagreeing, and here there is
no operator. This is one person's machine and one person's preference, so the line
was a sentence about env files on a screen that mentions none. What went with it is
the whole apparatus it justified — `source` on the wire, `null` on the route,
`clear` on the store — because the only thing any of them could express was "go back
to the configuration", and with no line there was no reader. An unread field is the
state this repository deletes rather than keeps. The way back to the default is
typing the number.

**The copy, and it is a decision rather than a detail.** One sentence — *"A
conversation left untouched this long has its agent shut down to free memory, and
your next message starts it again exactly where you left off"* — and the word
**park** appears nowhere a reader can see it. That is the owner's call and it is
right: the daemon calls this state `parked`, every docblock does, and that is
exactly how the word leaks onto a screen — it is the name everybody working on it
uses. On a settings row it would name a mechanism the reader cannot see and cannot
act on, turning housekeeping into a term to learn. `webcheck` asserts the
prohibition over the whole comment-stripped screen rather than over the sentence,
because read off the sentence it passes vacuously the moment somebody rewords it —
which is precisely when they are most likely to reach for the word. The other half
is asserted too: the sentence must still say that the agent is shut down and that
nothing is lost, or "no jargon" is satisfied by saying nothing.

**Alternatives taken out.**

- *Leaving it env-only, per Q2.223.* That entry refused a UI for
  `REEMOAT_MAX_LIVE_SESSIONS` on this exact rule, and the refusal stands **for that
  setting**: a ceiling on concurrent agents is a machine's operator's business.
  This one is not — it is a latency preference, and the person meeting the latency
  is the person holding the phone.
- *A column on the singleton `daemon` table.* That table is the running instance —
  `instance_id`, `pid`, `started_at` — and is rewritten at every boot. A persisted
  preference in it would be a preference that looks like state.
- *A general settings framework.* One key today, and the table is the general part;
  what is deliberately absent is a schema, a types-per-key registry and a
  migration story for a surface with one member.

**Driven.** `daemoncheck.restart-and-resume.ts`: the configuration in force with
nothing stored; a save that answers with what is now in force **and is already
being used by the running daemon**, which is the half a caller cannot check for
itself; a session released on the saved five minutes that the configured
forty-five would have kept; `0` surviving the round trip as a choice rather than
being read as unset, and `null` no longer being a way to ask for the configuration
back; an unknown key, a negative, a fraction and a value past the ceiling all
refused without changing what is stored;
and a store-less daemon answering `GET` and refusing `PATCH`. `webcheck`: the
sentence, the prohibition over the whole screen, and that no heading on it names
the mechanism either.

**Status.** Current, amended by Q3.686: the sentence is the idle row's subline, "Shut
down, then resumed where you left off.", and the setting is a pick of nine presets
rather than a typed number.

### Q2.226 — Can somebody correct the agent without stopping it?

**Question.** Until now, no. `ManagedSession.prompt` answered `busy` while
`this.turn !== null`, the route turned that into `409 turn_in_flight`, and the
composer refused Send — so the only way to change course mid-run was to press
Stop, which ends the turn and throws away whatever it had half-done. The ask was
the behaviour Claude Code has: type the correction, and it is taken.

**Rule.** A message sent while a turn is running is **accepted**, and how it
reaches the agent is the agent's own answer:

- Where the agent advertises `_meta.steering.supported` on `initialize`, the
  daemon sends the ACP extension `_session/steering` and the message goes **into
  the turn already running**. claude and codex both do.
- Where it does not, `ManagedSession` **holds** the message and delivers it the
  instant the turn ends. kimi does not, and opencode is unmeasured.

Both answer `202` carrying the same `seq`, distinguished by `steered` / `queued`
so a client knows what to say and never whether the send worked.

**Why not a second `session/prompt`.** Because it means two different things on
two agents and one of them is destructive. Measured 2026-09-11 by reading the
pinned adapters: claude-agent-acp 0.73.0 queues a second prompt FIFO
(`turnQueue`, and it advertises `agentCapabilities._meta.claudeCode.promptQueueing`),
while codex-acp 1.8.0 keeps **one** `activePrompt` per session and every
continuation of the first checks `this.activePrompts.get(sessionId) !== activePrompt`
and bails — a supersede that silently abandons a live turn. `Session.turnActive`
and `ManagedSession.turn` both assume one turn either way.

**Why the queue is the daemon's.** `attach.ts` had already named the two failure
modes against building one — *"a session that ends, a tab that closes"* — and both
are failure modes of a **client-side** hold, which is what had been built and
withdrawn twice before (most recently the plan card's, Q3.454). Neither exists
here: a closed tab is not holding anything, and a session that ends drops the
queue and writes an `error` event saying so.

**Where it is delivered, and why that is the last statement in the block.**
`deliverQueued` runs at the very end of `pump`'s `finally`. `sweepPending` in that
same block is **not** fenced on turn identity, so a turn started any earlier would
have its own freshly-raised permissions cancelled by the previous turn's sweep.
`whenRestarted` and `clearContext` call it too, because a restart and a clear are
two of the three states it declines in and something has to pick the queue back up
on the way out.

**One rule for the log.** A `prompt` event is appended when the daemon **accepts**
a message — which is what that event's own definition has always said — in all
three landings, and never a second time on delivery. That is what gives the client
a `seq` to settle its echo against, puts the bubble where the message was written,
and makes the queue a fact about *delivery* rather than about the conversation.
Whether a taken message is still waiting rides the **snapshot**
(`queuedPrompts`), which is `cancelRequestedAt`'s arrangement from Q2.42 and is
here for the identical reason.

**In memory, and the cost is written down rather than hidden.** A daemon restart
drops the queue — the same standing the interrupted turn itself has (Q2.12) — and
what survives is the transcript showing the message with no answer under it. A
**stop** is the case that can be reported, and is: `doStop` writes one `error`
event counting what never arrived, because each queued message is already a
`prompt` with nothing after it, which is precisely the shape Q2.218 named as a
message that reached no model.

**Two guards that are not obvious.** `parkable` answers `false` with a non-empty
queue: `status` reads an honest `idle` between the turn ending and the queue
draining, and `releaseOneSlot` asks with an `idleMs` of `0`, so the ceiling could
otherwise take the agent away from a message the daemon had already accepted,
recorded and spent the uploads for — the eighth caller of the "one process
boundary at a time" rule the `clearing` marker states. And the queue bound
(`MAX_QUEUED_PROMPTS`, 8) is checked **before** anything is appended, because on
the steer path the queue is only reached once the steer has failed.

**Rejected.** Cancel-then-prompt for the agents that cannot be steered — what
Q3.454's plan card does. It gives an immediate pickup on all four, and it throws
away the in-flight turn, which is the one thing this feature exists not to do.

**A cancel delivers what is waiting**, and that is decided rather than incidental:
it makes *type the correction, press Stop* one gesture that steers a queueing
agent — the same act Q3.454 performs from the other direction. There is
deliberately no way to take a queued message back (no ✕, matching Claude Code), so
Stop cannot also mean "and forget what I said": dropping it there would lose typed
text in silence, which is the failure this codebase does not ship.

⚠ **Three defects found in review, all of them about *when* a guard was taken, and
recorded because each was invisible to the drivers that existed:**

- **An agent restart is not a stop.** `restartAgent` reaches its boundary through
  `stop("config_changed")`, so `doStop`'s drop threw the message away and wrote
  *"the session stopped"* into a session that had not stopped — reached from
  `onAgentUnusable`, i.e. an agent dying mid-turn, which is the likeliest turn to
  hold a correction. The queue survives the three reasons `autoResumable` calls
  the daemon's own doing.
- **A stop landing inside the steer walked past every guard**, because all of them
  are taken before `sendMidTurn`'s two awaits. It answered `202 {queued: true}`
  for a terminal session, said nothing (the drop had run while the queue was still
  empty), and left an entry on every snapshot of a dead session for ever.
- **`steered` could answer `turn: null` against a declared `number`** — TypeScript
  keeps a property narrowing across an `await`, so `this.turn` read as `number`
  while `pump`'s `finally` had cleared it. The turn is captured before the awaits
  now, which is also the honest value: it names the turn the agent injected into.

**And two assertions that were green over nothing**, found by mutation: the
`parkable` queue clause is refused at `status !== "idle"` long before it is
reached — it is unreachable as the code stands and kept as a refusal, beside
`resumeGivenUp`, which is in the same position — and `idleBehavior:
"promptRequired"` was asserted nowhere at all, so deleting the whole opt-in left
every check passing.

**Status.** Current

### Q2.227 — What splitting `busy` cost, and why the code stayed

**Question.** `ManagedSession.prompt` refused on one expression —
`this.turn !== null || this.clearing || this.restarting` — answering a single
`busy`. Taking a mid-turn message means one of those three stops being a refusal.
Is that a new arm or a new method?

**Decision.** Both, and the split is along the line the old expression blurred. A
`/clear` or a restart is the agent being **unaddressable**; a turn is the agent
**working**, which is the ordinary state somebody types a correction in. So
`prompt` keeps `busy` for the first two — with every assertion it had, including
`daemoncheck`'s that a prompt beside an in-flight clear still answers
`409 turn_in_flight` over HTTP — and answers a new `turn_in_flight` arm for the
third, which appends nothing and tells the route to go elsewhere.

**Why a second method rather than a branch.** `prompt` is synchronous by contract
and the contract is load-bearing: it answers a 202 carrying `{turn, seq}`, it calls
`safeAppend`, and its guard is an assignment made before any await. `Session.steer`
is an RPC. An await inside `prompt` would put a suspension point in front of the
one assignment every other guard in the class depends on. `sendMidTurn` is the
async half, and the route calls it only on the arm that says to.

**The precedent it follows.** `POST /sessions/:id/prompt` already waits where
`ManagedSession` refuses — `await managed.whenRestarted()`, with `daemoncheck`
pinning both halves: *"the session itself still refuses a prompt mid-restart"* and
*"but the route waits and sends it"*. This is the same division one state over.

**Status.** Current


### Q2.228 — Parking is advertised as lossless. Is it?

**Question.** Q2.224 shipped parking on the claim that *"the process goes; the
transcript, the `agent_session_id`, the worktree, the branch and the title do
not"*, and `.env.example` says the same thing in four words: *"the process is
killed; nothing else is"*. Somebody asked whether that survives an agent with
background work running. It does not.

**Measured 2026-09-11, the development machine, against `~/.reemoat/reemoat.db`
and the installed binaries.**

*The hazard is real and has already fired.* Five sessions carry `exit.reason =
"parked"`. One of them is `s_5d26f98e` — Q7.113's own measured session — released
at 17:34:25, **60 m 14 s** after its last event, against a saved
`machine_settings.idleReleaseMinutes` of 60. Nothing moved the idle clock in that
hour.

*Why `parkable` could not see it.* `status === "idle"` is honest for a session
whose turn has ended, and Q2.44's own rule is that *the turn ending is not the
agent stopping*. Background work is that rule's sharpest case: the daemon holds
`running`, `blocked`, `starting`, `stopping` and the terminal states, and there
has never been a state for *the agent handed something off*.

*What a park does to that work, traced link by link.* `stop("parked")` →
`doStop` → `Session.dispose` sends `session/cancel`, then `session/close`, then
`AcpClient.close`: stdin EOF, 3 s, **SIGTERM to the process group**, 3 s,
**SIGKILL to the process group**. Every Bash tool call the CLI makes is spawned
`detached: true`, i.e. `setsid()` — confirmed live, a background `sleep` ran at
`pgid == pid`, state `Ss`, parented to the CLI — so the daemon's group kill does
not reach it directly. The CLI kills them itself: its `SIGTERM` handler runs the
shutdown sweep, which group-SIGKILLs every backgrounded shell and records each as
`"stopped"` with its `toolUseId`. That record goes nowhere this daemon reads. On
the SIGKILL rung, and whenever the adapter dies outright, the shells instead
**survive as orphans** reparented to launchd, still writing to
`/private/tmp/claude-///tasks/.output`, with
nothing able to report or stop them.

*And nothing comes back.* `session/resume` replays no task state; the Agent SDK
states outright that the background-task set is per-process and that nothing is
emitted at startup; the adapter's `replaySessionHistory` has a subagent replay
pass and no async-task equivalent, and its only teardown is a bare `tasks.clear()`
that emits no terminal update. The one durable registry —
`~/.claude/jobs//adopt.json` — is gated on `CLAUDE_JOB_DIR`, which this
daemon does not set: `~/.claude/jobs` does not exist on this machine.

*Two partial mitigations already existed and neither is enough.*
`CEILING_PARK_FLOOR_MS` is two minutes on the **ceiling path only**, and its own
docblock concedes the gap — *"two minutes is not a judgement about background
work; nothing here can make one"*. And out-of-turn `agent_log`/`other` events move
`lastEventAt` without being logged, which landed 2026-09-10 inside a commit titled
*"legal docs added"* with no entry here at all. The 60-minute park above is the
measurement that it does not fire for a quiet agent.

**Driven against a real claude, 2026-09-11** — `pnpm harness --agent claude --raw`
under 2.1.268 / claude-agent-acp 0.73.0, three runs. What the wire actually does,
rather than what the adapter's source suggested:

- *The spawn is first and it is thin.* `async_task_spawned` carries `taskType:
  "shell"`, `showInTranscript: false`, `canStop: true`, and `name` equal to
  `description` — and **neither `outputFilePath` nor `toolCallId`**. Both arrive on
  separate `async_task_progress` updates a moment later, so a fold that merged only
  on the spawn would carry neither, ever.
- *The spawn precedes the lying card.* It lands **before** the Bash
  `tool_call_update` that reports `status: "completed"`, and that update carries
  `_meta.jetbrains.air = {version: 1, asyncTasks: {backgrounded: true}}` beside
  `claudeCode.toolName: "Bash"` — the marker read as `ToolCallUpdateEvent.
  backgrounded`, confirmed against real bytes rather than against the adapter's
  `dist/`.
- *There is no heartbeat.* Both progress updates are correlation and arrive at
  once; a `sleep 45` produced nothing further for its whole life. **That is the
  measurement the staleness bound was waiting on, and it settles it against:** a
  quiet task is indistinguishable from a stopped one by time alone, so a bound on
  last-heard-from would kill quiet work and spare chatty work.
- *The end arrives during the turn, as two updates.* Backgrounding a `sleep 5`
  inside a turn held open by a foreground `sleep 30`: `state: "stopped"` first —
  the adapter closing a task it stopped seeing in the CLI's level — then `state:
  "completed"` behind it. So **a terminal state is not final**, and a fold that
  refused a second terminal word would label every finished shell `(stopped)`,
  which reads as *somebody stopped it* about a build that succeeded.
- *The output path is what containment refuses.*
  `/private/tmp/claude-///tasks/.output` —
  outside the workspace, which is why there is no detail view to build and why the
  panel's footer says so rather than leaving an absence unexplained.

**What changed underneath Q7.113.** That entry closed on one sentence: *"what
would close it is an ACP message for 'I am waiting' … neither exists, and neither
is ours to add."* The first of the two now exists, in the **pinned** adapter
(`claude-agent-acp` 0.73.0), and is one client capability away — three
`session/update` variants (`async_task_spawned`, `async_task_progress`,
`async_task_state_update`), an `AsyncTaskState` of five words with three of them
terminal, a stop verb `_session/async_task/stop`, and a marker on the detaching
Bash `tool_call_update` saying its card is not finished. All of it gated on
`clientCapabilities._meta.jetbrains.air = {version: 1, capabilities: ["asyncTasks"]}`,
which this client did not send.

**Decision. Declare the capability, refuse to park over live work, and draw it.**

1. **A live task blocks parking, with no timeout.** One clause in `parkable`,
   placed with the two other clauses `status` cannot express — `clearing ||
   restarting` and a non-empty prompt queue — because all three are *a session
   that owes somebody something while `status` honestly reads `idle`*. The
   ceiling inherits it through `parkCandidates` with nothing threaded, so at
   `MAX_LIVE_SESSIONS` with nothing takeable `create` answers `429
   too_many_sessions`: a session somebody does not get, so that a build somebody
   is running survives.
2. **Claude only, and not even all of claude.** kimi backgrounds fully and maps
   `background.task.terminated` nowhere, codex's `unified_exec` PTY outlives its
   call with no push, opencode cannot background at all — and within claude the
   adapter marks a backgrounded **subagent** `ignored`, which is Q7.113's own
   measured case. So `CEILING_PARK_FLOOR_MS` and the stderr clock stay: they are
   what stands for everything the clause cannot see, which is most of the fleet.
   A margin bought for a case that is still real must not be spent because a
   neighbour got a signal.
3. **The set rides the snapshot and is never logged.** `applyContextUsage`'s rule,
   reached by a sharper road: everything that hits `record` moves `lastEventAt`,
   so a logged lifecycle would defer the sweep **by accident** for a task that
   chatters and not at all for a `sleep 600` — the guard would be strongest
   exactly where it is least needed. It is fanned out with
   `reportsBackgroundTasks` beside it, because an empty list from kimi means
   *nobody asked* and an empty list from claude means *nothing is running*, and a
   count has no spare value to say which.
4. **A stop clears the set and says so once.** `contextUsageState = null`'s
   argument at its strongest: a parked session keeping the map would claim
   `running` about process groups that are gone, on every snapshot, for ever —
   verbatim the failure Q7.113 refused to ship a feature over. The sentence is
   `dropQueuedUndelivered`'s precedent, one row for one act, and it says the work
   was *still running* rather than that it was killed, because the SIGKILL rung
   leaves orphans and this daemon watched neither.

**⚠ The blocker the plan did not predict, and it is worth the paragraph.** The
published SDK refuses these notifications, and no hook reaches the refusal.
`zSessionUpdate` is a closed `z.union` in both 1.3.0 and 1.4.0 with no arm for any
of the three, and **two** places parse it: `registerAppNotification`, which a
custom parser can replace, and `ClientApp`'s constructor, which installs a
`SessionUpdateRouter` parsing every `session/update` unconditionally. The second
is not reachable from any option — a client built with `acp.client()` cannot
receive one of these however it registers its handler, and the notification is
rejected `-32602` and logged below anything this daemon can see.
`AcpClient.splitAsyncTaskUpdates` takes those three off the byte stream before the
SDK reads it, and forwards everything else untouched so that every known variant
keeps the validation it has today. The hot path is **one `indexOf` per line** —
`usage_update` arrives on essentially every output token, so a `JSON.parse` per
line would have been a real cost. Found by declaring the capability and driving a
real notification through the driver's rig; reading the schema first would have
been the cheaper order, and that is the lesson rather than the fix.

**⚠ Two things gave a parked session away, and both were found by looking at the
screen rather than at a driver.** Q2.224 decided parking shows *nothing*, and:

- *The `/` menu was empty.* `doStop` withdraws the command list on every stop, and
  point 7 of that entry had kept `agentConfigState` for a reason that applies to the
  command list word for word — the process is coming back to the same conversation.
  So the model picker worked and `/` opened nothing, under a composer whose
  placeholder is literally `Type / for commands`. Measured on a real daemon: 111
  commands live, **0** parked. It is kept now, with no revision bump, because the
  list did not change; the resume replaces it through `applyAgentCommands` in the
  ordinary way.
- *This feature's own new row.* The `error` a stop writes when it ends live
  background work said *"when this session ended"* — and `doStop` is reached by
  `daemon_shutdown` and by `restartAgent`'s `config_changed`, so a deploy or an
  ultracode toggle wrote that into a conversation that had not ended. The noun is
  the agent now, which is true in every arm, and a **park writes nothing at all**:
  it should be unreachable (`parkable` refuses over live work), and the one way to
  reach it is a guard that failed — which must not announce itself in somebody's
  transcript.

**Alternatives taken out.**

- *A staleness bound on each task's own last update.* The elegant answer, and
  unsafe on an unmeasured premise: if a quiet task emits no `async_task_progress`
  the bound kills quiet work and spares chatty work, which is this entry's own
  defect inverted. It becomes available the day the progress rate is measured.
- *A hard cap on how long background work may defer parking (~6 h).* Argued for,
  and the argument is real — `MAX_LIVE_SESSIONS` only became a memory budget
  because `idlepark.ts` bounds the population from the other end, so an unbounded
  clause un-bounds it again at ~397 MB per pinned session. Declined on the owner's
  word, because a cap with no measurement behind it is a number dressed as a
  judgement and the asymmetry runs the other way: too large costs memory, too
  small costs work. The seam is one constant beside `CEILING_PARK_FLOOR_MS`.
- *A manual lever — a pinned session never parks.* Works for all four agents and
  needs no signal, and was declined because parking is deliberately invisible
  (Q2.224 point 6) and a control that makes it visible is a decision about
  housekeeping somebody cannot otherwise influence.
- *Declaring `nativeSubagentSessions` for symmetry.* It sets the adapter's
  `forwardSubagentText`, which Q6.4 refused on budget — and a permission raised
  inside a subagent is then addressed to the **child** session id, which
  `AcpClient.route` answers `invalidParams` for. That is a subagent's approval
  dying on the floor.
- *Reading the CLI's own task files.* Q7.113 rejected it and the rejection
  stands: vendor private state at an undocumented path, and the path is outside
  the workspace, which containment refuses anyway.

**Driven.** `daemoncheck.restart-and-resume.ts`: that the `_meta` this daemon
sends is exactly the AIR object, which is the one row with no second signal
behind it; the negative first, a session with nothing running still released
beside one that is not; a session with live work not released *at any age*; that
the refusal is the task rather than the fixture, by completing it and sweeping
again; each terminal word releasing and `paused` not; the ceiling refusing a
`create` rather than taking the agent, with the floor faked away so the refusal is
the task; the stop route's three answers with `stopped: false` a success; an id
the session never announced refused before the agent is asked; and that a stop
says what it was still running, once, and then claims nothing. Seven of them go
red under a one-line mutation of the clause. `webcheck`: the foot line over both
sources including the mixed-kind fallback and that a finished task is not counted;
the chip table as a total mapping over all five states with the three terminal
ones three distinct tones; the sections as a partition, an unknown kind included;
and the `backgrounded` fold surviving the completing update that follows it.
`pincheck`: the installed adapter's own `clientSupportsAirCapability` accepting
the literal this daemon sends, plus the negative that says the gate is a gate —
because `AIR_EXTENSION_VERSION` is module-private and there is nothing to compare
constants against.

**Status.** Fixed, 2026-09-11.

### Q2.229 — Parking is invisible for thirty minutes, and then a deploy gives it away

**Question.** Q2.224 shipped parking on the promise that a released session *"draws
as an ordinary `idle` session and says nothing"*, and `doStop` keeps
`agentConfigState` and `agentCommandsState` for `parked` alone so the controls and
the `/` menu stay live. Both are true of the process that parked it. Neither is true
of the next one: those two fields are in memory, `ManagedSession` deliberately
restores no config from disk, and a parked row therefore comes back with nothing.

**Measured, 2026-09-19**, on the development machine, read-only against the running
daemon. Five parked rows, two claude and three opencode. Every one of them answered
`GET /sessions/:id/commands` with `revision 0, dropped 0, count 0`, and carried
`agentConfig.options: []` on its snapshot. A freshly created claude session on the
same daemon answered `revision 1, dropped 0, count 111` with `context` among them,
four options — `mode`, `model`, `effort`, `fast` — and five modes including
`bypassPermissions`. So the two states are not a matter of degree: a parked session
is one deploy away from a composer with three `—` chips under *"The agent is not
offering this control at the moment"* and a `/` menu with nothing in it, and it
stays there, because nothing publishes again until somebody types. The prod hosts
update at 04:00 and 05:00 UTC, which makes that the state of every overnight
conversation, fleet-wide, every morning.

It also answers a question that was filed separately: **`/context` was reported as
"not published as a command"**, and it is published. The 111-command list has it,
and driving that list through the client's own `buildCommands`/`filterCommands`
ranks it **first** for the query `context` and shows it for `c`, `co`, `con`. What
the reporter had was a session whose agent was away. One defect, two symptoms.

**Decision.** Keep them, on a wider gate, and write them down.

`revivableByPrompt` — `autoResumable`'s `prompt` column, asked one step earlier
because `doStop` has the reason before there is an `exitRecord` to read it from — is
the single gate on three things now: what `doStop` keeps, what `configIsDeferred`
accepts a tap on, and what `persistedRow` writes to the new nullable
`sessions.agent_state_json`. **A call into that function and never a second
`switch`**, which is what keeps *"adding an `ExitReason` is a compile error"* true in
one place; `daemoncheck` asserts set equality between the two over the whole union
rather than listing the members, so a new reason lands in the sweep with nobody
adding a line.

That widens the keeping from `parked` to `parked`, `stopped`, `agent_exited`,
`agent_signed_out`, `daemon_shutdown`, `daemon_restarted` and `config_changed`, and
leaves `start_failed`, `start_timeout` and `agent_kill_failed` clearing as before —
where `—` is the honest reading, because there was never a conversation for a
control to be about. The argument was never about the word: a conversation that
comes back on a message is one whose controls still describe something.

**What makes a stale copy honest is that nothing in it reaches an agent unchecked.**
A wake replays it through `Session.restoreConfig`, whose two withdrawal guards skip
any option or mode the returning agent no longer offers — which is precisely the
risk `agentConfigState`'s docblock named as the reason not to restore from disk,
answered rather than accepted. The worst case is a control that takes a tap and then
quietly does not come back, which is the bound parking already had within one
daemon life.

**Four things that are easy to get wrong here, and what each cost.**

  - **`commandsRevisionValue` seeds to 1, not 0.** `commandsPlan` in
    `packages/web/src/store.ts` reads `0` or `undefined` as *"this daemon has
    nothing"* and drops the fetch, so a restored list left at 0 would sit on the
    daemon with the `/` menu still empty — the exact symptom, with the fix in place.
  - **The raw `agentConfigState`, never `snapshot().agentConfig`.** That one is
    composed through `withUltracode`, which rewrites the effort value to a choice no
    agent ever published, and `dedupeAliasChoices`, which moves the model selection
    off its placeholder. Replaying either sends the agent something it never said —
    the same ⚠ `restartAgent` already carries about its own capture.
  - **Refused whole past `MAX_AGENT_STATE_BYTES`, never clipped.** A clipped choice
    list leaves `setConfigOption` validating against fewer rows than the agent
    published, so a value somebody really can choose answers `invalid_value`: a
    control that lies rather than one that is absent. 64 KiB, twice the measured
    worst case — opencode's 362 models reduce to ~33 KB once the prose is dropped
    from every unselected choice — and doubled resident rather than merely on disk,
    because the blob rides `SqliteSessionStore.put`'s dirty-check key.
  - **The `busy` guard moved above the deferred arm.** `restartAgent` reaches its
    process boundary through `stop("config_changed")`, a reason a prompt revives, so
    from the moment it stops `configIsDeferred` answers `true`. Recorded there, a
    choice would be written into `agentConfigState` and then silently overwritten by
    the `restoreConfig` already putting back a snapshot captured before the tap:
    200, the chip moves, nothing happens. `clearing || restarting` is tested first
    in both `setConfigOption` and `setMode` now, because in both windows the config
    is about to be replaced wholesale and there is nothing a recording could be
    recorded against.

**What the client changed: nothing.** `drawnControls`' first branch is
`live.length > 0` and it returns `stale: false`, so a session arriving with options
is already drawn live and tappable whatever its status — which is why the daemon's
two sets were made one set rather than a flag being added to the wire. That property
was asserted nowhere: every `stale` check in `webcheck` drives the *empty* branch,
where the status is exactly what decides, so re-narrowing the first branch to
`hasLiveAgent` would have left all of them green while the composer went faint on
four statuses at once. It is pinned by name now.

⚠ **One documented invariant is reversed and one window closes.**
`agentConfigState`'s *"deliberately not restored from disk"* now carries an
exception, stated at the field; and `snapshotConfigSource`'s *"the empty window is
left empty on purpose"* describes a window that no longer opens, since the restart
keeps its controls throughout. The gate below it stays: `armForStart` empties the
field on the way *in* to a spawn, so *"no live agent yet: report that rather than a
memory"* has more than one way of being true.

⚠ **That last sentence was wrong, and it was the gate's only surviving
justification.** `armForStart` assigns `exitRecord`, `parkedAtSignOut`,
`stopRequested`, `stopping`, `startAbandoned`, `startPromise` and `session` —
seven fields, and `agentConfigState` is not one of them. What the gate is worth is
narrower and still real: `held` is the *restart*'s captured config, and serving it
over a set `doStop` really did empty would report a memory where the rule is "no
live agent yet". `doStop` empties it for the three `revivableByPrompt` refuses, so
the clause still has a state to answer for. Corrected at the docblock 2026-09-19,
in the review that also found Q2.230.

**Status.** Fixed, 2026-09-19.

### Q2.230 — A wake now replays a mode, and one of the two callers was nobody

**Question.** Q2.229 made `doStop` keep `agentConfigState` for every stop a message
undoes and wrote it to `agent_state_json`, so `doResume`'s `wantedConfig` — empty
on all but a parked wake before — is now a real option list on essentially every
resume, and `Session.restoreConfig` replays it. Two things follow that nobody
decided: a tap landing *inside* that replay, and the boot pass running it with
nobody present.

**What was measured.** Both reproduced, 2026-09-19.

- **The tap.** `restoreConfig` runs *after* `onStarted` has published, so a
  `setConfigOption` or `setMode` arriving in that window finds `exitRecord` cleared
  by `armForStart` (hence `configIsDeferred` false), a live `session`, and neither
  `clearing` nor `restarting` — a wake sets neither. It reaches the agent, answers
  `ok`, and is then overwritten by the snapshot captured before it: 200, the chip
  moves, nothing happens. That is verbatim the failure `restarting` was written
  for, reached through the door Q2.229 opened. It was invisible because the *end
  state* is identical either way — the assertion that the wake put back what it
  captured passes with and without the bug.
- **The boot pass.** `shutdown` stops every live session with `stop("daemon_shutdown")`,
  which `revivableByPrompt` accepts, so the config is kept, persisted and adopted;
  `autoResumable(…, "boot")` is `true` for that reason, so the pass resumes the
  session and `restoreConfig` re-applies the remembered mode with nobody watching.
  On the prod hosts that is 04:00 and 05:00 UTC.

**Decision.** The tap is a defect and is fixed: `replacingConfig` names all three
windows — `clearing`, `restarting`, and `resuming` — and both methods test it
instead of writing two flags out by hand. The set was the thing that drifted, and
only `setMode` had ever been raced, so reverting `setConfigOption`'s half left every
driver in this repository green; `daemoncheck.restart-and-resume.ts` races both
inside a real wake now, through a rig hook that fires on the restore's own RPC.

The boot replay **stays**, on the owner's word. Restoring what somebody chose is the
whole of what Q2.229 bought, and a boot pass is not a new decision — it is the same
conversation coming back. What makes it bounded rather than a grant is
`restoreConfig`'s two withdrawal guards: a mode the returning agent no longer
publishes is skipped, which is what claude does with `bypassPermissions` under root.
Recorded here rather than left as a side effect, because *"the daemon re-applies a
permission mode at 4am"* is a sentence somebody should have written down before it
was true.

**Alternatives.** Skipping the mode on the `boot` trigger and replaying it only on a
prompt was offered and declined: it splits one restore into two behaviours keyed on
who asked, and the thing it protects against — a session left in a permissive mode
coming back in it — is a session the person put there. Refusing to restore a
full-access mode at all was declined for the same reason and a sharper one: it would
silently drop a choice somebody made by name, which is the failure Q3's plan-card
work is about from the other side.

**Status.** Fixed (the tap) and decided (the boot replay), 2026-09-19.


### Q2.231 — a turn the agent never answers, and the only thing in the process that can end one

**Question.** Reported from a live machine: the panel reads *working* hours after
the agent had finished. The owner's reading was that claude-agent-acp can start a
turn by itself on returning from background work, and that such a turn has no
`session/prompt` to produce a `turn_end` from.

**Decision.** A second sweep on `idlepark.ts`'s existing clock —
`SessionRegistry.abandonWedgedTurns`, the predicate `ManagedSession.wedged`,
`TURN_SILENCE_MS` at an hour, `REEMOAT_TURN_SILENCE_MINUTES` to move it and `0` to
switch it off. It calls `Session.abandonTurn`, which ends the turn **locally** with
`turn_end{stopReason: "abandoned"}` and sends the agent nothing at all.

**The reported mechanism is inverted, and that matters for anyone chasing this
again.** Nothing but `armTurn` writes `ManagedSession.turn`, and `armTurn` is
reached only from the three prompt paths — so a turn the agent starts by itself
leaves `turn === null`, and `status` reads **`idle`**, not `running`. Its events are
not lost; `startIdleDrain` records them. That produces the *opposite* defect, which
`sendMidTurn`'s `started_new_turn` arm already warns about.

**What actually pins `running`.** `status` is derived and `running` is
`this.turn !== null` and nothing else. `turn` is cleared in exactly one place,
`pump`'s `finally`, reached only when the turn's generator returns, which happens
only on a `turn_end` or an `error` — both produced only by the `session/prompt`
request settling. And that request is the one RPC in `session.ts` fired with **no
deadline**, deliberately, because a turn may legitimately run for hours. So
`status === "running"` is exactly *"a `session/prompt` this daemon issued has not
settled"*, and an adapter that stops answering pins it for the life of the process:
`POST /cancel` observes the same unsettled promise through `waitForTurnToSettle`,
and `parkable`'s first line refuses anything that is not `idle`, so the sweep, the
ceiling's eviction and a wake all skip it at any age — while it holds one of
`MAX_LIVE_SESSIONS` and its agent's ~397 MB. Only `DELETE /sessions/:id` or a
restart cleared it.

⚠ **`withAbandonableDeadline` is the obvious fix and it is the wrong one, twice
over.** Its own docblock records that against a peer which never answers the
cancellation reclaimed nothing — `pendingResponses` stayed at 20 of 20 — so it does
not solve the residual it exists for in exactly this case. And it names
`session/prompt` as the one method whose `ctx.signal` an installed adapter honours:
across codex-acp 1.8.0's whole bundle `ctx.signal` appears once, on that method. So
the "deadline" would abort the agent's work — a third stopping verb arrived at by
accident, against *"stopping the agent and stopping the session are two verbs"*
(Q2.42) and against *"the agent must never notice a client leaving"*.

⚠ **`turnActive` has to be cleared by hand, and missing that would have been worse
than the bug.** It is `Session`'s guard against two prompts in flight and it is
cleared *only* inside the outstanding request's callbacks. A turn closed without
those running reads as idle, opens the composer, accepts the next message and then
throws *"a prompt is already in flight for this session"* into the transcript — for
every message, for the rest of the session. `abandonTurn` clears it itself.

⚠ **And the late answer had to be fenced, or it would cut a *live* turn short.**
The request stays outstanding and may settle at any time, including after a second
turn has started. Every callback it installs is now fenced on a `promptEpoch`
`abandonTurn` bumps, so a stale answer pushes no second `turn_end` for the prompt it
belonged to and — the sharper half — cannot end the turn running now. The driver
walks exactly that: abandon, then answer the stalled request, then assert the live
turn is still live.

**Why silence rather than duration.** A turn running for three hours while the
agent narrates is working; a turn that has produced nothing is not distinguishable
from a dead one. A session waiting on a person reads `blocked`, which the predicate
refuses through `status` rather than through a clause; `hasLiveBackgroundWork` is
Q2.228 applied to the other threshold.

⚠ **The threshold was an hour and both halves of that were wrong — the number and
the clock it measured.** It was reasoned rather than measured: what goes quiet
inside a turn is one long tool call, a build or a test suite, and an hour is past
all of those. That is a fact about the wrong kind of turn. The correction came from
the machine that filed the report, whose store still held the session
(`s_89d35945`, 2 203 events): a prompt at 03:11:07 answered by a **real**
`turn_end{end_turn}` from the agent at 04:39:26 — 88 minutes, working the whole
time — and inside it a single silence of **51.7 minutes**, seq 426 → 427, with 21.8
minutes the next largest. An hour cleared that by 8.3 minutes, which is a
coincidence and not a margin. ⚠ Both gaps are between *agent* events, the series
the predicate measures; swept over every event the second reads 21.5 minutes
instead, because a person's prompt landed inside it (seq 1509 → 1510). The largest
is 51.7 either way.

So the number is now a multiple of the one measured silence rather than a margin
over it: **three hours**, ~3.5×. The multiple comes from the asymmetry, which is
one-sided. Ending a turn that was not over is not recoverable the way the other
mistake is — the transcript says the agent stopped answering where it had not, the
real end's `stopReason` and `usage` are dropped, and the next message reaches an
agent still answering the last one. Waiting too long is the status quo this fixes,
bounded instead of infinite. ⚠ It is no longer derived from `IDLE_PARK_MS`: "twice
the park threshold" was arithmetic dressed as an argument, and the two answer
different questions on different evidence.

⚠ **And the clock had to become the agent's own.** `wedged` measured
`lastActivityAt`, which every write moves — `status` events, the daemon's errors,
and the *person's* messages, since `recordPrompt` appends through `safeAppend`. So
somebody typing into a session that says *working* reset the silence clock on every
message, and the one state this daemon cannot otherwise escape was kept alive by
the person trying to escape it. The same measured session shows three such prompts
inside the open turn (04:28:35, 04:32:16, 04:38:44). `lastAgentEventAt` is set only
from `record` — the pump's loop and the idle drain, i.e. what came out of the agent
— and from the drain's early return for the `agent_log`/`other` it drops, because
those are the agent speaking even when they are not worth logging. The two clocks
stay separate rather than becoming one field: for *parking*, a person typing **is**
activity and is the reason not to take the agent away.

⚠ **The driver row for that clock passed with either field, and the reason is
worth keeping.** The two clocks are milliseconds apart in a test, so no `now` handed
to `abandonWedgedTurns` discriminates them — the row was written, the wrong field
was restored to check it, and it printed `ok`. It is a pair now: the mechanism
asserted with no clock at all (a person's message moves one field and not the
other) and the outcome asserted at *exactly* the threshold, with 200 ms of declared
margin either side of the instant it is measured from. A minute of slack — which is
what the row carried at first — swallows the difference and asserts nothing.

⚠ **Two of the driver rows written for this passed for the wrong reason, and both
are the same class of mistake as the bug.** *"Nothing was sent to the agent"* was
asserted as `stalledCount() === 1` — which says only that the outstanding prompt is
still outstanding, and is true however much traffic goes the other way: adding a
`session/cancel` to `abandonTurn`, the third stopping verb this entry exists to
avoid, left the whole section green, because the rig dispatches on the method, a
notification carries no id, and its `default:` arm drops one without a word. The rig
records every inbound method now and the property is a list rather than a count. And
the 61-minute row measured against the fixture's `now`, stamped when module 1 of 23
was imported, while `wedged` measures the real `turnStartedAt` — an undeclared
60-second wall-clock budget on a driver that has no other one, which a slower runner
or one more section inserted above would have turned red, reading as the feature
being broken rather than the clock. The instant is taken in the section now.

**What this deliberately does not do.** It adds no `SessionStatus` member and holds
no turn open. It does not stop the session, which would have read as `parked` or
`stopped` for a conversation nobody ended. After the ending the session is `idle`,
so the ordinary sweep releases the wedged agent half an hour later — the code that
could not see it before — and the pending request goes with the process. The wire
change is one `TurnStopReason` member and the daemon ships first: `wire.ts` types
`stopReason` as `string` and `stopReasonText` already falls through for a value it
has never heard of.

### Q2.232 — a question the agent asks between turns is cancelled the instant it arrives

**Symptom.** Reported by the owner from session `s_078b731c` on their own machine:
questions to them kept arriving already cancelled — *"cancelled — no turn to answer
into"* — and the first plan approval was drawn *"Approve Plan — denied, no turn to
answer into"*. His requirement, verbatim in substance: all such requests must wait
until somebody answers them.

**Measurement.** Read-only, the reporting machine's `~/.reemoat/reemoat.db`, session
`s_078b731c`, claude under claude-agent-acp 0.73.0. `turn_end` at seq 411
(13:50:00); the next `prompt` is seq 860 (14:28:12). Between them the agent worked
with no prompt: 446 events from 13:59:27, beginning after a background workflow
finished. At seq 511 (14:00:42) it raised an `AskUserQuestion` elicitation and seq
512, stamped the same second, is `elicitation_resolved {action: "cancel", by:
"no_turn"}`. At seq 685 (14:27:48) an ExitPlanMode `permission_request` titled
*Approve Plan*, and seq 686 the same second, `permission_resolved {outcome:
"cancelled", by: "no_turn"}`. Again at 1967/1968 (16:31:18), an elicitation, after
the turn that ended at seq 1652 (15:12:36). Each time the person had to prompt
again (seq 860, seq 2049) to be asked the same thing inside a turn, where it was
answered (864, 2053, `by: "client"`). Across that store's 20 sessions: 3
`no_turn` resolutions, all this session, none `turn_ended`.

**Cause.** Two settle paths keyed on turn boundaries, both older than the idle
drain. `ManagedSession.refusalReason` answered `"no_turn"` whenever `this.turn ===
null`, so `resolvePermission` and `resolveElicitation` recorded a refusal and
answered the agent `cancelled` on arrival. It dates from the initial commit, when
events outside a turn were stranded in the queue (Q2.44) and nobody *could* see the
request — refusing then was the honest answer. Q2.44 fixed the stranding and left
the refusal behind it. The second path is the same judgement one step later:
`pump`'s `finally` called `sweepPending("turn_ended" | "pump_failed")`, settling
any request still parked when a turn ended.

**Decision.** A request the agent raises is settled by exactly four things: a
person's answer, a person's cancel (the card's ✕, or Stop), the agent withdrawing it
(the abort signal, `agent_withdrew`), and the agent going away (`doStop`,
`session_stopped` — a stop, an exit, a restart). Never by a turn boundary, never by
a timer. `refusalReason` keeps only the terminal arm; `pump`'s sweep is deleted.
`turn_ended`, `pump_failed` and `no_turn` stay in `AnswerResolvedBy` — stored logs
hold them and `resolvedByText` still draws them — and nothing writes them.

**Consequences walked.**
- *Status.* A request parked with no turn reads `blocked`, which it already did
  (`awaitingCount` precedes `turn` in `status`). `parkable` and `wedged` both read
  `status`, so neither the idle sweep, the ceiling's eviction nor the silent-turn
  sweep touches it.
- *Stop.* `cancelTurn` with no turn but a request parked used to answer `no_turn`
  and leave it, so the only way to dismiss it was the card's ✕. It now runs a turn's
  own order — send `session/cancel`, sweep `turn_cancelled`, watch — and answers
  `cancelled: true, turn: null` (`cancelWithoutTurn`). `CancelResult.turn` widens
  to `number | null`; the route already typed `turn` as nullable. The sweep is
  fenced on `turn === null`, as the turn's is on its own turn.
- *Revising a plan.* The composer's `revising` send is cancel-then-prompt. With no
  turn the cancel used to be `no_turn`, leaving the plan parked and the new prompt
  queued behind it in claude's FIFO — a message that never arrives while the plan
  waits. The cancel now dismisses it, then the prompt is an ordinary turn.
- *`/clear`.* `clearContext` refuses (`409 turn_in_flight`) while a request is
  parked with no turn, the same reason it refuses mid-turn: clearing decides what
  happens to a cycle in progress, which is Stop's to decide. The composer's
  `clearRefused` reads `canCancelTurn` rather than `turn`, so the refusal is drawn
  rather than toasted.
- *The client.* The ask card was never gated on a turn. `canCancelTurn` widens to a
  parked request with no turn, so Stop is offered there.

**What was not done.** No timeout on a parked request, in either direction — a
question may wait overnight. No re-ask machinery: a request the daemon refused is
gone from the agent's side, and the agent re-raises it if it still wants it.

⚠ **A plan raised between turns is offered no clearing grant.** claude-agent-acp
0.73.0 cannot carry out a plan approved *with the context cleared* outside a turn:
that choice answers `deny, interrupt: true` and records
`pendingExitPlanContextReset`, the restart it defers to sits below the
`isAutonomousResult` early `break` in the adapter's `result` case, and
`continuePlanInFreshContext` throws *"Cannot clear context without an active ACP
turn"* — read off the adapter's source, not driven with a real CLI. Pressing the
card's filled primary (Q3.594) there would stop claude with nothing continuing. So
`PendingPermissionSnapshot.outOfTurn` records whether a turn was held when the
request was raised, and `planControls` given `true` drops the shape's `clearing`
grant and fills the one left; the 0.63.0 shape has none and is drawn unchanged. An
older daemon sends no field and gets today's card. Declining with a reason is still
the message box, and a plan re-raised inside a turn gets both grants back.

**Status.** Current. Reverses the initial commit's out-of-turn refusal and the
turn-end sweep. Amends Q3.594: between turns the plan card draws one grant.

### Q2.233 — the agent working outside a turn reads as working

**Symptom.** Reported by the owner: after a background workflow finished, the
working line at the foot of the conversation did not appear although the agent was
visibly doing things — and *the working line means literally that the agent is
working*, which the owner called an invariant. Q2.231 had already named the mechanism from
the other side: a cycle claude starts by itself leaves `turn === null`, so `status`
reads `idle`.

**Measurement.** The same store as Q2.232. Out-of-turn agent content — `text`,
`tool_call`, `tool_call_update`, `plan`, a request — between a `turn_end` and the
next `prompt`: 1 194 events in 3 sessions, **all claude**; the byo, grok, kimi and
opencode sessions there have none. In `s_078b731c` two such stretches run 28.5 and
54.9 minutes. Their longest gaps, 24.1 and 50.4 minutes, sit *between* cycles, not
inside one: seq 573 is the agent writing *"I'll continue when the notification
comes"*, and seq 574 is the next cycle starting. So a clock over output cannot tell
a working agent from a waiting one, which rules out a timer as the primary signal.

**The adapter, read (claude-agent-acp 0.73.0, `dist/acp-agent.js`).** A result
whose `origin.kind` is in `AUTONOMOUS_RESULT_ORIGINS` (task-notification, peer,
coordinator, observer, observer-activity) is an autonomous cycle's end. Every
`result` — the user's own too — is followed by a `usage_update` whose `_meta` carries
`"_claude/origin": message.origin`, sent whenever the consumer has seen a top-level
assistant message (`lastAssistantTotalUsage !== null`), and sent *before* the
`session.cancelled` check. The SDK's `session_state_changed` running/idle is not
forwarded. `cancel()` calls `query.interrupt()` whether or not an ACP turn is active,
so `session/cancel` with no prompt in flight interrupts an autonomous cycle.

**Decision.** Work between turns is a state, not a clock. `Session` lights
`unpromptedSince` when the agent's own text, thought, tool call, tool update, plan,
permission or elicitation arrives with no `session/prompt` in flight — at arrival,
in `onUpdate`'s order, so a drain running behind the end marker cannot light it
again. A subagent's step (a non-null `parentToolCallId`) is a delegation and lights
nothing. What ends it:
1. a `usage_update` carrying `_claude/origin`, of **any** origin — one cycle runs at
   a time, so whichever ended, the agent is between cycles (`marksCycleEnd`);
2. the agent answering a prompt of ours — claude runs its input FIFO, so every
   cycle begun before that prompt has ended;
3. a `/clear`, whose old conversation's marker is unroutable;
4. the process going (`doStop` drops the mirror);
5. the silent-turn clock (below).

`ManagedSession` mirrors it, `status` reads `running` over it, and the snapshot
carries it as `unpromptedSince`.

⚠ **Latched on the first marker.** Tracking starts only once the agent has sent one
`usage_update` with `_claude/origin` — claude's first result does. An agent that
never sends one is never tracked, so a straggler after another agent's turn cannot
light a working line nothing would ever end. That is a capability read off the
agent's own output, not its name.

**Q2.44's refusal, re-read.** It refused three things. A new `SessionStatus`: still
refused, `running` is reused. A clock in `status`: still refused — this is a state an
event sets and an event clears. Widening `showsWorking`, *"because it is what refuses
Send"*: no longer true. `sendRefused` is `session.status === "stopping" ||
(!acceptsMidTurn(session) && (blocked || working))`, and a daemon that sends
`unpromptedSince` sets it only from a live agent, whose snapshot always carries a
`midTurnDelivery` — `daemoncheck` asserts the pair. A message sent now is an
ordinary `prompt`, which claude queues behind the cycle.

**What reads it.** `parkable` refuses it through `status` (the idle sweep would
otherwise release an agent mid-cycle once its output went quiet for half an hour).
`takesCredentialChange` refuses it (a credential change restarts the agent).
`clearContext` refuses it. `wedged` now tests `turn` itself, since `running` no
longer implies one. Stop is offered and works: send, no sweep of anything but
parked requests, and watch `awaitUnpromptedEnd`; `cancelRequestedAt` is set for it
and cleared when the work ends. ⚠ `armTurn` clears `cancelRequestedAt`: an
out-of-turn cancel the agent has not finished answering would otherwise make
`pump`'s cancel-before-prompt check end the *next* message unsent — exactly the
revising flow's cancel-then-prompt when the plan was raised outside a turn.

**The safety net, and why it is the silent turn's.** An agent that marks cycles and
then stops marking — an adapter change, an interrupt that pre-empts the result —
would read as working until the next turn ends it. `abandonWedgedTurns` also calls
`unpromptedGoneQuiet`, the same predicate as `wedged` (`status === "running"`, not
mid-clear or restart, no live background work, the agent's own clock) floored at
`unpromptedSince`, with `TURN_SILENCE_MS` and `REEMOAT_TURN_SILENCE_MINUTES` — three
hours by default. The evidence is the same with or without a turn: the agent has
said nothing. A separate, shorter number was rejected: the measured gaps above show
a waiting agent and a working one look alike to a clock, and a second number for
one judgement drifts. It writes nothing — no turn ended — and never settles a
request: a session waiting on a person reads `blocked`, which the predicate refuses.

**Rejected.**
- *Holding the turn open over the cycle* — Q2.44's objection stands: `canCancelTurn`
  true for a turn that ended, `409 busy` for ever.
- *A timer after the last output as the primary signal* — the measured 50-minute
  between-cycle gaps make any timer either lie during them or drop the line during
  a long tool call.
- *Recording the marker as an event* — one row per cycle for something nobody did;
  it rides the snapshot like `cancelRequestedAt`.
- *Keying on the agent id* — the latch reads the agent's own output instead.

**Compatibility.** `unpromptedSince` is optional in `wire.ts` and absent reads as
not working, which is today's behaviour. Daemons ship first. An older client
against this daemon sees `status: "running"` with `turn: null`: a running dot in the
list and no working line — degraded, not broken.

**Status.** Current. Narrows Q2.44 and closes the defect Q2.231 described from the
other side.

### Q2.234 — Finished background work belonged to the process, and an effort change took it away

**Symptom.** Reported by the owner, translated: *"After changing effort from
ultracode to high, all finished background tasks were reset, although it's the same
dialog. They must be kept."*

**Measurement.** Read-only against the development machine's store, 2026-09-24.
Session `s_078b731c` (claude, now `ultracode = 0`) launched six workflows in the
background between 13:39 and 15:38 — every one is a `Workflow` tool call whose
result reads *"Workflow launched in background. Task ID: w…"* (seq 147 → 1711). At
16:31:35 seq 2041 is `status interrupted`, `exit.reason: "config_changed"`, then
`starting`, `agent_config`, `idle` and a `session_started` on the **same**
conversation, all within one second. There is no `stoppedWithBackgroundWork` row
beside it, so nothing was running: every row the panel held was finished, and every
one was gone after that second.

**Cause.** Choosing a level while ultracode is on is `setConfigOption`'s ultracode
arm → `applyUltracode(false)` → `restartAgent` → `stop("config_changed")` and
`resume()` on the same `agentSessionId`, because ultracode is read only when a
conversation opens (Q2.43). `doStop` then set `backgroundTasksState = []`, and
`onStarted` handed the list to `applyBackgroundTasks`, which **replaced** it with
the new `Session`'s — a fresh `asyncTasks` map, empty. Nothing could refill it:
claude-agent-acp 0.73.0 holds the lifecycle per process in `AsyncTaskRuntime`
(`async-tasks.js`), built per session at `acp-agent.js:1377`, and
`teardownSession` calls its `clear()` without publishing anything; the SDK's
replace-semantics level (`backgroundTasksChanged`) *"does not create unknown
tasks"*. So the daemon was the only thing that had ever known about that work, and
it threw its copy away. The same `doStop` runs for a credential restart
(`applyCredentialChange`, `onAuthFailure`), a park, an agent exiting and a clean
daemon shutdown — all of them stops a message undoes, all of them the same
conversation, and every one lost the rows. An effort change *without* ultracode,
a model change and a mode change go through `session/set_config_option` and never
restarted anything; they were not affected.

**Decision.** A task list belongs to the conversation, not to the process.

- **Kept on Q2.229's gate.** `doStop` keeps the rows as `earlierTasks` whenever
  `revivableByPrompt(reason, …)` — the same call that keeps the controls and the
  `/` menu — and `applyBackgroundTasks` merges the live agent's list over them
  (`withEarlierAgents`). The live agent's row wins an id it shares, which would only
  be a reused id; the cap stays `MAX_TRACKED_ASYNC_TASKS` over both together, and
  what gives way is an earlier agent's oldest-finished row, which is the order the
  cap already evicted in. A `/clear` is a new conversation and drops them in
  `clearContext`; a stop nothing revives (`start_failed`, `start_timeout`,
  `agent_kill_failed`) drops them as it drops the controls.
- **A row that was running when its agent went reads `stopped`, with `endedAt` the
  stop's time.** It cannot stay `running`: the process that owned it is gone and no
  resume replays it (Q7.113), a live row makes `hasLiveBackgroundWork` refuse
  parking and the silence sweep for ever, and the panel's clock would tick for ever.
  `failed` draws `(error)` in the danger tone and claims the task itself went wrong,
  which nobody measured. `stopped` is the chip for *ended from outside*, and it is the
  adapter's own word for the same fact: `taskState` maps `killed` to `stopped`, and
  `finishLifecycle` publishes `stopped` for a cancelled stream. The transcript's
  existing `stoppedWithBackgroundWork` row still says it in prose, once.
- **Durable across a clean restart, with no migration.** The finished rows ride
  the `agent_state_json` blob Q2.229 added, as a `tasks` key, written on the same
  gate. A JSON key is additive in both directions: an older build's `toAgentState`
  destructures `config` and `commands` and never reads it, and a blob an older build
  wrote has none and reads as no rows. They are bounded **apart** from the controls
  (`keptOnDisk`, `MAX_KEPT_TASKS_CHARS` = 32 KiB, rows kept whole, oldest-finished
  first), because the controls are refused whole past 64 KiB and a long task list
  must never cost somebody their model picker. Read back **row by row**
  (`readKeptTask`): a row this build did not write — live, unstamped, over a bound —
  is dropped alone, and a `tasks` that is not an array costs the tasks and never the
  controls beside it.
- **A memory of tasks alone does not seed the commands revision.** Restore set it to
  1 for any stored memory, which was right while a memory implied a list; 1 over an
  empty list tells every client to fetch one. It is seeded only when there are
  options or commands.

**What the client needed: nothing, and why that was checked rather than assumed.**
An agent swap walks `interrupted → starting → idle`, and `backgroundReporting` is
`unasked` for the first two. `showFinished` is `reporting === "reports" ||
finished.length > 0`, so the band is held open by the rows the daemon kept, not by
the arm — `FinishedSection` stays mounted and its fold stays where the reader left
it. The hidden set is keyed by session, the ids do not change, and
`forgetHiddenFinished` has one caller, `forgetSession`, so a reader's clear
survives the swap too. `webcheck` pins all three by name now, because each looked
like it could be tidied into the bug.

**What was not done.**

- **A crash still loses them.** The blob is written at a stop, as the controls are,
  and a live session's row carries none. A column of its own written on every touch
  (`background_tasks_json` through `migrate()`) would have survived a crash, and was
  declined: it is up to 32 KiB re-serialized on every touch of every live session to
  cover a daemon killed hard, and a column an older build does not know is one its
  upsert leaves stale — so a roll-forward after a rollback could list rows from
  before a `/clear` the older build performed.
- **The `unasked` sentence is unchanged**: *"It is not kept across a restart"*
  remains true of running work and of a crash, and is shown only when there are no
  rows, which is now rarer. Its wording is the owner's to revisit.
- **The empty band still flickers for a second.** A session in the `reports` arm
  with **no** finished rows draws `Completed (0)`, and for the second a swap takes
  it draws the `unasked` sentence instead. Nothing is lost — a zero band is a
  heading with no fold — and holding the arm through the window would need a wire
  field saying *restarting*.
- **A stop pressed on an earlier agent's id is still forwarded** to the live agent,
  which answers `stopped: false`, already an ordinary 200. The panel offers no stop
  on a finished row, and every earlier row is finished.

**What still loses them**, exactly: a daemon crash; a `/clear`; a stop nothing
revives; eviction at the 32-row cap; the disk budget on a clean restart; the
startup prune taking the whole session (Q2.222); and a rollback to a build that
rewrites the blob without the key. The budget was measured rather than guessed: a
row shaped like one of `s_078b731c`'s workflows serializes to 542 characters, so all
32 fit in ~17 KB and it never bites; a row at every text bound is 3 201, and 32 of
those keep the newest 10.

**Pinned.** `daemoncheck.restart-and-resume.ts`: a replacement through
`applyCredentialChange` — the door ultracode takes — keeps two finished rows and
turns the running one `stopped` with an end of its own; the finished row's end is
not restamped; the new agent's rows merge live-first; earlier rows hold nobody back
from release; a park keeps all four and writes them; a second registry over the same
store lists them on a `parked` row before any agent is back, at commands revision 0;
a wake keeps them; a `/clear` drops them; `start_failed` drops them; and the merge,
the cap, the disk budget and the reader as functions.
`daemoncheck.after-the-turn-and-config.ts`: the reported path itself on the modal
rig — a workflow completed under ultracode, then `effort: "high"`, still listed.
`daemoncheck.store-and-worktrees.ts`: the SQLite round trip, a live row dropped
alone, and a malformed `tasks` costing nothing else. ⚠ **Two rows pinned the old
behaviour** — a released session's list and a stopped session's list, both `[]` —
and are rewritten in place with a comment saying what they were. Each new guard was
knocked out once and its rows went red: the keep in `doStop`, the `stopped`
relabel, the `/clear` drop, the write in `persistedRow`, the revision gate.

**Status.** Current. Amends Q3.633: a zero is no longer what an agent swap leaves.

### Q2.235 — grok's questions, plan approvals and MCP forms arrive as its own requests, and are routed onto the two doors every agent uses

**Symptom.** Reported by the owner with grok 1.0.40: the agent called its
`ask_user_question` tool and the transcript showed the tool failing with *"Tool
`ask_user_question` failed: Failed to reach the client for user question: "Method
not found": _x.ai/ask_user_question"*, over a card header reading *"Ask: Какой
напиток ты бы выбрал прямо сейчас?"*.

**Cause.** `AcpClient` registered no handler for any `_`-prefixed method, so the
SDK answered every agent-to-client extension request `-32601`. grok sends three —
its question, its plan approval and an MCP server's form — and Q6.113 measured what
each did with the refusal: the question tool failed; the plan approval failed as
*"the client disconnected"* and **ended the turn**; the MCP server was handed a
`cancel` it never learned the reason for.

**Decision.** Parse each in `src/acp/xai.ts`, the one module that knows their
shapes, and route it onto a door every agent already uses, so parking, the log, the
card, Stop, the idle sweep and the four ways a request ends (Q2.232) are the
existing ones rather than a copy:

- `_x.ai/ask_user_question` becomes an elicitation built in **claude-agent-acp's
  AskUserQuestion bridge shape** — `question_` with the options as a titled
  `oneOf` (or an `anyOf` list for `multiSelect`), and its own-answer box
  `question__custom` marked with `_askUserQuestionCustomAnswer`, the marker that
  bridge documents as *intentionally agent-neutral, for other AskUserQuestion
  bridges*. It then goes through `toElicitationForm`, so every elicitation bound is
  the bound — twelve questions is twenty-four fields, the cap — with no second
  number. The card draws it exactly as it draws claude's: a question a step, the
  descriptions, the box and circle marks, the own-answer row. The answer goes back
  as grok measured accepting: `{outcome: "accepted", answers: {:
  label | [labels] | typed text}}`, a typed answer winning over a pick as on claude.
- `_x.ai/exit_plan_mode` becomes a **permission** titled *Approve plan* with
  `rawInput: {plan}` — which is where the card reads a plan — and two options named
  by the outcome each sends, `approved` (*Approve plan*, `allow_once`) and
  `abandoned` (*Abandon plan*, `reject_once`). grok's `exit_plan_mode` tool call
  carries `{}` for arguments and the snapshot clamps a permission's blob at 8 KiB,
  so the plan is also written onto grok's own call as a `tool_call_update` with
  `rawInput.plan`: the card recovers a clamped payload from the log, which would
  otherwise hold no copy.
- `_x.ai/mcp/elicit` becomes an elicitation of the server's own schema, its message
  prefixed with the server's name as grok's own card names it, answered `{outcome:
  "accept", content}`, `{outcome: "decline"}` or `{outcome: "cancel"}`.

**What this costs in meaning, stated.** On grok **decline and cancel are one word**:
the question's only no-answer outcome is `cancelled`, which the model reads as
*"User declined to answer"*, so Skip and the ✕ send the same thing where on claude
they are two acts. `chat_about_this`, `skip_interview` and `annotations` exist on
the wire and are never sent, because nothing on the card asks for them. A plan's ✕,
Stop and every sweep send `cancelled`, which grok reads as *"the user wants to
revise the plan"* and stays in plan mode — exactly what the composer's `revising`
send (cancel, then the correction as a prompt) wants.

**The question is the title of its field.** grok has no short header, and the
title is what `renderAnswers` logs an answer under; without one a typed answer — which
`answeredQuestions` cannot join back by label — was logged as `question_0`. The
card draws no heading equal to its title (`askTitle`), so nothing is drawn twice.

**A plan's options keep their names on the card.** `optionLabel` substitutes a
kind's word whenever kinds are unique, and grok's two would have read *Allow once*
and *Deny* over approve and abandon. It takes `plan` now: a kind's word describes a
grant, and approving a plan is not one. `planControls` still curates claude's shapes
alone — grok's request has no `switch_mode` and matches none of them.

**grok withdraws by resolving, never by cancelling.** It sends
`_x.ai/session_notification {update: {sessionUpdate: "interaction_resolved",
tool_call_id}}` after every answer *and* after its own timeout, and no
`$/cancel_request` ever. `Session.withdrawable` gives each request a signal that
either the SDK's or that notification aborts, so the registry settles it
`agent_withdrew` — Q2.232's third way. ⚠ **The notification handler's position is
load-bearing.** grok resolves its own permission step for a call immediately before
it asks the question on that call; the SDK walks a message through the handlers
with one `await` each, so registered after the request handlers, that earlier
resolution reached its handler *after* the later question reached its own and
withdrew it on arrival — the Q2.232 defect again, by a new road. The driver
reproduced it before the handler moved to second place; it sends the pair in
grok's order.

**grok's own question timeout is off at the spawn.** `GROK_SPAWN_ENV` carries
`GROK_ASK_USER_QUESTION_TIMEOUT_ENABLED=false` on the grok arm of `resolveAgent`:
the default gives up after 30 minutes and answers itself "declined" (Q6.113), which
is Q2.232 from the other side. The environment outranks the user's `config.toml`
and loses only to an organisation's `requirements.toml` — which is what the
withdrawal above is for.

**With questions off, the tool is withdrawn.** grok keeps `ask_user_question`
whatever the client declares, so `REEMOAT_ELICITATION=0` now also sends
`_meta.askUserQuestion: false` on `session/new` (`sessionMetaFor`, measured to
remove the tool), and the question and MCP-form methods still answer `-32601`, as
`elicitation/create` does when declined. A plan is a permission and is always asked.

**Refusals.** Malformed params are `-32602` from the parser, before anything is
parked or logged. A question longer than the card's 4096-character message cap is
refused rather than drawn cut, because its text is the answer's key. `url` mode is
refused as Q2.20 refuses it; grok hands its MCP server a `cancel` for any refusal.
Every other `_` method — including x.ai's own `folder_trust/request` — is still the
SDK's `-32601`: an unknown request must stay a failure the agent reports.

**Rejected.**
- *Recognising grok's tool calls in the transcript and asking from those* — the
  request is the protocol; a tool call is a picture of it.
- *A grok-specific card* — two bodies already exist and grok's requests fit them.
- *Sending grok's `revise` with the composer's text as `feedback`* — measured to
  work (*"The user wants to revise the plan. The user said: …"*), and it would skip
  the cancel; but it needs a route the composer does not have, and the cancel-then-
  prompt that works for claude works here.
- *Handling `interaction_resolved` by diverting the line below the SDK*, as the
  async-task updates are — it would order the notification strictly, but at the
  price of a second parse of a stream that carries one notification per token.

**Status.** Current.

### Q2.236 — How does one session's agent reach another session's?

**Decision.** Through an MCP server this daemon injects into every agent that
declares an http MCP client, named `reemoat`, with two tools: `list_agents` and
`send_message` (three until Q2.243). `SessionOptions.mcpServers` is a callback called
after `initialize`, because `mcpCapabilities` is only known then, and `launchOptions`
hands it to `session/new`, `session/resume` and the `/clear` path alike.
`PeerMcpEndpoint` is its own listener on `127.0.0.1` with a port the OS picks, and
`PeerHub` is the one place every peer message passes through.

**Why MCP.** ACP has no notion of another session, and an agent can only be handed
a tool it calls through MCP; Claude Code's own cross-session SendMessage and
Codex's `send_message` are built-in tools for the same reason, and the one ACP host
that already does this (Paseo) injects an http MCP server exactly so. What an agent
sends arrives as a `prompt`, through the prompt route's own machinery (Q2.243).

**Why its own listener.** The daemon's HTTP server binds `REEMOAT_HOST`, which may
not be loopback, and `REEMOAT_PORT=0` means it has no listener at all.

**A bearer per launch.** `mcpServersFor` mints one each time an agent is handed the
server and retires the one before, so a replaced process stops naming the session;
since the 0.12.0 review it is also retired, by token, when its process ends. It
identifies the caller; it does not confine it, since every agent runs as the same
user and can read another's — the same position as `agentEnv()`.

**claude's bearer is not on a command line.** Measured 2026-10-01: claude-agent-acp
0.73.0 hands an http server's headers to the SDK, which starts the CLI with
`--mcp-config `, so the bearer sat in argv, readable by **every** local
account (`ps`, `/proc//cmdline`) — the premise above held only for the same
user. Claude Code 2.1.286 expands `${VAR}` in those headers (a probe server received
the value), and the adapter builds the CLI's environment from its own. So for claude
the bearer is minted before the spawn into `REEMOAT_MCP_BEARER`
(`AgentLaunchConfig.mcpBearerEnv`) and the header names the variable; through the
real adapter the CLI's argv carried only `${REEMOAT_MCP_BEARER}` and the server got
the value. The other five take MCP servers over stdio and get the literal header.
Rejected: a table keyed by agent id (a stub would get a header it cannot expand),
and minting in the hub before the spawn (a second callback before `initialize`).

**Rejected.**
- *A stdio shim per session* — one more process per agent, for the one transport
  every measured harness already has in http (Q6.114). Kept for a harness that
  needs it; none does yet.
- *MCP over ACP* (`type: "acp"`, an RFD) — it would remove the listener, but
  `codex-acp` advertises `acp: false` and kimi drops such entries.
- *The MCP SDK* — it brings zod, which this repository does not use.

**Status.** Current. Between machines, see Q7.150.

### Q2.237 — Why two verbs, and why does a report arrive with the notice?

**Decision.** Codex's pair, not Claude Code's single verb. `assign_task` is work:
it starts a turn on an idle session, steers into a running one, and queues behind a
turn that cannot be steered — `submit`, the prompt route's two steps. `send_message`
is context: logged at once and never starting a turn, and put ahead of the next
turn's text, whoever starts it.
`assign_task` subscribes its sender to the recipient going idle by default; the
notice is delivered through `submit`, so a report the recipient left as context
reaches the sender **in the same turn as the notice**.

**Why.** A message that must not interrupt should cost nothing until something
else wakes the reader, and a reply to a task is exactly that. Codex splits the two
for the same reason (`send_message` does not trigger a turn; `followup_task` does).
Its `wait_agent` is not copied: in one reported run 89 waits, 88 timed out, cost
6.5% of the tokens — the push is the notice.

**Where it differs from Codex.** There, context enters the history at once and the
model sees it on its next step. ACP cannot add to a history without a prompt, and
a steer on claude pre-empts the running turn (`mid-turn-messages.md`), so context
here always waits for the next turn; what is urgent goes as `assign_task`.

**Measured** 2026-09-25, claude 2.1.281 assigning codex 0.156.1: `list_agents`,
then `assign_task`; codex woke, read the file, reported with `send_message`; the
idle notice woke claude with the report ahead of it and claude answered in that
turn — 25 s end to end, no polling.

**Status.** Reversed by Q2.243 the same day: every message is acted on, and there is
no context verb.

### Q2.238 — What stops two agents talking to each other for ever?

**Decision.** This daemon, never the model's good sense: `PEER_TURN_BUDGET` turns
caused by agents in a session with no message from its person, after which work
from agents is refused and the person is told once; `MAX_PEER_HOPS`, from
`peerDepth`, the deepest hop delivered since the person last wrote; a token bucket
per sending session; the same words to the same session inside a minute; and a
size cap. A person's message resets both counters.

**Why a budget and not only hops.** A hop count survives only while every agent
passes it on, and a cycle of two never grows deep; counting at the receiver stops
both. Notices count, or two agents assigning each other work would never stop.
Since Q2.243 every message wakes its reader, so the budget is also what stops two
agents thanking each other. Claude Code reaches the same place with a 50-message
queue per session.

**And the queue.** Peer entries hold at most `MAX_QUEUED_PEER_PROMPTS` of
`MAX_QUEUED_PROMPTS`, so other agents can never make a person's own message answer
429; consecutive ones go as one turn.

**Status.** Current. The numbers are first guesses, not measurements.

### Q2.239 — Which ended sessions may another agent reach?

**Decision.** Those that ended for a reason in `PEER_WAKE_REASONS`: parked, a config
restart, an agent that exited, the daemon going away. A person's Stop is not among
them: that session is neither listed nor woken.

**Why.** `revivableByPrompt` answers whether a *person's* message may bring a
session back, and says yes to `stopped`. An agent doing it would undo a person's
decision without asking them.

**Status.** Current.

### Q2.240 — Where does a message for a machine that is off wait?

**Decision.** On the sending daemon, in `peer_outbox`. A send whose answer is the
relay's `503` — or no answer — is held and reported `pending`, then retried by
`pumpOutbox` from 30 s to 10 min apart for 24 h, **byte-identical**, so the
receiving daemon's per-machine message-id check (Q2.241) delivers it once however
many tries it took — and since the 0.12.0 review however many restarts too: the ids
are kept in `peer_seen` for a day, because the sender gives up after 10 s while a
wake may take 45, so a retry is the common case and an in-memory set forgot it at
every restart. A delivery a shutdown cuts off answers `503 shutting_down`, which
every sender holds, rather than `ended`. A refusal on a later try, or the day
running out, wakes the sender with a notice saying so, within the budget any notice
is (Q2.243) — another machine's words only ever as a quotation in it. At most
`MAX_OUTBOX_PER_SESSION` per session and `MAX_OUTBOX` on the machine.

**Why here.** A tunnel with no daemon is a 503, never a queue (Q5.21), and the
Authority may hold none of an agent's work (`authority.md`). Claude Code holds a
message for an offline session on its own servers; the only process here that may
hold it is the one whose agent wrote it.

**Status.** Current.

### Q2.241 — What does a daemon take on a linked machine's word?

**Decision.** Which *session* on that machine sent a message, and nothing else.
Which *machine* is the capability's (`principal.link`, all three link claims or
the token is malformed). Every link has its own token bucket here, whatever the
other daemon says it enforces; a message id is delivered once per sending machine for a
day, since re-minted links hand the same sender a new link id, and kept in
`peer_seen` so a restart forgets none; a refusal is re-read too — its code kept only
if `PeerRefusal` has it, its words one line of `MAX_REMOTE_REFUSAL_CHARS`; a
notice is taken only when this machine asked for it, once (`expectedNotices`), so a
link cannot wake a session by claiming to answer it; a listing row is re-read field
by field (`remoteRowOf`), and one whose ref names a machine is dropped, while one
whose status this build does not know is kept and shown as idle (`compatibility.md`
rule 2) — dropped, it let a bare name resolve to a same-named session here. A link
reaches `/peer/*` only: `session:message` opens no other route, and a person's
capability never carries it.

**Why.** A linked machine is another computer running as the same person, which is
not the same as being right: a looping agent or a compromised host there must cost
this machine a bounded amount of work, never its owner's attention.

**Status.** Current.

### Q2.242 — Why does no claude session have its own ListAgents, and why are this daemon's tools loaded up front?

**Symptom.** Reported by the owner on 2026-09-25: a claude session driven over ACP,
asked about other agents, called Claude Code's own `ListAgents` rather than
`list_agents`. Claude Code 2.1.28x ships cross-session messaging, and the tool is in
an SDK session too. It lists Claude sessions on the machine and nothing this daemon
runs, so it answers a different question and sounds right doing it. And `list_agents`
was one step further away: claude defers an MCP tool's schema behind its tool search
(Q6.114).

**Decision.** Two changes, each where it belongs:
- `sessionMetaFor` sends `disallowedTools` naming `CLAUDE_WITHDRAWN_PEER_TOOLS` to
  every claude session, whether or not it was handed the `reemoat` server.
  claude-agent-acp appends it to its own list, and a disallowed tool is not in the
  model's context at all. `session/new`, `session/resume` and `/clear` all carry it.
- Each of this daemon's tools carries `_meta["anthropic/alwaysLoad"]: true`, which
  claude reads per tool and which keeps it out of the tool search. It costs their
  schemas in every claude session's prompt. Every other harness ignores the key.

**Widened the same day.** It was first withdrawn only where the tools were handed,
so that a daemon with `REEMOAT_PEER_MESSAGES=off` took nothing of claude's away.
The owner's call: it is never wanted here, because what it lists is never what this
daemon runs, with the tools or without them.

**Measured** 2026-09-25, claude 2.1.282 under claude-agent-acp 0.73.0. Asked to name
every tool it had for other agents, it gave the three `mcp__reemoat__*` and
`SendMessage`, and no `ListAgents`. Asked which sessions it could message, it called
`mcp__reemoat__list_agents` first, with no ToolSearch before it.

**Why SendMessage stays.** It is also how claude continues a named subagent it
started, a background one included. Withdrawing it would cost claude its own
orchestration to save a tool that no longer has a listing to feed it. The server's
instructions say that a harness's own messaging tools do not reach these sessions.

**Rejected.**
- *Passing it as settings* (a deny rule, or Claude Code's crossSessionInbound). The adapter
  drops the settings it builds from `CLAUDE_MODEL_CONFIG` whenever any are passed, so
  a model override would vanish from every session.

**Status.** Current. Claude Code's inbound cross-session messages are not refused, for
the same settings reason.

### Q2.243 — Why does every message between agents start or join a turn?

**Symptom.** The owner, 2026-09-25, on a transcript row *Note from …· read with its
next turn* holding grok's count to a hundred: only messages the other session reacts
to. A note is logged at once and then does nothing until something else wakes its
reader, which on screen is a message that went nowhere.

**Decision.** One verb. `send_message` is what `assign_task` was: it starts a turn on
an idle session, steers into a running one, and queues behind a turn that cannot be
steered. Nothing is held for somebody else's turn, so there is no buffer and no
refusal for a full one, and an answer is a message like any other: it wakes
whoever it is for. `notify_when_idle` is off unless asked for, and the notice it
asks for **stands in for an answer**: writing back to the subscriber cancels it
(`answered`), and on the subscriber's machine the answer's arrival forgets the
notice it was expecting. A subscription that runs out lapses silently, and a message
the outbox gives up on wakes its sender with a notice.

**Why the notice is off by default.** A reply goes by the same verb as a task. On by
default, every reply would subscribe its writer to its reader going idle, and the
end of every exchange would wake the one who answered for nothing.

**What it costs.** An answer is a turn of its own for its reader, where Q2.237's
report rode the idle notice's turn; an assignment that is answered still costs its
sender one wake, not two, since the notice is not sent. A reply to a reply is a turn
too, and nothing but the tool's wording and `PEER_TURN_BUDGET` stops two agents
thanking each other.

**Rejected.**
- *Keeping `assign_task` as the one name.* An answer would have gone as a task.
- *Steering context into a running turn and buffering it only for an idle one.* The
  idle session is the case that read as lost.

**Measured** 2026-09-25, claude 2.1.282 asking codex 0.157.0 on one machine, no
notice asked for: `list_agents`, then `send_message`; codex read the file and
answered with `send_message`; the answer woke claude, which summarised it and sent
nothing back. Two turns for claude, one for codex, no notice, and nothing more in the
minute after — 36 s end to end.

**Status.** Current. Reverses Q2.237.

### Q2.244 — Who may switch off messages between agents, and at which levels?

**Decision.** Three switches, each only narrowing the one above: the account's, a
machine's and a conversation's. `REEMOAT_PEER_MESSAGES=off` is still a ceiling none
of them can lift. A conversation takes part only when all four allow it. The first
two are Q1.654's, applied by `setPolicy`. The third is `peer_messages_off` on the
session row, set by `POST /sessions/:id/meta` with the same `session:write` a rename
needs, because it narrows and a grantee who can prompt the session already has more.

- **Off is checked where it is used, on the live value.** The flag stopped being
  read once at boot, and every path that bypassed it is gated: the local rows of
  `list_agents` behind an already-handed bearer, `/peer/notices`, the outbox pump,
  and the idle notices.
- **A running agent keeps the tool names.** Its tool list was fixed at launch, so
  a call is answered with a sentence rather than a missing tool. The bearer stays
  valid, because a `401` from an http MCP server reads to claude as a sign-in to
  start. Switching back on reaches conversations that start or resume afterwards.
- **Switching off drops what was waiting.** Queued peer prompts not yet delivered
  (`dropQueuedPeer`), idle-notice subscriptions, and the outbox, with one quiet line
  in each sender's transcript (`notePeerMessagesOff`) and no wake. What cannot be
  taken back:
  - a message already steered into a running turn;
  - a turn another agent already started;
  - an outbox request already on the wire.
  A send inside `sendMidTurn` is dropped too since the 0.12.0 review: a steer that
  fails after the switch fell through to the queue and was delivered, so
  `dropQueuedPeer` bumps a counter the steer re-reads after its await.
- **A conversation that is off neither sends nor receives.** Other agents do not
  see it, and it is refused both ways with `conversation_messaging_off`. A parked
  one is refused without being woken first.
- **A machine may instead be isolated.** Its sessions still message each other,
  but nothing reaches them from another machine and they reach none
  (`reachesOthers`, `messaging_isolated`). Going isolated empties the outbox with the
  same quiet line and forgets what was owed abroad; nothing local is touched. The
  flag is kept while the machine is off.

**Rejected.**
- *A per-conversation switch that only stops the conversation being woken.* A
  sender that is never answered.
- *Revoking bearers on off.* See the second point.

**Status.** Current. Q2.238 bounds how much agents may say to each other; this
decides whether they may.
### Q2.245 — What is a session's nickname, and what keeps it one session's?

**Decision.** Every session has a nickname — `mira`, `otto` — and it is the name
other agents address it by: `nameOf` answers it, so `list_agents` prints it,
`send_message to="mira"` resolves it and a peer message reads *Message from mira*.
- **Shape**: `NICKNAME`, lowercase latin, digits and single hyphens, starting with
  a letter, 2–32 characters. It passes every daemon's `isPeerName`, the older ones
  included, so a listing row is never dropped on another machine. It holds no `_`,
  which `PeerHub.resolve` reads as a session id.
- **One per machine**, case-insensitive, over every session the registry holds,
  ended ones included, and over the names being created right now.
  `reserveNickname` takes the name synchronously at the top of `create`, before the
  capacity check, the create token and `createWorkspace`, so the loser of two
  concurrent creates gets `409 nickname_taken` and has made nothing.
- **Always present.** A create that names none gets `pickNickname`: a free name
  from `NICKNAMES`, then one of them with the first free `-2`, `-3`. `restore()`
  gives one to every row written before the column, walking rows oldest first so
  the older of two duplicates keeps its name. A nickname cannot be cleared.

The title is unchanged: still derived from the first message, still renamed by a
person, still printed by `list_agents` as what the session is about. It stopped
being the address.

**Why.** A title is what a session is about, and that makes it a poor address. It
is long, it moves with every rename, and two sessions share one as soon as two
people ask for the same fix — which is why a bare name had to answer
`ambiguous_recipient` even among one machine's sessions. A handle a person chose,
or accepted, stays put.

**Rejected.**
- *A UNIQUE index.* `put` swallows its errors, and a rolled-back build writes NULL
  into the column; a reservation in memory is the one check both creates pass
  through.
- *One per account, across machines.* No daemon sees another's rows without the
  network, and the Authority holds no sessions. The app's dice avoids every name it
  can see instead (Q3.677), and an ambiguous name is answered by listing each match.

**A bare name never resolves by elimination.** Found in the 0.12.0 review: a linked
machine whose listing failed was skipped, so `mira` here and a `mira` on a machine
that did not answer resolved to this one. With any listing unchecked — no answer, a
transport failure, an unexpected status — one match is refused `ambiguous_recipient`
naming the machine and the match's full address, and none `unknown_recipient` naming
what could not be checked. A machine that answered off, isolated or too old does
not block, or one old daemon would refuse every bare name for good; a full address
waits on no listing.

**Status.** Current.

### Q2.246 — What does the agent get when a person writes `@mira`?

**Decision.** The text exactly as typed, then a second text block the daemon writes:
``, saying it is Reemoat's note and not the person's, one line per
session named — its address, title, harness, folder and machine — and
`send_message to="…"` only where this session's agent was handed the tools.
`mentionNote` builds it on the person's prompt route and nowhere else.
- **It never waits on the network.** Local rows are read in place; another
  machine's are its last successful listing, `remoteSettled`, which a failed
  listing clears so the note never names what `list_agents` would call unreachable.
  Opening the `@` menu warms it (Q3.678).
- **At most eight names** a message, and a name two machines share lists both.
- **The switches of Q2.244 hold**: nothing is named where this machine's or this
  conversation's messaging is off, and another machine's sessions only while this
  one is not isolated — the listing `list_agents` would give, and no wider.
- **Not in a slash command**, since agents parse one at index 0 and a second block
  beside it is unmeasured.
- **Not in a peer's envelope or a plugin's prompt**: `defuse` breaks a forged
  `` tag inside a message body.
- It rides the turn the way attachments do — the steer's extra blocks, a queued
  entry's `note`, `pump` — and the `prompt` event logs the names it resolved as
  `mentions`, so the log says a note was added.

**Why.** An agent reading `@mira` has to guess that it names a session at all, then
list every machine to find which. The daemon already knows, and saying it once
beside the message costs nothing the agent would not spend finding out.

**Rejected.**
- *Rewriting the text.* `text` is what the web client's echo is matched on, what a
  title is derived from and what `/clear` is compared with.
- *The client resolving names and sending references.* The listing is the daemon's;
  a client's own rows include machines the agent cannot reach.
- *Awaiting the listings.* Up to three seconds on every message a person sends, for
  a word that may be `@Override`.
- *A status in the note.* A queued message is delivered minutes after it was
  accepted, and *idle* would be stale by then.

**Measured.** Not against a live agent. That a second text block reaches the model
as part of the same message is read off claude-agent-acp 0.73.0 and codex-acp 1.8.0,
each of which keeps separate text blocks separate; kimi, opencode and grok are
unmeasured.

**Status.** Current.

### Q2.247 — What does a session keep of its files, and what happens when it is full?

**Decision.** Two budgets, and both roll.
- **Files somebody sent**: 100 files and 1 GiB a session (`MAX_UPLOADS_PER_SESSION`,
  `MAX_SESSION_UPLOAD_BYTES`). Past either bound the oldest file **already sent** is
  dropped, row first and bytes after, to make room (`roomFor`). A file not yet sent is
  never dropped, since a draft names it, so the only refusals left are a hundred files
  or a gibibyte all waiting to be sent, and they say so.
- **Images an agent returned**: kept for the transcript on a budget of their own, 200
  images and 256 MiB (`MAX_AGENT_IMAGES_PER_SESSION`, `MAX_SESSION_AGENT_IMAGE_BYTES`),
  oldest first out, and never counted against a person's files. An id's prefix is its
  kind — `u_` or `a_` — and `isAgentImage` is the one place that reads it.
- **A dropped file answers 404 `upload_not_found`**, and the transcript says
  *"… is no longer kept."* where its preview was, as a download of it does.
- **An upload's type is read off its first bytes** when it declared none or only
  `application/octet-stream` (`sniffImageMime`: PNG, JPEG, GIF, WebP), and the web
  client sends a file's own type over the relay too (`contentTypeFor`).
- **A failed chip offers Retry only where a retry can succeed** (`uploadRetryable`): a
  dropped connection, a 408, a 429 or a 5xx, never a 400, 409 or 413.

**Why.** Measured 2026-09-29 on the owner's machine: a landing-page session refused a
pasted screenshot with *"this session already holds too many staged files"*. It held
100 rows, and 96 of them were images the agent had returned while taking screenshots
of its own work — 66 JPEG and 30 PNG across three days — against the owner's 4. One
count covered both kinds and every file ever sent, and a sent row had no way out but
the session's deletion, so the agent's working screenshots locked the person out of
attaching anything, for good, in a session that looked healthy. The inode argument
behind the count (Q5.101) still holds, since each file is a directory, so the answer
is a rolling bound rather than none: a transcript's media is a display cache, and a
cache evicts rather than refuses. The person's input keeps a hard edge only where
dropping would lose something nobody has sent yet. The agent's numbers are twice what
that session produced in three days, and room for ten images at the 25 MiB one may be.

**The type, found on the way.** All four of the owner's screenshots were stored as
`application/octet-stream`. Over the relay the header came from `contentTypeFor`,
which answered bytes for any `Blob`, and `inlinesImage` hands the agent an image only
under `image/*` — so a screenshot pasted in a browser, or in the app off loopback,
reached the agent as a path rather than a picture. Reading the type off the content is
the upload advice OWASP gives; here it only fills a gap and never overrides a type the
client declared.

**Cost.** A dropped file can no longer be re-read by the path its prompt carried
(Q2.31); with a hundred newer files in the session, one that old is rarely the one a
conversation turns back to.

**Rejected.**
- *Not counting an agent's images at all.* Each is a directory on the daemon's disk,
  and an agent looping on screenshots would write without bound.
- *Dropping a file not yet sent.* The draft holding its id would send a prompt naming
  nothing, which `resolve` refuses as `missing`.
- *Refusing an agent's image at the bound, as before.* Every image past it was drawn
  as the text `[image]`, silently.

**Status.** Current. Amends Q5.101's upload bounds, and Q2.31 on what the agent can
re-read.

### Q2.248 — Reattaching an agent that has `session/load` but no `session/resume`

**Decision.** `Session.openResumed` sends `session/resume` where the agent declares
it and `session/load` where it declares only `loadSession: true`. The load's replay
is not suppressed by any code of its own: it arrives **before** the load's answer,
and `adopt` registers the session with the client only **after** that answer, so the
router drops every replayed frame exactly as it drops any frame for an unregistered
id. `available_commands_update`, which cursor schedules after the answer, lands as
state like it does after `session/new`.

**Why.** cursor 2026.09.28 has no `session/resume` (`-32601`) and declares
`loadSession: true`. Without a second verb every cursor session is stranded by a
daemon restart and by the idle sweep (Q2.224) — its next message would answer
`ResumeUnsupportedError` for ever. Q5.85's rule existed to stop a replayed history
being written into a log that already holds it, and the ordering removes that cost
rather than the rule being ignored.

**Measured.** Read from cursor's ACP server, `loadSession` in
`./src/acp/cursor-acp-agent.ts`: it `await`s `replayConversationHistory` and only
then returns `{modes, models, configOptions}`; the replay is user text (with cursor's
appended `Additional ACP context:` block), agent text and thought, and
`tool_call`/`tool_call_update` pairs with ids `replay--`. `daemoncheck`
drives a stub that replays four frames before answering and asserts none reaches the
log, and that a command list sent after the answer does.

**Rejected.** *Mapping cursor's unknown-session answer to `SessionForgottenError`.*
It is `-32602` with a message, not `-32002`, and `-32602` is also what cursor answers
to a malformed `mcpServers` of ours — persisting `resume_gave_up` on it would strand
a session over this daemon's own bug. It costs retries instead.

**Status.** Current. Reverses Q5.85 for an agent with no `session/resume`; where
both exist resume is still the only verb sent.

### Q2.249 — A model chosen on a parked Cursor session, and the effort that did not appear

**Question.** A parked session's chips are live and a tap is recorded for the wake
(Q3.618). On 2026-09-30 the owner picked Claude Opus 5.5 on a parked Cursor session
and the effort chip stayed a dash. Why, and what should a tap do?

**Why it was a dash.** Cursor's controls other than its mode belong to the model:
`parameterizedModelPicker` makes each model bring its own — Claude Opus 5.5 an
`effort`, Codex 5.3 a `reasoning`, Auto nothing (Q6.115) — and only cursor can say
which, after `session/set_config_option` reaches it. The recorded choice reached
nobody, so the strip kept the options the parked agent last published, which were
Auto's. The log shows it: two `agent_config` rows at 17:11 carrying a new model and
no `effort`, and no `status` row between them and the park.

**Decision.** On a harness whose controls are its model's (`modelScopesControls`,
true exactly where `clientMetaFor` declares the picker, so cursor alone), a model
tapped on a **parked** session records the choice and then wakes the agent through
`resume`, whose `restoreConfig` sends it; the tap answers with what the woken agent
published. And in every deferred state the recorded model drops the old model's
controls, keeping only the mode, since `restoreConfig` would otherwise replay the old
model's `effort` onto the new one whenever the value happened to exist — overriding
the level cursor remembers for that model in `modelParameters`.

**Parked only.** A `stopped`, `agent_exited` or `agent_signed_out` session still
records and waits for a message: a person ended the first, the other two ended on a
failure a tap should not retry. A parked session is one this daemon let go for its
own reasons, and choosing a model is somebody using it.

**Cost.** A tap on a parked Cursor session spawns an agent and may evict another at
the machine's ceiling (`makeRoomForWake`), as a message would; the route already
budgets a start and a config call (`START_TIMEOUT_MS`, `SET_CONFIG_TIMEOUT_MS`), as
ultracode's restart needed. A failed wake answers `agent_config_failed` with the
choice kept, as that restart does.

**Status.** Current.

### Q2.250 — A question Cursor's model cannot ask, and the tool this daemon gives it

**Question.** Asked to put a multiple-choice question in front of its person, a
Cursor session answered in text: *"this harness has no tool for a UI with
buttons"*. Its own reasoning, in the log, looked for `AskQuestion` and found it
unavailable. `cursor/ask_question` is handled (Q6.117) — why did it never arrive?

**Measured 2026-09-30, on the owner's account, which is served Auto only** —
Composer 2.5, Codex 5.3 and Claude Opus 5.5 each answer *Upgrade your plan to
continue*. The model listed its tools, four ways: over ACP in `agent` mode, over
ACP in `plan` mode (`CreatePlan` joins), over ACP with `clientInfo` naming `zed`,
and through the terminal's own `cursor-agent -p`. Twenty-odd tools each time and
`AskQuestion` in none. The tool list is the server's, sent per request; the ACP
path tells it only `x-cursor-client-type: acp` and the host app's name, and reads
neither `--allowed-tools` nor a custom header. So nothing this client sends can ask
for it, and whether the gate is the client type, the plan or Auto is unmeasured.

**Decision. `ask_question`, served by the `reemoat` MCP server to the harnesses in
`QUESTION_TOOL_HARNESSES` — cursor alone.** Its input is cursor's own AskQuestion
shape, parsed by the same `parseQuestionRequest`, drawn by the same form builder,
so it is the card cursor's request would have drawn. Q2.14's rule stands: an agent
with a question tool of its own is never given a second.

**It returns at once, and the answer is a message.** A blocking call cannot work:
cursor calls MCP tools with no timeout option, so the SDK's default 60 s cuts any
answer given later than a minute. So `poseQuestion` opens an elicitation no request
waits on, the tool tells the model to end its turn, and settling the card sends
`answerText` through `deliverAnswer` as its answerer's message — the person's, or a
plugin's (`sessions.answerElicitation`), which neither resets nor counts the peer
budget — waking, queueing and refusing exactly as a typed one would. Submit sends the labels picked, Skip
says it was skipped, ✕ sends nothing.

**What it outlives, and what ends it.** One open at a time, refused in words
otherwise. Kept on the row in `open_question_json` and redrawn by a restarted
daemon under the same id; kept through every stop a message could revive, bar the
person's own Stop, and dismissed by Stop on the turn. This is the one pending
request that survives its agent going, which `daemon-sessions.md`'s invariant
otherwise forbids — nothing on the agent's side is waiting on it. While open the
session reads `blocked`, so it is not parked.

**The permission in front of it is answered by the daemon**, the owner's call:
cursor asks before every MCP call not in its allowlist, and the card behind this
one is the consent that permission would ask for. `Session` learns the call is
this tool from the `rawInput` cursor puts on its `tool_call_update` first
(`readMcpToolCall`), answers `allow-once`, and logs it as a decision, the path a
session with no resolver takes. Nothing is written to `~/.cursor/cli-config.json`.

**No messaging switch withdraws it.** A question to your own person is not a
message to another agent, so the endpoint now always listens, the server is
injected for a question-tool harness with messaging off, holding that tool alone,
and `callTool` answers it before the messaging refusal. Questions off
(`REEMOAT_ELICITATION=0`) withdraw it.

**Measured live the same day**, this tool's own definition on a loopback server:
asked for a choice, the model called `reemoat: ask_question` by itself with a title,
one question and three options, then ended its turn with *"pick an option on the
card above"*. The `rawInput` naming the tool arrived 6 ms before the permission, and
`tools/call` 3 ms after it was answered.

**Status.** Current, and **amended by Q2.251**: the call now waits for the answer,
and the message is only what an answer later than that becomes. If cursor starts
sending `cursor/ask_question`, the model has two ways to ask and both draw the same
card.

### Q2.251 — A Cursor question answered as every other harness's is

**Question.** The owner, on a cursor session, 2026-10-01: the answer appeared as their
own message — a bubble reading *"Answer to your ask_question: … — …"* — under the
answered card, *"if this is how asking is done, it must be done as for the other
agents, with no difference at all"*. Read off that session's log, the difference was
four rows, not one: the `reemoat: ask_question` tool row, a `permission_request` the
daemon had answered itself (drawn *asked: … (answered)*), the model's *"the card is
on screen — pick an option"* and its turn ending, then the bubble and a new turn.

**Why it returned at once.** Q2.250's measurement stands and was read again, this time
off cursor 2026.09.28's own bundle: `McpSdkClient.callTool` calls the SDK's
`client.callTool({name, arguments})` with no options, so the request gets the SDK's
`timeout ?? 6e4` and `resetTimeoutOnProgress ?? false` — 60 s, and no progress
notification moves it. So a call cannot wait for a person who takes longer.

**Decision. It waits as long as it may.** `poseQuestion` holds the call open for
`ASK_WAIT_MS` (50 s, ten under cursor's cut, the permission in front having already
been answered); an answer in that time settles `posedWaiter` and is the call's own
result (`answerResult`), so the turn goes on and nothing is sent — what claude's
question does. Past it, or when the client drops the POST (`res` closing aborts the
wait), the call returns `ASK_PENDING`, which tells the model to end its turn writing
nothing, and the answer goes as Q2.250's message. Skip and ✕ are results too, since
a call has to return something.

**And the transcript draws it as claude's.** The card takes the call's id —
`Session.claimPosedCall`, the newest call whose `rawInput` named the tool, cleared
when that call ends — and keeps it on `open_question_json` across a restart, so
`askedThrough` folds the call away and `askedInput` reads the questions off cursor's
MCP wrapper. A `permission_request` the daemon answered (`permissionId: null`) on a
call that asked is not drawn. A late answer's prompt carries `answers` (the
elicitation id) and is not drawn either: the card above already shows it.

**What is still different, past 50 s.** The model ends its turn and a new one starts
with the answer; it is told to write nothing, which it may not honour. Held longer
would need a client that passes a timeout, or a loop of calls each under 60 s, which
spends a model request a minute on nobody answering — not built.

**Status.** Current.

### Q2.252 — A file the agent hands over on purpose

**Question.** Q3.690 ended *"A way for an agent to hand a file over on purpose would
be a tool of its own"*. Until now a file reached its person only where a tool call
had read or written it inside the workspace; a PDF made by a shell command, or
anything in `/tmp`, was a path printed at somebody who may be on a phone.

**Decision. `send_file`, on the `reemoat` MCP server, for every agent that takes the
server.** One argument, `path`: absolute, from `~`, or relative to the session's
working folder. The daemon **copies the file as it stands** into the session's upload
store and appends one event, `file_sent`, carrying a `StoredFileRef` and nothing
else; the app draws a card, an image previewed through `previewable` like every
other, and a press saves it through `GET /sessions/:id/uploads/:uploadId`. No bytes
cross the MCP call, whose body is 256 KiB, and no route was added.

**A copy, never a path.** The log outlives the disk, which is `StoredFileRef`'s own
reason; `/files` serves only under `workspace.root`, and what somebody asks for is
as often outside it; and the card has to open after the agent overwrites the
original. So a file is sent again after it changes, and the tool says so.

**Nothing is contained, and that is the honest reading of what is.** The agent runs
as the user and can read the file already, so the path is refused only for what it
is: missing, not a regular file, over `MAX_SENT_FILE_BYTES`, or on a filesystem that
does not answer. Each is a sentence to the model (`sendFileRefusal`), never a 4xx.

**The copy, in the order that has to hold** (`Uploads.keepAgentFile`): the rate
window, `probeRealpath`, `probeFile`, an open with `O_NONBLOCK` so a pipe swapped in
after the probe cannot park it, `fstat` on the handle, the copy under a running
byte counter and the call's own abort signal, then the row — the commit point,
inserted already consumed — and only then the eviction. A refusal before the copy
leaves nothing; one inside it removes the directory; a crash before the row is
`reconcile`'s. Calls from one session run one after another, so each is charged and
bounded in the order it came. **45 s for the whole call**, its wait behind another
included, which is under the 60 s an MCP client gives one (Q2.251): a copy that
outlived its call would put a file in the chat the model saw fail, and the model
would send it again. `sendFile` asks whether the session is still running a second
time, after the copy, for a Stop that landed inside it. ⚠ A read that stalls after
the probes is given up on and still holds its thread, as the download route's does.

**A third budget.** Sent files are `f_` rows: 100 of them or 1 GiB, oldest first
out, with a rate window of their own. `sentFiles` counts a person's files alone, so
an agent's loop can neither fill a person's budget, nor be evicted by their upload,
nor answer it with 429 — the argument `keepAgentImage`'s budget already made.

**The name is the agent's, which no upload's ever was.** `sentFileName` drops
controls, bidi and zero-width characters rather than refusing: a right-to-left
override in front of `fdp.exe` reads as a PDF in a save panel.

**No messaging switch withdraws it**, for Q2.250's reason: a file goes to the
session's own person. So `mcpServersFor` now hands the server to every http-capable
agent wherever there is an upload store, with messaging off too, and `callTool`
answers it before `callRefusal`. `REEMOAT_PEER_MESSAGES=off` removes the messaging
tools, no longer the server.

**The permission in front of it is answered by the daemon, the owner's call
2026-10-02** — the agent is meant to send a file when it judges one wanted, and a
card for each would undo that. How, per harness, measured the same day on a daemon
running this tree:

| Harness | The call as announced | The permission |
|---|---|---|
| claude 2.1.287 | `tool_call` with `_meta.claudeCode.toolName` `mcp__reemoat__send_file` | none: `allowedTools` in `sessionMetaFor`, which the SDK applies before `canUseTool` |
| codex 0.160.0 | `rawInput` `{server, tool, arguments}` | none reached the client: its Guardian approved, *"Risk: low"* |
| grok 1.0.40 | `use_tool`, `rawInput` `{tool_name: "reemoat__send_file", tool_input}`, then the request on the same id | answered `allow-once` here |
| cursor | `rawInput` `{providerIdentifier, toolName, args}` (Q6.114) | answered `allow-once` here; not run live, its keychain being locked in the shell that measured |
| opencode 1.18.34 | titled `reemoat_send_file`, `rawInput` `{path}` | none arrived |

`ownToolCall` reads each shape **for its own harness only**: on another, the same
keys are whatever a model typed. grok's is the one a model does type — they are
`use_tool`'s arguments — so there the call is answered only once grok itself has
vouched for it: the `variant: "UseTool"` it adds to those arguments after parsing
them, which the request carries and the first announcement does not. A call
carrying any other key is not one, and a call that later says it is something else
is forgotten. The answer is only ever `allow_once`: the fallback to `allow_always`
that `ask_question` may take would write a rule into the person's own harness
config. An unrecognised call falls to the person's card, and so does a subagent's.

**The card stands for the call.** `file_sent.toolCallId` is the harness's own call
where `claimSentFileCall` could tell — by the path it named, else the oldest
unclaimed — and the app then draws neither that call's row nor the daemon's answer
to its permission, as it does for a question. opencode names the tool only in a
title, which nothing here reads, so there the row and the card are both drawn.

**Compatibility.** An app that predates the event draws nothing for it and still
shows the harness's own tool row, while the model is told the file was sent: so the
app ships first, then the daemons. A daemon older than this cannot weigh a stored
`file_sent`, so it is not rolled back under a log that holds one.

**Not built.** A switch that withholds the tool; several files in one call; a
folder; a bridge that would give an older app a button.

**Status.** Current. Amended by Q2.253.

### Q2.253 — send_file, checked again: what a Stop, a stall, a closed folder and a typed tag could do

**Question.** Q2.252 was built in a day and the owner asked for it to be read again
properly. Two independent reviews on 2026-10-03, one of the copy and one of the
recognition and the permission, each reproduced what it claimed with a script.

**A Stop during the copy kept a file nobody could see and dropped one somebody
could.** `keepAgentFile` inserted the row and evicted the oldest before it returned,
and `sendFile` asked whether the session was still running only afterwards — so at
the 100-file budget a Stop landing mid-copy answered *not running*, kept the row and
its bytes with no `file_sent`, and had already thrown away a file the transcript
still drew. **Decision:** `KeepFileOptions.kept`, called in the insert's own
synchronous block — after the insert, so what it records names a row that exists,
and before the eviction, so a no evicts nothing. `sendFile` answers there: a session
that went keeps nothing (`withdrawn`), and the event lands with its row or not at
all. A crash can no longer fall between the two.

**A copy that stalled was forgotten, so the next one stalled too.** The probes feed
`stall.ts`'s memory; the open and the reads, raced against the deadline, did not —
and a mount that answers metadata from a cache and stalls on data passes the probes
every time, so each retry pinned another threadpool slot. **Decision:** a source call
still out at the deadline, and out since before `DESCRIBE_TIMEOUT_MS`, is remembered
(`noteStalled`) and answered `unresponsive`; one that was merely slow stays
`timed_out`, which is a different sentence.

**Every realpath error said there was no file.** A file under a mode-000 folder came
back *there is no file at …*. **Decision:** `PathResolution` carries the errno;
ENOENT and ENOTDIR are missing, EACCES and EPERM are `denied`, a link loop and an
over-long path say so.

**Waiting in line ignored the call's own abort and deadline.** A call behind a long
copy answered its abort only once that copy finished. **Decision:** the wait is
raced against both (`settledOrStopped`), and the next call still queues behind
**both** the copy ahead and the one that gave up, or two copies would run at once.
The probes and the mount table are raced too.

**Smaller, each reproduced or read off the code.** A long name cut through a
surrogate pair, which SQLite stores as U+FFFD while the event held the half: names
are cut by code point, and a lone surrogate the agent sent is dropped. A relative
path read from the wrong folder sent the wrong file silently: every answer names
the path that was read — in the structured result too, since claude shows its model
that half alone (measured on 2.1.288) — and the tool says which folder a relative
path is taken from. Only ASCII whitespace is trimmed, a no-break space being part of
a real name. A short write is finished. All of `/proc` is refused on Linux: a
process's `environ` holds the machine's token, and the daemon's own pid was not the
only way to it — every thread is `/proc/` by name though never listed, and the
`tsx` parent carries the same environment (review, 2026-10-04). Decided twice, on the
resolved path and on the opened descriptor's device, since a folder on the way can be
swapped for a link after the probe. Old rate charges are swept.

**What a permission answer trusted.** grok's `variant: "UseTool"` was believed
wherever it appeared, including the first announcement — whose `rawInput` is the
model's own typing — and once believed it stayed: a model typing the tag made a bare
request on that id an `allow-once`. **Decision:** on grok only the request's own
`toolCall` vouches (`noteOwnCall`'s `fromRequest`). A call that completed or failed
is ended, and nothing later said about its id is believed; another tool of this
server on the same id is forgotten; `/clear` empties both maps.

**claude's request is its person's.** `allowedTools` lets the SDK run send_file
before `canUseTool`; the adapter's own comment says a request that still reaches
the client is *bypass-immune* — a safety check, or an explicit ask rule somebody
wrote. So the daemon no longer answers claude's, and a subagent's asks its person
on every harness, which Q2.252 said and only cursor did.

**Which call a file stands for.** `claimSentFileCall` took the *newest* call naming
the path, so two in flight on one path swapped ids and the first card hid the
second call's row, failure included. It takes the oldest now, and a call announced
and never claimed is dropped at its turn's end and at a `/clear`, which is what the
newest-first rule had been standing in for. `ask_question`'s auto-answer now
requires `allow_once` as send_file's does: falling back to `allow_always` wrote
`Mcp(reemoat:ask_question)` into cursor's own config.

**Checked and sound**, so nobody re-derives it: the download route's headers and
type, the three budgets kept apart, descriptors closed on every path, a FIFO or a
device refused twice, `reconcile` after a crash, look-alike server names, codex's
shape being unforgeable by a model, and none of the 29 withdrawn claude names being
an alias (the three MCP-resource aliases joined the driver's guard).

**Not done.** A per-session lock around `forgetSession`, which runs only for
sessions pruned at startup; codex's MCP approvals arriving as elicitations, which
nothing answers for it.

**Status.** Current. Amends Q2.252.

### Q2.254 — What the controls show while a released agent comes back

**Reported 2026-10-03 by the owner**: on a wake the strip under the message box
twitched, *"as if for a moment the effort switched to the default"*.

**Measured on that session's own log.** A wake opens a fresh agent on its own
defaults and `restoreConfig` then walks the session's choices back, one call each.
Every step was written and fanned out. 18963 `mode=default, effort=default` at
16:26:06.074, then `mode=auto` twice at .084, then `effort=xhigh` at .107: four
snapshots in 33 ms, and the person saw the first three, which are not their session.
`restartAgent` had always held its captured config over that window
(`snapshotConfigSource`); a wake held nothing.

**Decision.** `doResume` holds the config it captured, `wakeConfig`, from before the
spawn until the restore settles. The snapshot serves it over the agent's defaults,
and `applyAgentConfig` writes no event while it is held. When the restore settles,
one `agent_config` is written for where it landed. That is the held set unless the
agent refused or withdrew part of it, so a value that did not come back still shows
as it really is. A tap in the window was already refused as busy (`resuming`), so
nothing needed holding against a person.

Covers every wake: a message to a parked session, a daemon restart's pass and
Resume. `daemoncheck` samples the snapshot at the moment the fresh agent, still on
its default, receives the replay; without the hold it read the default, and the log
held three events where it now holds one.

**Status.** Current.

### Q2.255 — A cancel the agent never honours

**Reported 2026-10-03 by the owner** on session lyra, measured on its log and processes.
claude CLI 2.1.288 stopped mid-response at 22:16:19: a Bash `tool_call` complete, a
`ToolSearch` begun, neither run. The CLI and adapter were alive at 0% CPU with no
children, every socket to the API `CLOSED`, and its own transcript ended before that
response. Stop sent `session/cancel`; the adapter's floor (`forceCancelGraceMs`, 30 s)
resolved the prompt `cancelled` and logged *"the underlying query may still be wedged"*.
A message sent during those 30 s was queued, and when the turn ended the daemon handed it
to the same CLI, which wedged that turn too. Even SIGTERM left both processes standing;
SIGKILL ended them. Only the 3 h silence bound (Q2.231) would have ended either turn.

**Decision.** The first cancel of a turn arms `WEDGED_CANCEL_MS` (15 s, under claude's
floor). If the turn is still open then, the daemon ends it in the log as `cancelled`
(`Session.abandonTurn("cancelled")`, through the pump, so it is written once) and
replaces the process with `restartAgent`: a stop as `config_changed`, which keeps the
queue and kills past the dispose grace, then a resume of the same conversation. The
queue is handed to the new agent, never the old one. A turn ending sooner disarms it; a
repeated Stop does not push it out.

**Why the daemon and not the adapter.** We patch claude's adapter (Q6.121), but a wedge
is not claude's alone. The daemon's own clock covers every harness, and replacing a
process is what `onAgentUnusable` already does for a rejected prompt (Q7.99). An agent
that takes longer than 15 s to honour a cancel is replaced unnecessarily, at the cost of
a resume; claude's interrupt takes well under the 1.5 s `CANCEL_SETTLE_MS`.

**Two holds, from the review (2026-10-04).** A session reporting live background work is
not replaced: the restart kills the process group, and with it a dev server the agent
left running, so the turn stays open as before and End session is the escalation (the
owner's call). And a person's stop that lands while the replacement is stopping the old
agent joined that stop, whose resume then brought the agent back with the queue: `stop`
records the reason (`stopDuringRestart`), and `restartAgent` records it as the exit and
resumes nothing. Both are older than this entry for ultracode and credential restarts;
this made them reachable seconds after a Stop.

**Not changed.** A silent turn (Q2.231) still only abandons, so the next message after it
may meet the same agent; a Stop then replaces it. `daemoncheck` drives a stub that never
answers a cancel, with a message sent while the cancel is pending: one `turn_end`, a
second launch that resumes, and the message delivered to it.

**Status.** Current.

## The web client

### What the client is

#### Q3.1 — What is the web client, and did it replace the terminal client?

**Decision.** `packages/web` is a plain React + Vite + Tailwind SPA, built to
static files by `pnpm web:build` and served by the control plane at `/`. There is
no Electron, no service worker and no push. `scripts/client.ts` remains, still as
the regression driver for the terminal, but it is no longer the only way in.

**Why.** The web UI is how the system is actually used; the terminal client's
remaining job is to be a reference implementation of the token and replay logic
that the browser mirrors.

**Status.** Current — and **narrowed by Q3.605**, which is where to read before
concluding anything from the "no Electron" clause. There is a native shell now
(`packages/native`, a Tauri window); what that clause was actually refusing — a
second copy of the product with its own runtime and its own drift — is still
refused, and there is exactly one bundle. The service worker and the push are
untouched.

#### Q3.2 — How does the screen answer "does anything anywhere need me"?

**Decision.** The answer travels *with the rows* rather than living in a mode
that has to be entered. A session waiting on a human says so on the status dot
every row already carries — amber, haloed, the loudest static thing in the list —
and shows what it is waiting for instead of its path. Its machine's header
carries the count, *in place of* the live count rather than beside it.

**Why.** Because the signal is on the row, the answer is true under every filter,
and approving is two taps from open with the command or diff on screen — never a
wall of transcript to scroll past first.

**Status.** Current

### Nothing in a row mounts sideways

#### Q3.3 — Why is there no warning triangle in front of the status dot?

**Decision.** The triangle is deleted. The dot and the subline already say a
session is waiting; a third glyph said it again.

**Why.** It was the only one of the three that *moved* anything: mounting it
pushed the dot, the name and the subline 21px right, at exactly the moment the
row mattered most. The general rule the screen now follows is that **nothing in a
row mounts sideways into another control.** Two shapes of the problem kept
recurring — a glyph that appears when a session becomes interesting (the
triangle; the pin beside a session's name), and a readout whose *own* width
changes (the context percentage at `9% → 10%`).

**Status.** Current

#### Q3.4 — When something in a row can change width, which of the three remedies applies?

**Decision.** *Delete it* when the thing is redundant — the triangle said what the
dot and the subline already said, so it is gone rather than reserved. *Reserve its
slot* when it is the only copy — the pin, the home spinner and the `Toggle`
spinner each get a fixed box sized by exactly what it holds, drawn empty
otherwise. *Move it off the row* when it is neither redundant nor fixed-width —
the context percentage went into a popover.

**Why.** The choice between the three remedies is the interesting part; a single
blanket remedy would reserve space for redundant things and leave genuinely
variable ones unfixed.

**Rejected.** A widest-label constant. Because the ring left behind by moving the
percentage off the row is one width at every value, there is deliberately **no**
such constant anywhere — looking for one is looking for something that was
considered and rejected.

**Status.** Current

#### Q3.5 — Which mounting glyphs actually displace anything?

**Decision.** Measured rather than assumed: a mount only displaces what lies
between it and the nearest `flex-1` sibling.

**Measured.** The spinner in the home header displaces nothing; the pin beside a
session's name displaces the name.

**Why.** Reserving a slot costs layout and code, so it is spent only where a mount
really moves a neighbour.

**Status.** Current

#### Q3.6 — Why do centred things in the transcript slide left when a card is expanded?

**Decision.** The cause is the scrollbar, not anything in a row. `index.css` sets
`scrollbar-width: thin` under `@media (pointer: fine)`, which is a bar that takes
layout width rather than one that floats over the content the way a phone's does.

**Why.** A box crossing from fitting to not fitting loses about eight pixels of
`clientWidth`, and everything *centred* inside it re-centres by half of that. In
the transcript the trigger is expanding a tool call or a subagent card: it adds
height, height crosses the threshold, and the control at the top, the turn-end
line, a gap marker, the context-cleared divider and the right-hand edge of every
message bubble all slide left together while nothing left-aligned moves at all.
That asymmetry is what made it hard to place — it reads as *some* things shifting.

**Status.** Current

#### Q3.7 — How is the scrollbar shift fixed, and why not globally?

**Decision.** Reserve the gutter: `scrollbar-gutter: stable`, applied as
`.scroll-stable` on the three boxes whose width other things are measured against
— the transcript, and `AppShell`'s two panes. The one row that was centred for no
reason, the control at the top of the transcript (a `w-full` `