Invariants
Every invariant a test enforces, a paragraph each, with the test that holds it.
The invariant paragraphs CLAUDE.md carried until 2026-10-25 under ### Invariants that tests enforce — don't break them casually, moved here verbatim by measured-console phase 5. Each names the test that
holds it; CLAUDE.md lists them by name. The architecture they protect is viewer/docs/architecture.md.
Invariants that tests enforce — don't break them casually
- Engine parity.
viewer/test/engine-parity.test.tsre-derives every plan's board from the JS parser and asserts it matchesphase-graph.sh. Run it after touching either parser. - F5 — one sizing source.
scripts/sizing.envholds every size weight and per-model budget, andscripts/mcp.envthe per-attached-server surcharge.phase-graph.shsources it,references/sizing.mddocuments those exact numbers, andviewer/server/analysis/graph.tsreads the same file. Change a number only there. - F1/F2/F3/F20/F21 — structural lint (malformed phase cell, undefined dependency, cycle, a table
whose columns cannot be located by name, a cell the parser read but could not believe) lives in
phase-graph.sh --lint;validate.shdelegates to it and adds handoff body/consistency checks. F20 and F21 are F1-tier gates, not advisories —LINT OKon a plan whose scopes are fiction is how two sessions end up in one working tree. F14 rides the same arm and has GATED since 5.0.0 (verification-empty-open): an open, not-done phase whose §Verification holds nothing runnable. It asks only "is anything runnable?"; what the autopilot actually parks on — a single fragment it will not run underPerson-check: halt— is answered by ONE review,runner/verify-review.ts, which boarding, the start response, the plan page, plan health, the repair gate and the prelude's fifth probe all ask (2026-09-18, run f0da619a: five weaker readers had called abatsline fine). F15 rides the same arm as a WARNING (stderr, exit untouched): a plan or phase naming an MCP server, an account or a credential this machine has not registered — the console tells bash its registries throughPE_MCP_SERVERS,PE_ACCOUNTSandPE_CREDENTIALS(unset disables the check; set but empty is a real answer), and the run-start prelude is the gate that acts on it. F16 rides it too: a §Verification command that waits on an external clock (gh run watch,task deploy,--watch/waitflags, long sleeps) — runnable by F14's test, unfinishable inside a session's turn; split the phase behind a Gate-check or expect a runtime park. F17 and F18 complete the family, both born from one measured incident class (16 verify-failed halts, 15 spurious): F17 warns when a §Verification lead is not installed on THIS machine (rga shell function elsewhere,pythonmeaning python3) — the runner now SKIPS such a command at verification (recorded, never failed; a phase whose every check is skipped parks) — and F18 when a cwd-sensitive lead (pnpm,docker, …) has no**Verify in:**, since verification runs at the repository root.scripts/verify.envis the F5-style single source for those two word-lists, sourced by bash and parsed byrunner/verify-env.tswith a drift test. F19 is next: an open plan that cannot progress at all — nothing ready, nothing in flight, and a QA verdict holding every remaining phase. F22 and F23 close it, both about a §Verification that will be read by a machine: F22 when a bring-up command sits inside it (move it to- **Setup:**, which runs BEFORE verification and can never colour a phase red), F23 when an expected failure is stated in PROSE beside a command (the runner reads exit codes, not sentences, so it calls that phase red). F28 joins them in 5.1.0 (land-needs-lane): a phase that LANDS from a checkout it shares lands whatever else is in it. F30 (note-target-done): a forward note addressed to a phase that is already done — the near miss of F26, and its own id because an id that both gates and warns cannot answer "did the lint fail?". F32 (verification-fleet-wide, control-tower phase 62, #47) closes the family: a §Verification line whose exit code reads fleet-wide state (task hygiene,task drift, a machine-wideps … | grep) can never pass while another plan or the run's own branches are live, so the lint names what to write instead — only forms the task accepts (phase 89, #71 PEH-3: phase 62's--plan/--rootwere refused or ignored), per read, fromFLEET_WIDE_READS/FLEET_WIDE_SCOPEDinscripts/verify.env. F33 (repos-outside-root, control-tower phase 82, #94) names a Repos token that is not a path under the docs root, in a plan whose other cells are: that phase alone is refused isolation — the run keeps its checkout, which one such cell used to refuse for every phase. F34 (repos-root-token, control-tower phase 90, #154) names a Repos cell that says the superproject's own name in a phase whoseFilesbullet lists only submodule anddocs/paths — the token mounts the whole root and holds the phase against any run on the root repository, for nothing — and F35 (checkout-inert, the same phase) aCheckout:value other thanmain,masterordefault, which the console carries and never acts on. F36 (wait-window-short, control-tower phase 14, #40) names aWaits on:max shorter than the timeout of the workflow it watches — told throughPE_WAIT_TIMEOUTS,<ref>=<minutes>pairs the console resolves once pergh:…#run/<id>(watch-refs.ts); unset is off, set but empty an answer. F38 (human-step-no-proof, control-tower phase 41) names a- **Human step:**with noproof:ref. F39 (setup-deps-missing, control-tower phase 106, #185) names a §Verification line that runs a package-manager script or a.venv/bin/*binary in a repository the phase's resolved Setup installs nothing for — a run's isolated checkout mounts every repository as a fresh worktree with nonode_modulesor.venv, so the line is red at baseline before it tests anything. The line's repository is its**Verify in:**(the same reader--verify-in Nprints), moved by acdand the package manager's own directory flag; Setup is resolved from the SAME directory, because the runner hands both one cwd, and an install counts only for its own ecosystem. Its word lists stay inphase-graph.sh, notverify.env: no runtime reader asks them. The advisory family is therefore F15–F19, F22–F23, F28, F30, F32–F36, F38–F41, F43, F45–F46 and F49–F52 — twenty-five ids (F49,amendment-malformed, measured-console phase 30: the two halves of a plan amendment — a phase's- **Amendment:** Kand the*(Amendment K, …)*note under the title — disagree; F50,directive-mid-line, F51,path-no-dir, and F52,verification-shared-checkout, measured-console phase 2: a directive label where no reader reads it, a Repos token orVerify in:path that names no directory under the docs root, and a §Verification line thatcds into the shared checkout's absolute path; F45,proof-unparsable, and F46,proof-script-run-branch-only, one-pool phase 13: aHuman step:proof the check reader would refuse, and acmd:proof whose script is not on its repository's trunk — both read throughscripts/cmd-ref.sh, the bash twin of the onecmd:reader; F40,human-step-no-why, control-tower phase 130: aHuman step:with nowhy:, given its kind's default reason; F41,human-step-no-guide, and F43,errand-row-unbound, turn-by-turn phase 2: a task or decision with no guide file, no options and no bound## Operator errandsrow, and a table row no bullet names —HUMAN_STEP_GUIDED_KINDSand the one awk,errand_refs, stay inphase-graph.sh); F14 gates, and so do F26 (note-target-unknown), F27 (land-word-unknown), F29 (landed-gate-unknown-phase), F31 (permission-mode-unknown, control-tower phase 11): aPermission mode:line or bullet whose word is not one ofscripts/permission.env's, which the reader falls through exactly as F27's do — and F37 (human-step-superseded,human-step-kind-unknown,human-step-field-invalid, phase 41): aHuman step:bullet the reader skips, the 5.1.0<who, what, proof ref>spelling named with the grammar that replaced it. F42 (guide-file-missing, turn-by-turn phase 2) gates too: aHuman step:'sguide:that is not a file under the docs root. The bullet's three newer keys —options:,recommended:,deadline:— are validated by both parsers and printed by neither:--human-stepsstays byte-identical, and the console reads them, and the## Operator errandstable (server/parse/errands.ts), from the plan. A Pro tree's engine emits one more advisory, outside the free count above: F44 (model-unserved, one-pool phase 4), a**Target model:**or- **Model:**that no account registered on this console serves — a Claude name is the machine login's, any other id must be one the console's API-key accounts' providers list, told to the engine asPE_MODELS(unset is off, set but empty an answer). - An empty
readyset is four facts, so the engine says which.--memory-blockis the only engine command the runner reads, and it emitted five bucket lines — collapsing "finished", "all in flight", "closed" and "nothing can ever move again" into one silence. It now also emitsblocked: 2<-1(qa:fail) 4<-2(not-done),3(qa:pending)when something is waiting, parsed intoBoard.blockedBy/Board.qa(engine.ts), and that is what lets a deadlocked plan halt withkind: 'plan-deadlocked'anchored on the phase actually holding it. The line is additive —readMemoryBlockignores lines it does not know — but it is a CONTRACT now: change the shape inphase-graph.shandengine.tstogether, and runviewer/test/engine-parity.test.ts. - QA gates only when the plan says so.
QA_GATINGderives fromqa_mode(), not fromtest-status.mdmerely existing:**QA gate:** off(→waived) means recorded verdicts stop holding dependents, which is the only release there is. It is opt-in and in writing, per plan —failunderongates exactly as before. Two rules ride with it: apendingrow holds dependents as hard as afail(so--boot-promptnames the finish-time QA duty an unattended session would otherwise never learn), and anything that CREATEStest-status.mdmust backfill already-complete phases aswaived—new-handoff.shalways did,qa-record.shnow does, and the console's own "turn QA on" reaches the second one. - bash 3.2. The scripts' target runtime is macOS system bash.
tests/helpers/test_helper.bashforces/bin/bashfor every script under test — no associative arrays, no${var^^}, nomapfile. - Never implicitly build the client.
client/distis gitignored; the console warns when the build is stale and serves an explanatory page when it's missing, but nothing builds on its own. The launchd boot path especially never builds, so a crash loop can't burn its throttle interval. sw.jsstays at the root and stays push-capable. Live push subscriptions are bound to('/sw.js', scope '/'); moving or renaming it unsubscribes every device silently.check-dist.mjsguards this and several other one-time regressions — read its header before weakening an assertion.- The console binds to
127.0.0.1, always.--remoteadds an allowlisted proxy hostname and identity check; it deliberately does not widen--host. TheTailscale-User-Loginheader is only trustworthy because nothing but the proxy can reach the port. - Permission
denyis identical across all three run profiles. Profiles move only the ask list. The classifier,classifyTool(runner/approvals.ts), reads deny FIRST, then answersholdforQUESTION_CLASS(AskUserQuestion) from its own constant — never from the ask list, which a profile, a strike or a written allow rule can empty; aholdis answered by the relay on an armed run and by the plan'sambiguityrow everywhere else. The two publishing asks inOPEN_PR_ASK(git push,gh pr create) are never auto-granted: only apermission.destructiverow in the plan's manifest lets the console answer one, and that answer is announced. The PreToolUse hook fails open and carries workflow, never safety. The Stop hook rides the same settings file with the same philosophy: it nudges a session ending with neither a handoff nor a declared outcome (at most twice), lets one go whose own subagents or monitors still run in the background (hook.stop-awaiting— their notification starts its next turn), fails open, and the runner's own exit-time outcome check — not the hook — is the load-bearing enforcement. - The outcome protocol is the session→runner channel; prose never is. A session declares how it
ended via
scripts/phase-outcome.sh→ one atomic JSON file atPE_OUTCOME_FILE, read once, journalled, consumed, staleness-guarded twice (deleted pre-spawn;written_atchecked against the attempt).waiting-externalparks the phase aswaitingand the resume is the phase's own session (--resume), never a pty agent — but a session is resumed only while it is worth resuming: one that ended at ≥ 250k tokens of context and is cold (idle ≥ 55 min) or under another account, that declaredpartial --reason budget|context, or that the console checkpointed is boarded FRESH with the resume brief instead (phase.resume-policy). The one gate,resumableSession, asksresumePolicy(runner/usage.ts, where the numbers live) for every--resume, after the gone and live checks and before any transcript port. The handoff.mdstays the engine/human contract; its status vocabulary (complete|in-progress|blocked|pending) is frozen —waitingis a runner state, never a handoff status. The supervisor chat (Pro, control-tower phase 27) resumes through the same gate, exempt from its cold rule: however long it sat idle it is resumed, and one that ended at ≥ 250k tokens of context starts a visible new thread instead. - Reconcile closes records, never re-runs them. The drive loop's reconcile pass (and the
read-path resolver) flips a record the board has overtaken to
done("closed outside this run", or "closed while checkpointed — the board reads done; not verified by this run" when this run had started the phase) and dissolves halts anchored to it; afailedrecord whose phase the board does not show done is untouched. Recovery is resolve-first (board re-read before any launch), the session API is the first vehicle (--allow-run), the pty agent is for plan repairs (--allow-agent) and people, and "found nothing wrong" is a recorded outcome (no-defect), not a failure. - A process is a fact; a record is a claim — settle the claim against the fact. The class this
closes: a phase-9 session outlived its console, sat in state
Tfor three and a half hours holding its session id and its lock, and the run record went on readingrunningthe whole time — because the record was settled againststate.status, which is another claim by the same dead writer. Three clauses, all pinned byviewer/test/invariants.test.ts. One place asks:server/pid.ts'sprocessStateis the only probe — the onlykill(pid, 0)and the only shelledpsinserver/— because two probes disagreeing about one process is how a stopped session read as alive (ps -o comm=answersclaude;ucommanswers the version string the CLI execs, so it iscomm). One place acts:server/runner/signals.tsis the only place that signals one — SIGCONT first (a stopped process queues SIGTERM and never runs its handler); then SIGINT, once, to the CLI leader itself and never its group — SIGINT closes the turn and writes theresultthe CLI books turns and dollars in, SIGTERM does not — withINT_GRACE_MS(5 s) to leave; then SIGTERM to the process GROUP (-pid, so the child's bash, MCP servers and subagents go with it); and always a SIGKILL backstop, awaited rather than armed on asetTimeoutthat dies with the console that set it. The MCP probe takes the same ladder with the interrupt rung off — it has no turn to close — so its ending is SIGTERM to its group,PROBE_TERM_GRACE_MS(3 s), then the SIGKILL itsnpxshims need. The lint allows seven other.kill(files, each by name and reason (KILL_ALLOWEDin the test), and fails when that set changes in EITHER direction, so a reason cannot rot. The handle outlives the console that made it:syncMirrorMERGESstate.childrenand never rebuilds it, so a ChildRef for a phase this console holds no lane for survives byte-for-byte and only the probe may drop it; identity is the(pid, procStartedAt)tuple, neverchild.startedAt, which is the PHASE's clock and is hours off on a retry. And no reader ever sees work in flight over a process that is gone —settleInFlightRecordsruns on the read path for every run status, not only the in-flight ones, because the statuses a dead console leaves behind (parked,halted,interrupted) are exactly the ones the old early-return skipped. - The ladder climbs; a person is asked once, with an errand (since 2.3.0). A stopped phase is
never healed by its halt kind:
runner/situation.tsclassifies it from evidence (board, handoff, record, lock + the session registry, the tree, the transcript, the declared outcome, gate, MCP, health) into one of the sixteen situations inshared/situation-model.js, andrunner/ladder.tsclimbs the rung table inshared/ladder-model.js— never the same rung twice per situation per phase, bounded by attempts AND dollars (theladder*prefs), every rung journalled (phase.situation→phase.rung→phase.errand). Exhaustion parks the phase with ONEErrand {need, how, tried}and the run keeps driving; only the errand is pushed (needs-you).Service.converge(converge.ts) is the one unattended orchestration — at boot, on change, everyconvergeEveryMs, a minute after a halt, on Recover & continue — and it acts THROUGH the runner (startRun({resumeRunId, reboard})), never beside it; it touches only runs the operator did not stop, never a resolved one, and a console without--allow-runor with--no-convergenever converges by itself. Presence is three-valued (live·ended·unknown,sessions/registry.ts): only a lock whose ownsession=the registry shows ENDED is debris before its lease; an owner/time match is display only and releases nothing. The three vocabularies (situation, ladder, recovery) are imported by identity by server, client and tests — add a situation, rung or class in the shared file and nowhere else. The console never spawns a reviewer itself; what the QA rungs do is resume the PHASE's own session and instruct it to dispatch the fresh-context subagent and record the verdict (qa-verdict,qa-fix), which is why both QA situations are actormachine.autoClass: 'ladder*'inKIND_PROFILEis still a word for a surface, never a launch. Every rung's vehicle has a driver inVEHICLE_DRIVERS(console·writes·agent·never, held total bytest/ladder.test.ts): a rung this console cannot drive is skipped, a table with nothing drivable left is exhausted and its errand names what is in the way (undrivableSentence), and nothing waits on a rung nobody owns. A halt only a person's press relaunches is inPRESS_ONLY_HALT_KINDS(converge.ts) —failure-streak, because relaunching by clock was what reset the streak, andcredential-refused, because a retired credential only meets the same wall — and converge closes just the run-level relaunch to them; the phases' own ladders still climb. - The engine is the authority on gate state, including for the healer.
collectEvidencetakes agatedep andService.evidenceDepssupplies it as a live--gate-statusread; therecord.gatefallback is only for a read that could not RUN. It once had no dep at all, so the classifier judged a phase by the snapshot stored before the operator approved, answeredgated-manualforever, and left the run halted while the board and the engine both saidclear. For the same reasonevidenceFingerprintincludes the gate's stamp: approving moves neither the run, its records, nor the board word, so without it converge skips with "nothing has changed" against a gate a person has just opened. Both are pinned by tests — don't drop either. - A foreign unexpired lock queues, never terminally parks. The scheduler owns the wait (holder
named, lease end shown; woken by the docs watcher, a lease-expiry timer, and the idle poll;
bounded by the 2-hour lock-wait cap); the boarding belt-check owns only the grant→spawn race
window and resolves it back to the queue. The runner keepalives its lane's lock every lease/3
under the shared
PE_OWNERand stands down — never fights — on a foreign takeover. - A shipped default is struck by name —
denyincluded, since 2026-08-06. Removing a default records it underremoved.<list>in the policy file rather than copying the list out and editing it — a copied list would freeze the defaults at whatever version the first edit saw, and an upgrade's new rules must still apply. Restoring is deleting that name. The deny half is a deliberate reversal of the old "no browser can unpick the wall" shape, on the operator's explicit ask; its terms are: the browser confirms a shipped-deny strike before writing it (the one confirm on the policy page — it widens what every future run may do, the CLI-side settings included), the per-run push carve-out never resurrects a struck wall, and profiles still never move deny.approvals.test.tspins all three. - One spawn door, and every start names its door. Every
claude -punderrunner/goes throughRunnerBase.spawnSession, which is also the one writer ofphase.session, and eachSESSION_MODESmember is exactly one call site — a session spawned beside the door is a session no census can see (invariants.test.tsclause 1). EverystartRun(site names its door fromSTART_DOORS(SLF-1) and passesaccountIdorresumeRunId(ACT-1). The automatic doors are counted by the per-instance start ceiling (server/start-ceiling.ts: 40 starts and $250 of reported session spend per sliding hour,ceilingStartsPerHour/ceilingUsdPerHour);OPERATOR_DOORnever is. The actor is DERIVED rather than defaulted — a route builds it withactorOfRequest(server/api/actor.ts), an automatic door withdoorActor— and everyphase.situation/phase.rungline carriesby(RCV-9). EverytrySwitchAccount(is preceded byleaveAccount((ACT-5), and an exported setter onAccountsneeds a production caller (ACT-3). - Halts, parks and waits have kinds.
halt()andpark()take aHaltKindfromshared/recovery-model.js— a writer naming a word the list lacks is a type error, andwaiting-is-a-state.test.tsholds every written kind toHALT_KINDSand to exactly one ofPHASE_HALT_KINDS/RUN_HALT_KINDS. A wait issetRunState(state, 'waiting', { kind, until })with aWAIT_REASONSkind — a standing verification or approval card is thepersonwait, never arunningrun with no child (run-lifecycle.test.ts). - Every journal and log name has a row in
docs/journal-events.md. Each'<phase|run|policy>.<name>'literal underviewer/server/and eachlog.info/warn/errorfirst argument is documented there, checked in both directions; a name is never composed from a template literal, and a retired event keeps its row withretired <version>in the emitter column (docs-parity.test.ts). A new event is a new row in the same commit. - The relay is one call site, behind two floors.
server/relay.tsis the only file that callspickAnswer(, the service entersrelayQuestion(from one place, and inside it the deny list is read before any window opens (invariants.test.tsAC-14). The relay arms only atRELAY_CLI_FLOOR(2.1.268), judged fromsystem/init.claude_code_versionasserver/cli-init.tsremembers it, never fromcapabilities; every session that is not armed carries--permission-prompts noneatPERMISSION_PROMPTS_CLI_FLOOR(2.1.259) or later, and under a known older CLI the flag is skipped and journalled (run.permission-prompts-skipped).phase-console doctorcompares the installed CLI against the relay floor. - Tests never touch the operator's state. Every node test file that imports from
server/imports./state-sandbox.tsfirst (or redirectsXDG_STATE_HOMEinline before that import — static imports evaluate in source order), and a console is spawned only throughtest/spawn-console.ts.test/state-isolation.test.tsenforces both, and belongs in every verification batch that runs node tests. The usage belt is the same idea for the network (one-pool phase 30, #241): aUsagePollerin a test process with nothing injected reads no meters at all (usageSealedinserver/accounts/usage.ts— it records "a test process reads no usage endpoint" instead of asking). A test that wants a reading gives the pollerfetchFnorbase, or names an endpoint withPHASE_CONSOLE_USAGE_BASE.
- The fleet supervisor is a second entry point, not a console.
viewer/fleet/(com.phase-console.fleet) binds127.0.0.1:4100and never wider. A phone reaches it through one of TWO doors, and the machine profile'sreach.modesays which: the one Tailscale Serve handler (443 → 4100), or the DIRECT door (fleet/direct.ts) — a second, HTTPS listener the supervisor opens only whilereach.modeisdirect, on the one address and port the profile names (a literal, never a wildcard; a public address only oncereach.acknowledgednames that exact host), opened, moved and closed from the profile while the supervisor runs, and never a reason for it to exit. The direct door judges its caller itself: aHostit does not admit is 421, a__Host-phase-consolecookie its device register verifies is a paired device, and anyone else meets the pairing page and nothing more. Neither door forwards a caller's identity headers: the supervisor drops them and ASSERTS the login it verified (a Tailscale login, or a device'sdevice:<slug>#<id6>), under a header proved with the bearer in<stateHome>/fleet/fleet.token, which a console reads as a second trusted-proxy shape that is alwaysremote— so presence and the permission hooks, which answer only alocalcaller, are never reachable through it (/c/<id>/hooksis a 403). Console pages mount at/c/<id>/; the supervisor serves the push worker at/sw.jswith scope/, and/c/<id>/sw.jsis a 404, because a console page registering a worker under the mount would take the supervisor's. So a mounted page registers no worker at all, and every API, event and socket path the client asks for is prefixed throughclient/src/lib/base.ts'sBASE.