# Invariants index

Every row here cost a real bug when it was violated. This is a flat index — enforcement code first, then
the test that pins it. Prose explaining *why* each one matters lives at the "enforced by" link (don't
duplicate it here; this table is for finding things fast, not for the full rationale).

| Invariant | Enforced by | Tested by |
|---|---|---|
| `serializeDecision` must emit `updatedInput: { questions, answers }`, never `{ answers }` alone (O7 bug: dropping `questions` throws `q.map` in the in-VM binary, the answer never reaches the model) | `src/agent/session.ts` (`serializeDecision`) | `test/seams.test.ts` (regression guard — `it("permission allow / question answers nest under inner response")` asserts `updatedInput.questions` survives `serializeDecision`; tamper test — `it("tamper: controlOut envelope with questions dropped trips replay_protocol_fidelity guard")` trips the `replay_protocol_fidelity` guard) |
| `replay` must re-serialize `controlOut` via `serializeDecision` on the token-free lane — a new decision *kind* must extend both `serializeDecision` and `deserializeDecision` (declared inverses) | `src/agent/session.ts` | `test/seams.test.ts` (the "Per-kind round-trip tests" block — `it("round-trip: permission allow")` and one per decision kind — assert `serializeDecision`/`deserializeDecision` are inverses; the tamper test above exercises replay's re-serialization path directly) |
| A new `assert:` key must be classified into exactly one replay bucket (content vs. filesystem/egress) in `replayCassette` — an unclassified key throws at first replay instead of silently no-op'ing on CI | `src/run/cassette.ts` (grep `is not classified for replay`, or the `// deterministic exhaustiveness check` comment just above it — the check over `AssertionSchema.shape`) | no dedicated test of the throw itself — it fires unconditionally on every `replayCassette` call (exercised across `test/seams.test.ts`, `test/cassette-protocol.test.ts`, and others); a newly-added unclassified key would surface as a thrown error the next time those suites run |
| `evaluate()` (`src/assert.ts`) is synchronous — no model-call/LLM-judge assertion without an explicit async-refactor decision (would break determinism + the replay lane) | `src/assert.ts` | `test/assert.test.ts` (directly exercises `evaluate()` across every assertion kind — no test asserts non-`Promise` return explicitly, but any accidental `async` conversion would surface as a broken call site across this file) |
| `profile:` is retired vocabulary — no alias remains; it must reject as an unknown key like any other typo, never be reintroduced as a first-class concept | `src/types.ts` (`ScenarioObject`'s `z.strictObject`) | `test/cli-json.test.ts` (grep the test name: `it("retired scenario field \`profile:\` is rejected as an unknown key (no alias)"`) |
| Answer paths compose as a `Chain` — scripted (`--answer`/`--answer-policy`) is tried first, falling through to the terminal decider (`--decider-llm`/`--decider-cmd`/`--decider-dir`/`on_unanswered` policy) only on ABSTAIN, never both live-decided at once; the bare `--on-unanswered llm` CLI flag is rejected and redirects to `--decider-llm` | `src/decide/decider.ts` (`buildDecider`, `Chain`), `src/cli.ts` (decider flag validation) | `test/seams.test.ts` (`it("Chain walks scripted → parity default → terminal; buildDecider(fail) throws on unscripted Q")` — the Chain composition) + `test/cli-json.test.ts` (the bare `--on-unanswered` flag is rejected/redirected — e.g. `it("--on-unanswered external (removed) → usage error redirecting to --decider-dir")` and the invalid-value guard) |
| CLI commands must resolve positional arguments via `positionals()`/`parseArgs` — never a first-non-dash-token scan (silently grabs a flag's value as a positional) | CI grep guard, `.github/workflows/ci.yml:34-43` | `test/cli-structural-guard.test.ts` (every command rejects an unknown flag) |
| `resolveAgentBinary` (with the newest-staged-binary fallback) must be defined only once, in `src/baseline.ts` — it was once duplicated inline in `container.ts`/`hostloop.ts` without the fallback | CI grep guard, `.github/workflows/ci.yml:44-53` | `test/agent-binary-single-source.test.ts` |
| Usage/runtime CLI errors must route through `fail(...)` (never a bare `log()` + `process.exit`) so `--output-format json` always gets the shared envelope; an intentional exception needs the `cli-error-envelope-exempt` marker | CI grep guard, `.github/workflows/ci.yml` (the grep scans `src/cli.ts`, `src/run/doctor.ts`, AND `src/run/cassette.ts`) | `test/cli-json-error-envelope.test.ts` |
| npm package version, package-lock version, companion-skill version (marketplace.json/plugin.json/SKILL.md), the skill's bootstrap floor, and README's floor references must all agree (6 sub-checks) | `scripts/check-versions.ts` | `npm run check:versions` (CI step `ci.yml:30-31`), plus `test/check-cassette-version-claims.test.ts`, `test/check-fingerprint-field-claims.test.ts` and `test/check-design-scope-note.test.ts` for the invariants whose logic is an exported pure function |
| A cassette's `scenario.assert[]` is validated against the (strict) assertion schema on every load — a malformed/unrecognized assertion in a same-or-older-version cassette is a hard reject (never silently vanishes from replay evaluation); a newer-version cassette warn-tolerates | `src/run/cassette.ts` (`readCassette`) | `test/cassette-protocol.test.ts`, `test/seams.test.ts` |
| `--repeat --max-budget-usd` and `--matrix` truncation both fail the batch by default when incomplete — "incomplete is not green" — opt out per-flag (`--allow-budget-stop`, `--allow-truncated-matrix`) | `src/run/repeat.ts` (`rollupPasses`), `src/run/matrix.ts` (`buildMatrixRollup`) | `test/repeat-rollup.test.ts`, `test/matrix.test.ts` |
| `--max-budget-usd` without `--repeat` decides BEFORE any run of the invocation spends — every resolved scenario is checked ahead of the loop, and the `--matrix` path is checked ahead of its cells, so a refusal never lands after a sibling has already been paid for. With no priced history it degrades loudly and proceeds rather than implying a cap it cannot enforce | `src/cli.ts` (`preflightBudget`, the pre-loop + matrix call sites), `src/run/run-index.ts` (`budgetPreflight`, `scenarioCostHistory`) | `test/run-index.test.ts` (`describe("budgetPreflight …")`), `test/cli-stats.test.ts`, `test/cli-arg-guards.test.ts` |
| A cassette carries a `sessionFingerprint` (v9+, content-SHAPE hash of the AUTHORED, pre-resolution session — connected folders, plugins, skills, mcp and egress config, web_fetch approved domains, plus projects and agent_env when set; authored so the hash stays relocatable across checkouts) checked ONLY by `verify-cassettes`, deliberately excluded from `computeStaleness`/`checkStaleness` so it never affects the default replay verdict | `src/run/cassette.ts` (`sessionFingerprintDrift`) | `test/session-fingerprint.test.ts` (`describe("buildSessionFingerprint (function-level)")` — covers `buildSessionFingerprint` + `sessionFingerprintDrift`) |
| A corrupt `timeline.jsonl` header must read as "no timeline" (`undefined`) at every consumer, never a present-empty timeline — a header-corrupt read that folds to `[]`/`undefined` events bakes a novel "ran, no activity" shape into a recorded cassette instead of the honest evidence-unavailable state | `src/run/execute.ts`, `src/run/chat-result.ts`, `src/run/cassette.ts` (each checks `!timelineRaw.headerCorrupt` alongside `malformedLines === 0` before trusting a `readTimeline()` result) | `test/central-cluster-guards.test.ts` |
| `evidenceErrors.egressParse` must be included in the presence gate that decides whether `RunResult.evidenceErrors` serializes at all — omitting it silently absorbs the one counter that exists specifically to surface dropped egress-proxy lines | `src/run/run.ts` (`evidenceErrorsForResult`) | `test/central-cluster-guards.test.ts` (`describe("#39 egressParse reaches result.json (presence-gate fix, not just parseEgressLine)")`) |
| Every GUEST path must be composed from the tree the harness stages — `<sessionRoot>/mnt` — and never from a baseline's recorded `mountLayout.mntRoot`. A recorded mnt root that differs is a fidelity divergence reported at spawn, not a path to build: honouring one put `--plugin-dir` a directory above the staged plugin tree, so the plugin under test never loaded. Guest paths anchor on `sessionRoot` (the bind target), never `cwd` (where the agent merely starts); at microvm the root is lima's mount point and a baseline recording another cwd is REFUSED, since the guest `cd` would otherwise succeed at the wrong dir. A baseline with no `spawn` block is refused at the sandbox tiers outright — its toolset and pre-approvals are that block | `src/baseline.ts` (`GUEST_MNT_SEGMENT`, `resolveMounts`, `recordedLayoutDivergence`), `src/runtime/argv.ts` (the spawn-block refusal + divergence warning), `src/runtime/microvm.ts` (`microvmGuestSessionRoot`), `src/prompt.ts` (routed through `resolveMounts` instead of a private derivation) | `test/guest-path-layout.test.ts` (a table over every shipped baseline, oracle taken from the STAGING side, plus a synthetic spawnable-but-divergent baseline — the shipped divergent one has no `spawn` block, so only the synthetic case exercises the path composition) |
| The session root handed to `Run.setSessionRoot` must come from the SPAWN that started the agent, in the same path space the agent reports its own paths in — host at hostloop (native agent), the VM `/sessions/<id>` at container — never re-derived by the caller. A host root measured against VM-reported paths puts nothing inside the root, so every presented file classifies `leaked: false` and `no_scratchpad_leak` (which evaluates at container and nowhere else) passes vacuously over a real copy-failure leak | `src/runtime/container.ts` + `src/runtime/hostloop.ts` (each returns the `sessionRoot` it used), `src/run/execute.ts` + `src/run/chat.ts` (pass it through), `src/run/run.ts` (`notePresentedFiles`' cwd-at-or-inside-root space check counts the batch malformed rather than grading it in the wrong space) | `test/session-root-path-space.test.ts` (per-tier geometry, the fail-closed space mismatch, live-vs-replay agreement, and the spawn-reports-its-own-root seam) |
| `present_files_called` must read presence from `RunResult.presentFilesCalls` (the invocation count, derived from the tool_use input's SHAPE), never from the classified `presentedFiles` list — that list drops any path it cannot resolve, which a host-path redaction policy guarantees at `hostloop`, so reading classification claimed "the tool was never called" about a run that called it AND made every such scenario unrecordable (record refuses a cassette whose verdict redaction changed) | `src/assert.ts` (the `present_files_called` branch), `src/run/run.ts` (`presentFilesCalls`, counted at the `present_files` tool_use) | `test/present-files-redaction-invariant.test.ts` (drives the real `assertRedactionVerdictPreserved` over a hostloop cassette redacted with the shipped policy; the CONTROL case pins that the un-redacted side genuinely passes, so a shared tier-gate failure can't green it) |
| An anonymous sub-agent dispatch's synthesized `toolUseId` must derive from the assistant message's own (stable, unique) id, never a process-lifetime counter — a counter-derived id isn't reproducible across record→replay and can collide across messages in a single run | `src/agent/session.ts` (`parseMessage`) | `test/session-parse-guards.test.ts` (the regression test asserting synthesized dispatch id uniqueness across messages, in the `parseMessage` describe block) |
| The pre-commit cassette gate must fail CLOSED: any `verify-cassettes` outcome that is not a proven clean `0` blocks the commit, and an exit 3 blocks unless its cause is *staleness* specifically. `ci.yml` triggers on `push: [main]` and `pull_request`, but the documented local workflow lands with `merge --ff-only` into `main` and pushes afterwards — so for the maintainer, the only person who records host-inheriting cassettes, CI is not a pre-publication gate and the hook is not one layer of two. It is the only gate, and anything it waves through reaches public history | `.githooks/pre-commit` (allowlist on `hook_status`; missing `dist/cli.js` blocks rather than warning; exit 3 split by cause via `--output-format json`) | `test/cassette-gate.test.ts` |
| Staging a new/updated `baselines/desktop-*.json` must re-stamp or re-record the committed `examples/replays/*.cassette.json` fixtures against it in the same commit — `latest` resolves to the newest baseline file (`src/baseline.ts`, `latestBaselineFile`), so any staged baseline can move `latest` and instantly stale every example cassette's `fingerprint.baseline`; caught 3 times (3431e09, 9eaba8d, the 0.29.0 cycle), always late (on the release PR's first CI run) because these commits sit unpushed for a while | `.githooks/pre-commit` (runs `verify-cassettes` whenever a `baselines/desktop-*.json`, a `*.cassette.json`, or any staged `.json` carrying the `"generator": "cowork-harness"` marker is staged) | `test/cassette-gate.test.ts` — drives the hook in a scratch repo through a stubbed CLI, and separately pins the stub's exit-code contract against the real binary; CI's `Cassette privacy + staleness scan` and `Cassette scan covers every TRACKED cassette` steps (`ci.yml`) are the non-local backstop |
| The sub-agent append is modeled as TWO branches selected on hostLoopMode; sync hard-fails on any drift in the key pair, branch ternary, branch-text fingerprints, substitution VALUES (a host/VM cwd swap fails), resolveSection gate, or the delivery argument list — and on gate 124685897 reading ON (server override active, no captured text) | `src/sync/cowork-sync.ts` (`checkSubagentPromptFacts`, `checkSubagentOverrideGate`) | `test/baseline.test.ts` (mutation + decoy fixtures: body edit, cwd swap, key rename, ternary inversion, map-key rename, missing spSectionPrompts, gate id change, disconnected-literals decoy) |
| The host-loop path gate is sentinel-pinned per-chunk against the asar: gated/excluded tool-set contents via export-name hops, every deny text as its own anchor, the read-only-guard-before-containment order (also the removed-plugin-exemption absence proof), the per-call root-assembly and chat-ternary shapes, and the conditional `Se&&` canUseTool `??`-chain | `src/sync/cowork-sync.ts` (`checkPathHookFacts`, module-bounded over `readMainBundleFiles`) | `test/baseline.test.ts` (mutation fixtures: set membership, deny rewording, unconditional wrapper, inverted order, excluded-set change; disconnected-chunk decoy) |

## Adding a new invariant

When you introduce a new "this must never happen again" guard (a CI grep, an exhaustiveness throw, a
structural test), add a row here in the same commit. If it doesn't have a row here, the next contributor
won't know it exists until they break it.
