@sema-agent/core 7.18.0 → 7.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +276 -2
  2. package/dist/agents/subagent.js +12 -20
  3. package/dist/agents/teacher.js +2 -5
  4. package/dist/agents/verify.js +21 -10
  5. package/dist/brain/anthropic.js +8 -3
  6. package/dist/brain/open-responses.js +12 -4
  7. package/dist/brain/openai.js +7 -2
  8. package/dist/brain/reasoning.d.ts +13 -13
  9. package/dist/brain/reasoning.js +2 -2
  10. package/dist/brain/stream-engine.d.ts +4 -0
  11. package/dist/brain/stream-engine.js +6 -1
  12. package/dist/brain/stream-shared.d.ts +3 -0
  13. package/dist/brain/stream-shared.js +1 -0
  14. package/dist/core/ask-origin.d.ts +16 -0
  15. package/dist/core/ask-origin.js +8 -0
  16. package/dist/core/auto-mode-arming.d.ts +6 -0
  17. package/dist/core/auto-mode-arming.js +5 -5
  18. package/dist/core/auto-mode-defaults.d.ts +43 -15
  19. package/dist/core/auto-mode-defaults.js +4 -1
  20. package/dist/core/auto-mode.d.ts +74 -26
  21. package/dist/core/auto-mode.js +80 -11
  22. package/dist/core/checkpoint-store.d.ts +7 -0
  23. package/dist/core/checkpoint-store.js +2 -2
  24. package/dist/core/child-clamp-seats.d.ts +79 -0
  25. package/dist/core/child-clamp-seats.js +29 -0
  26. package/dist/core/engine-notice.d.ts +89 -41
  27. package/dist/core/fs-write-gate-policy.d.ts +17 -9
  28. package/dist/core/fs-write-gate-policy.js +51 -4
  29. package/dist/core/gate-bounded-await.d.ts +38 -0
  30. package/dist/core/gate-bounded-await.js +53 -0
  31. package/dist/core/gate-fold.js +20 -48
  32. package/dist/core/gate-lanes.js +17 -9
  33. package/dist/core/gate-outcome.d.ts +34 -6
  34. package/dist/core/gate-outcome.js +7 -0
  35. package/dist/core/governance-codes.d.ts +16 -1
  36. package/dist/core/governance-codes.js +18 -0
  37. package/dist/core/hooks.d.ts +8 -8
  38. package/dist/core/memory-engine/engine.d.ts +14 -9
  39. package/dist/core/memory-engine/engine.js +21 -8
  40. package/dist/core/memory-engine/layout.d.ts +31 -0
  41. package/dist/core/memory-engine/layout.js +68 -3
  42. package/dist/core/memory-engine/types.d.ts +24 -0
  43. package/dist/core/memory.d.ts +19 -0
  44. package/dist/core/memory.js +12 -0
  45. package/dist/core/read-root-candidate.d.ts +12 -0
  46. package/dist/core/read-root-candidate.js +24 -0
  47. package/dist/core/remote-env.d.ts +32 -0
  48. package/dist/core/remote-env.js +4 -1
  49. package/dist/core/roles.d.ts +9 -1
  50. package/dist/core/roles.js +4 -0
  51. package/dist/core/runner/advertised-writable-dirs.d.ts +59 -1
  52. package/dist/core/runner/advertised-writable-dirs.js +51 -3
  53. package/dist/core/runner/announce-once-ledger.d.ts +15 -0
  54. package/dist/core/runner/announce-once-ledger.js +8 -0
  55. package/dist/core/runner/classifier-ladder.d.ts +154 -11
  56. package/dist/core/runner/classifier-ladder.js +170 -10
  57. package/dist/core/runner/contracts.d.ts +46 -1
  58. package/dist/core/runner/denial-limit-arms.d.ts +14 -1
  59. package/dist/core/runner/denial-limit-arms.js +7 -6
  60. package/dist/core/runner/env-capability-census.d.ts +30 -0
  61. package/dist/core/runner/env-capability-census.js +38 -0
  62. package/dist/core/runner/forward-sink.d.ts +32 -0
  63. package/dist/core/runner/forward-sink.js +41 -0
  64. package/dist/core/runner/gate-exit.d.ts +4 -0
  65. package/dist/core/runner/gate-exit.js +4 -1
  66. package/dist/core/runner/git-status-frame.d.ts +0 -9
  67. package/dist/core/runner/listing-fold.d.ts +57 -0
  68. package/dist/core/runner/listing-fold.js +75 -0
  69. package/dist/core/runner/prepare-ask-lane.js +1 -0
  70. package/dist/core/runner/prepare-caps-and-workflow.js +20 -31
  71. package/dist/core/runner/prepare-config-doors.d.ts +6 -0
  72. package/dist/core/runner/prepare-config-doors.js +8 -10
  73. package/dist/core/runner/prepare-execution-env.d.ts +1 -1
  74. package/dist/core/runner/prepare-execution-env.js +2 -0
  75. package/dist/core/runner/prepare-gate-stations.d.ts +8 -0
  76. package/dist/core/runner/prepare-gate-stations.js +6 -2
  77. package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
  78. package/dist/core/runner/prepare-hands-readface.js +3 -5
  79. package/dist/core/runner/prepare-listings.d.ts +3 -0
  80. package/dist/core/runner/prepare-listings.js +4 -2
  81. package/dist/core/runner/prepare-memory.js +7 -1
  82. package/dist/core/runner/prepare-policy-chain.js +5 -5
  83. package/dist/core/runner/prepare-project-context.d.ts +5 -1
  84. package/dist/core/runner/prepare-project-context.js +3 -2
  85. package/dist/core/runner/prepare-prompt-inputs.d.ts +2 -2
  86. package/dist/core/runner/prepare-run-refs.d.ts +4 -2
  87. package/dist/core/runner/prepare-run-refs.js +5 -4
  88. package/dist/core/runner/prepare-task.js +8 -6
  89. package/dist/core/runner/resume-apply.js +3 -3
  90. package/dist/core/runner/run-clock-and-content.js +3 -7
  91. package/dist/core/runner/run-harness-handlers.js +10 -10
  92. package/dist/core/runner/run-leg.js +31 -16
  93. package/dist/core/runner/run-settle-and-teardown.js +2 -5
  94. package/dist/core/runner/run-turn-boundary.js +44 -25
  95. package/dist/core/runner/runtask.js +2 -1
  96. package/dist/core/runner/synthetic-tools.d.ts +41 -29
  97. package/dist/core/runner/synthetic-tools.js +25 -40
  98. package/dist/core/runner/task-root-canonical.d.ts +45 -0
  99. package/dist/core/runner/task-root-canonical.js +18 -0
  100. package/dist/core/runner/turn-attachments.d.ts +160 -53
  101. package/dist/core/runner/turn-attachments.js +153 -51
  102. package/dist/core/safe-notify.d.ts +5 -0
  103. package/dist/core/safe-notify.js +24 -2
  104. package/dist/core/sensitive-path-policy.d.ts +84 -17
  105. package/dist/core/sensitive-path-policy.js +37 -19
  106. package/dist/core/skill-listing-budget.d.ts +106 -0
  107. package/dist/core/skill-listing-budget.js +57 -0
  108. package/dist/core/skills-directory.js +17 -10
  109. package/dist/core/task-registry.js +2 -1
  110. package/dist/core/task-spec.d.ts +29 -2
  111. package/dist/core/tool-policy.d.ts +31 -5
  112. package/dist/core/tool-policy.js +45 -32
  113. package/dist/core/tool-spec.d.ts +37 -4
  114. package/dist/core/types.d.ts +1 -1
  115. package/dist/core/write-protect.d.ts +105 -20
  116. package/dist/core/write-protect.js +89 -32
  117. package/dist/index.d.ts +8 -7
  118. package/dist/index.js +6 -5
  119. package/dist/orchestration/workflow-primitives.js +10 -20
  120. package/dist/stores/file/memory-store.d.ts +5 -2
  121. package/dist/stores/file/memory-store.js +8 -4
  122. package/dist/tools/fs/bash-readonly-classifier.d.ts +40 -2
  123. package/dist/tools/fs/bash-readonly-classifier.js +376 -41
  124. package/dist/tools/fs/fs-bash.d.ts +1 -1
  125. package/dist/tools/fs/fs-bash.js +19 -28
  126. package/dist/tools/fs/fs-shared.d.ts +22 -0
  127. package/dist/tools/fs/fs-shared.js +30 -0
  128. package/dist/tools/fs/index.d.ts +1 -1
  129. package/dist/tools/fs/index.js +1 -1
  130. package/dist/tools/fs/read-deny.d.ts +4 -3
  131. package/dist/tools/fs/safety.d.ts +15 -0
  132. package/dist/tools/fs/safety.js +7 -0
  133. package/package.json +2 -1
  134. package/test/export-surface.snapshot.json +31 -5
  135. package/wire-changes/core@7.18.0.json +1273 -0
  136. package/wire-changes/core@7.19.0.json +457 -0
  137. package/wire-changes/core@7.20.0.json +568 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,279 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.20.0 — 2026-09-16
4
+
5
+ ### Security / narrowing — the always-on write-protection table now judges the file the tool will WRITE, symlinks resolved (#817; every deployment; @server @cli @client-core @test; deployment operators)
6
+ - **Before.** The default-on write-protection table (`WRITE_PROTECTED_DEFAULT_TABLE`, the gate's fourth tighten) judged the SPELLING of the target only. A symlink whose name is innocent but whose real target is a table row produced zero friction: `notes.txt -> ~/.bashrc` was written without an ask on every deployment, and always had been (measured on the tree before this change, all three write tools). The opt-in sensitive-path deny set already resolved symlinks; the always-on floor did not, so a bare deployment (no deny set wired) had no line behind the table at all, and a deployment with the deny set had none for the table's own rows (the shell rc family, `.gitconfig`, package-manager rc files, the `.ssh` / `.gnupg` / `.claude` / `.sema` directories, …).
7
+ - **After — one canonicalizer, one reach.** The judge sees TWO views of the write and asks on either: the spelling (anchored at the live cwd, as before — no filesystem consulted) and the resolved real target, through the same `canonicalizeTarget` every other path guard reads (`resolveKey`, the sensitive-path deny set, the fs-write gate). The spelling view runs first, so a spelling hit costs no I/O; the target view is paid only on a spelling miss. Contract sentence `write_protection.judges_the_real_target` (on `WriteProtectionMatcher.judge`): the table and the deny set differ in vocabulary and severity, never in reach. The reference CLI (2.1.259) judges every variant of a path — the spelling and what it resolves to — and asks on any hit; this is that form.
8
+ - **Narrowing (allow → ask).** A write through an alias whose real target lands on a table row now asks where it used to execute. The ask carries the same `safety` reason and the same clearing posture as the spelled form (an approver, a blanket `onAsk:"allow"`, the classifier, or a matching persisted allow rule may clear it); its message says the target RESOLVED onto the row: `tool "Write" writes to a write-protected path — its real target resolves onto table entry ".bashrc" — explicit approval required`. The spelled form's sentence is byte-identical to before. **Headless lane (no approver): the aliased write is now refused where it used to execute.**
9
+ - **Narrowing (allow → deny), one shape — on the lane the tighten judges.** A surviving ALLOW is what this tighten sees (it only ever tightens; a decision that is already an ask or a deny is not re-judged, so a target this layer never resolved can still reach execution by a lane that cleared some OTHER ask — the reach is the allow lane, as it has always been). A covered write whose target the run's environment cannot RESOLVE — a symlink the environment cannot follow (no `readLink`, an errored probe, a hop-cap), an ancestor chain that cannot be verified, a namespace spelling the tree refuses — is now DENIED by the write-protection judge, naming the cause (`deniedBy: "write_protection"`): an unjudged target is never vouched. This is the rule the sensitive-path deny set has followed since 7.17.1, now one rule for both judges. The approved-edit re-check arm denies the same way when a restriction-chain rewrite lands on such a target.
10
+ - **Data-root passthrough, both views — and ONE exemption law per view.** The library under the engine's data root stays exempt through an alias INTO it, and under a declared data root that is itself reached through a symlink — on the target view the roots are compared as canonical keys (the deny set's `relativeUnderRoot`, roots resolved per judgment, never cached), so an exemption can neither be lost to a realpath spelling nor gained by a lookalike. The spelling view's own lexical containment test is deliberately NOT applied to the canonical key: the `ExecutionEnv` seam promises a canonical path and says nothing about how it folds `..` through a symlink (the OS `realpath(3)` walks the link first, a lexical `path.resolve` folds first — both legal, and they name different directories), and an exemption granted on an unpromised agreement between the two is a guard bypassed. Cost of the single law: a write whose REAL target lands inside the data root now resolves the root spellings once (2 env calls) instead of taking a lexical shortcut. A spelling hit is never re-litigated by the target view (a library file spelled by its REAL path under a symlinked declared root still asks on the spelling view — the historical over-ask, the safe direction).
11
+ - **Security (allow → ask), a second shape the same round turned up.** The table read a spelling under BOTH separator families and then folded `.`/`..`, so a component that is only a traversal under the OTHER platform's rules popped the protected segment away: a file literally named `..\x` inside `.ssh` (a legal POSIX name — the backslash is an ordinary filename character there) was read as `<parent>/x` and every `segment` row answered **clear**, on the spelling view and on the target view alike. Every `segment` row of the default table was affected (`.ssh`, `.git`, `.gnupg`, `.claude`, `.sema`, …) and it had always been so. A spelling is now read under each separator family with its own component normalization — POSIX-literal, win32-spelling-literal, win32-normalized — and the protective answer wins; the unambiguous traversal (`.ssh/../x`) still resolves away in every reading, so no over-match is invented. The opt-in deny set was never affected (it canonicalizes and splits correctly), so on a deployment wiring it the `.ssh` family was covered; the configuration directories, which have no deny-set row, were not.
12
+ - **The judgment is BOUNDED, on the fold's one deadline seat.** The target view awaits the environment, and an environment's filesystem calls do not observe an abort signal — so handing the signal down is not a bound, and the fold is awaited before the loop looks at cancellation again. The wait now takes the same deadline the reversibility probe takes (`approvalTimeoutMs`, else 30s, a garbage value refused loudly to that default — one resolver for both awaits now, so a deployment cannot bound one and not the other) and the same abort race. An elapsed or aborted judgment is the judge's own `unresolvable` verdict: DENIED, naming the cause. A stalled or unreachable backend therefore costs one refusal, not a task that cannot be cancelled.
13
+ - **The approved-edit re-check arm, on aliases too.** A restriction-chain rewrite of an approved edit onto a table row was already denied fail-closed when the rewrite SPELLED the row; it now denies when the rewrite's real target lands on one. On a deployment that ran 7.19.0, an aliased rewrite into a configuration directory raised a second ask (the retiring policy tier) and could be re-approved; it is now the same refusal the spelled form always got — one verdict for both spellings, on this arm too.
14
+ - **What the target view does NOT do, two boundaries.** (i) It reaches exactly as far as the ENVIRONMENT resolves. The shared canonicalizer early-allows a win-form UNC spelling (`\\host\share\…`) without touching the filesystem — a `stat` against an unreachable share blocks in the kernel past any signal, so deciding without the probe is a liveness rule — and the key it returns IS the spelling. On a deployment whose workspace is a UNC share, an alias there is therefore judged by its spelling alone, exactly as before this change; the opt-in deny set reads the same canonicalizer and has the same reach, so the two judges still agree. Pinned as current reach in `test/backlog817-*` ④(f), with the share measured as never probed. (ii) It resolves the target at JUDGMENT time; it does not bind that identity to the write. The tool resolves again when it executes, so a link retargeted between the verdict and the execution is judged by the tool's own guard against the NEW resolution — the window `requireGuardedWrite` (`fs-write-gate-policy.ts`) already states and deliberately leaves open (closing it needs the adjudicated key carried through a trusted approval receipt into execution, a separate seat). This change closes the STABLE alias — the shape that was silently free on every deployment — and nothing about that window moves in either direction.
15
+ - **Absence law.** A judge compiled WITHOUT an execution environment (a run with no hands, or a host driving `runToolGate` with its own check) has no target view and answers by the spelling alone — today's reading, byte for byte — the same absence law the cwd follows. The Runner compiles the judge with the hands' environment and the task root.
16
+ - **Cost (measured, pinned).** Per covered write, in the gate: spelling hit 0 env calls; spelling miss 1 canonicalization = `exists` + `canonicalPath` for an existing file, 4 env calls for a not-yet-existing one (the shared canonicalizer's own ancestor walk); a target that hits a row — inside or outside a declared data root — additionally resolves the root spellings once (2 env calls per distinct spelling). A remote environment pays the same number of round trips. The fold gains a suspension point for covered writes only (the same class as the reversibility probe it already awaits); a call the judge does not cover answers `null` synchronously and its tick shape is unchanged.
17
+ - **Seats (additive).** `WriteProtectionMatcher.judge(path, { cwd?, base?, signal? }) → Promise<WriteProtectionVerdict>` (`clear` / `hit` with `via: "spelling" | "target"` / `unresolvable` with `cause`); `compileWriteProtection(entries, { dataRoot?, env?, rootPath? })` (two new optional seats; `matchPath` unchanged = the spelling view); new exported types `WriteProtectionVerdict`, `WriteProtectionJudgeContext`, `WriteProtectionCompileOptions`, `WriteProtectionCheck` (exports +4 types). `ToolGateInput.writeProtectionCheck` (a host driving the gate) now returns `Promise<WriteProtectionVerdict> | null` — `null` synchronously for a call the judge does not cover — and takes an optional trailing `signal`; a host that passed its own synchronous check must return the promise form (the engine-built check is unchanged in name and seat).
18
+ - Pins: `test/backlog817-canonical-write-protection.test.ts` (①–⑦ + the absence law; measured I/O counts; ④(c) drives the judge with an environment whose `canonicalPath` is the OS `realpath(3)` and pins that only the canonical containment grants the exemption); `test/backlog286-write-protect-default.test.ts`, `test/backlog804-agent-config-write-tier.test.ts` (#815 block), `test/design388-write-fences.test.ts`, `test/gate-outcome-stations.test.ts` re-pinned onto the promise-returning check (spelling view, no environment).
19
+
20
+ ### BREAKING — the sensitive-path policy's ASK tier retires: one ask judge for the configuration directories, the always-on table (#817; @server @client-core @cli @test)
21
+ - **What is removed.** `createSensitivePathPolicy`'s `askPatterns` option and the export `RECOMMENDED_SENSITIVE_ASK_PATTERNS` (7.19.0, #804 (d)). The contract sentence `sensitive_paths.two_severities_one_judge` retires with them; the policy is the DENY set alone, as it was before 7.19.0.
22
+ - **Why.** The ask tier was added in 7.19.0 to recover REACH: the `.claude` / `.sema` directories had moved from the deny set to the always-on write-protection table, and that table judged spellings only, so a symlink alias into a configuration directory walked past it. The table now judges the real target (above), so the same directories on the same canonicalizer at the same severity were two judges answering one question — with two message forms and two configuration seats. One judge remains: `WRITE_PROTECTED_DEFAULT_TABLE`'s `.claude` / `.sema` segment rows, default-on, target view included.
23
+ - **Cell by cell, unchanged.** The configuration DIRECTORY and the person's material under it (`.claude/projects/**`, `commands/`, `skills/`, `.sema/…` twins) ⇒ ask, clearable by an approver or a persisted allow rule; `settings.json` / `settings.local.json` / `.claude.*` / `.sema.*` / `hooks/` / `agents/` ⇒ deny (the deny set, untouched); the data-root passthrough exempts the library on both judges; every cell holds through a symlink alias. Pinned in `test/backlog804-agent-config-write-tier.test.ts` (the `(d)` block re-pinned onto the table judge, same shapes) and `test/backlog817-*`.
24
+ - **What a consumer FEELS.** On a deployment that wires the deny set (server) AND keeps the default table: an aliased write into a configuration directory still asks, with the table's sentence (`… its real target resolves onto table entry ".claude" …`) instead of the policy's (`… resolves inside the guarded directory ".claude" …`); `decisionReason: "safety"` and the clearing posture are identical. Nothing that asked stops asking; nothing that denied stops denying.
25
+ - **WIDENED (ask → allow) for TWO deployment shapes, named because the sentence above does not cover them.** The two judges are not interchangeable seats: the retiring ask tier rode on `createSensitivePathPolicy`, the surviving one rides on the TABLE. So a deployment that mounts the policy and has **no table** — `RunnerDeps.writeProtectedPaths: []`, or a replacement table without the `.claude` / `.sema` rows — had the configuration-directory ask from the policy's default in 7.19.0 and has none after this change. That is a write that asked and now executes. It is one version's exposure (the tier shipped in 7.19.0) and no deployment in this ecosystem is in that shape (server mounts the recommended deny set with the default table; cli and client-core reference neither seat), but a deployment that empties or filters the table while relying on the policy must add the rows back: `{ name: ".claude", kind: "segment" }` / `{ name: ".sema", kind: "segment" }`. **There is no compatibility path** — the tier is removed, not deprecated, and a caller passing `askPatterns` is a type error rather than a silent reading. The SECOND shape is the deny set's explicit tool NAME list: `createSensitivePathPolicy({ tools: ["CustomWrite"] })` judges a tool the catalog does not know as a writer, and the retiring tier rode along; the table's membership is the predicate over the CALL (a face that DECLARES a write path target — design/388 A-12), so a custom writer that reaches the guard only by name loses the configuration-directory ask. Migration: declare the tool's path target, which puts it on both guards at once; the name list keeps its DENY reach either way. Again nobody here is in that shape (server passes `patterns` / `rootPath` / `dataRoot` only; cli and client-core reference neither seat), and mirroring the name-list seat onto the table would be exactly the compatibility layer this project does not keep — if the symmetry is wanted it is its own decision, not this change's. Pinned as current reach in `test/backlog817-*` ⑥.
26
+ - **The deployment exit (the replacement for `askPatterns: []`).** The ask on the configuration directories is switched off the way every table row is: `RunnerDeps.writeProtectedPaths` — a filtered copy of `WRITE_PROTECTED_DEFAULT_TABLE` drops the `.claude` / `.sema` rows, `[]` mounts no table at all; a custom ask set is spelled as rows (`{ name: ".myagent", kind: "segment" }`), literal names, loud on a glob. The table seat is the existing deployment face for exactly this (server's two env knobs replace or extend the table); no deployment passed `askPatterns` (server, cli, client-core: zero references).
27
+ - **Compile-time.** A caller passing `askPatterns` is a type error (excess property); an import of `RECOMMENDED_SENSITIVE_ASK_PATTERNS` fails to resolve. Both are loud at build, never a silent behaviour change.
28
+ ### Exports +2 — the classifier SEAT resolver is reachable from the package root, so a host displaying the route reads the engine's answer (#830; additive, zero behaviour; @server)
29
+ - A host that shows which model judges a run's gated calls had to re-derive the pick from catalog + roles + tier bindings, because the resolver the engine's own arming calls (`classifierSeatModel`, with its `tier_below_main` leg) was exported from its module and never re-exported from the package entry. Two derivations, one of them guaranteed to fall behind the other the moment either side's rules move.
30
+ - **Now exported:** `classifierSeatModel` (function) and `ClassifierSeat` (type). Same function object the arming calls — not a copy, not a re-implementation. Zero behaviour change: no call site moved and nothing else in the module became reachable.
31
+ - Contract sentence `classifier.seat.one_resolver` on the declaration: the engine's arming and any host display call THIS function; a second derivation is a second answer.
32
+ - **The resolver never throws.** A tier binding naming a model the catalog no longer carries is a BOOT error from the `Runner`'s constructor; a seat READ must not re-raise it, so the expansion's failure hands the raw catalog on and the ordinary fallbacks name the seat. Such a deployment cannot construct a `Runner` at all, so there is no engine answer to disagree with. This keeps the posture the two existing `catch` arms already state: a stale reference never fails the caller.
33
+ - **The resolver normalizes tier bindings itself**, so a host may hand it the very `deps` it handed the `Runner`. The tier-relative leg addresses the catalog BY tier word, and the Runner expands the bindings into the catalog at construction and keeps that expansion private — a caller holding the UNexpanded catalog would otherwise have every tier word miss and get the main model back with `main_only`, i.e. the wrong judging model displayed while looking like it had asked the engine. The expansion is idempotent (a catalog key shadows a tier binding), so the engine's own already-expanded lane is unchanged, and a deployment that declares no tiers is untouched.
34
+ - Pins: `test/backlog830-classifier-seat-export.test.ts` — a REAL LOADING PATH pin (imports from the package root, so an `index.ts` that forgets the re-export reds even with every unit test green), identity against the module export, the four seat arms a display asks about (named role / tier below main / two tiers on one route / nothing below), the stale-role arm, and the signature shape.
35
+
36
+ ### Fix — a connection that breaks AFTER the provider's terminal frame no longer makes the response "partial" (#814; @server @cli @client-core)
37
+ - **Before.** The mid-stream failure tiering asked only "did the stream break?". A body that had already delivered its finish frame and then errored — a socket reset with nothing left to send — or simply never closed (tripping the idle watchdog) was promoted as a TORN stream: `partialFinalized`, the CC "may be incomplete" line appended to the message, and downstream the agent loop's bounded continue nudge. A second paid turn to finish an answer the model had already finished, on a response nothing was missing from.
38
+ - **After.** TORN is ONE predicate: the provider never terminated the stream AND the stream broke. The three stream adapters report the FACT — `StreamParser.snapshot().terminalFrameSeen` (openai `finish_reason`, anthropic `message_delta.stop_reason`, responses `response.completed`/`response.incomplete`), read off the same state each adapter's own finalize already reads, so the two cannot drift — and the engine decides what it means. A failure landing after the terminal frame finalizes the response whole: no marker, no warning, no nudge, and no re-send.
39
+ - **Scope.** The exit finalises, it does not re-judge: a `length` finish frame keeps its `length`, a declared-incomplete Responses terminal keeps the adapter's own incompleteness sentence. Every stream cut BEFORE its terminal frame is unchanged on all three wires — still `partialFinalized` + the connection-cut or stall warning — and so are tiers B and C.
40
+ - **Layering, kept and measured.** Each adapter's own `tornWithContent` stamp (#805: content that arrived with no terminal frame is a torn stream) is independent of the engine's tiering and stays that way; the pins measure that independence rather than assume it.
41
+ - Pins: `test/backlog814-terminal-frame-then-broken-connection.test.ts` (all three wires, socket reset and idle stall; the `length` control; the three cut controls; the real adapter's message driven through the real agent loop, with and without the marker).
42
+
43
+ ### Fix — a child run judged by an ANCESTOR's frozen classifier now records WHICH MODEL answered (#794; additive; @server @cli @client-core)
44
+ - **Before.** `GateDisposition.classifier` was written by the gate's OWN classifier station, which is the station and the pass in one call. An ancestor's frozen classifier is a station one policy wrapper away: its verdict reaches the descendant's fold as an ordinary decision, and a decision has no member for a model name. The round's FORM (`GateDisposition.cause`) rode an identity-keyed attestation seat and arrived; the model did not. So every call a child run inherited from its parent's classifier reported the attribution ABSENT — the word that record's own contract spells "unknown" — and the allow arm re-minted its `{action:"allow"}` in the caller, which would have dropped an attestation even if one had existed.
45
+ - **After.** One seat (`attestClassifierRound`, the twin of the cause seat), one writer (`judgeInheritedClassifier`, which now mints the ALLOW as well as both denies and hands the arm its object), one read (the fold's single attestation read, beside the cause it already read there). The arms return the station's object instead of re-minting; the ask's own rewrite is handed IN (`allowRewrite`) so it rides the same one object.
46
+ - **Additive.** A cell that was always absent on the inherited lane is now filled there; absence still reads UNKNOWN and nothing that was present changes. The gate's own station still outranks this read — it runs only over a surviving ask, i.e. only when the ancestor's chain did not settle the call.
47
+ - **Truthed documentation.** `GateDisposition.classifier`'s three causes of absence become two (the inherited chain is no longer one of them), and the remaining pair is named: no classification happened, or the decider had no candidate ladder to name a model from (a hand-built decider; a parked ancestor's cross-process rebuild). The one round that still reaches no seat is a block AT the denial-limit bound — it becomes an ask, and the disposition names whoever answered it.
48
+ - **Attestation seats are now a LIST, not four names.** `transferEngineAttestations` walks it (so a new seat cannot be half-wired: present at its own mint, dropped by every clone site) and transfers PER SEAT, last writer wins — the call sites transfer in the order the stations ran, so precedence is the order they write rather than a fact about which `set` ran last. The combined fold accumulates records per seat on a fold-owned carrier rather than remembering one whole attested OBJECT: two layers that fill DIFFERENT seats (a person's yes, then an inherited classifier's allow) no longer erase each other, in either composition order, and a person's yes still rides a later layer's veto (I3) while the refusing station's own seats stay its own. The fold's screen for "may the shared `ALLOW` constant stand in for this layer's allow?" reads the list too, so an allow carrying only a classification is preserved exactly as one carrying a person's yes is.
49
+ - **The approved-EDIT re-check is a second classification, and is now recorded as one — on BOTH arms.** An operator may approve a call WITH an edit, and the gate re-adjudicates the edited bytes: an adjudication like the first, so an ancestor's frozen classifier can both refuse AND clear it there. The lane refreshed `deniedBy` and the round's FORM from the re-check's decision but never the model behind it. A refusal was therefore recorded with an unknown judge (the same absence one lane over) — and an ALLOW was worse than absent: the record kept the model that judged the ORIGINAL bytes and refused them, naming a model that said no about a different action, while the bytes a different model cleared are the ones that ran. The attribution is now refreshed before the deny test, so both arms carry it, and GUARDED like the fold's twin read: a re-check that classified nothing does not ERASE what the earlier pass recorded; one that did, outranks it.
50
+ - Pins: `test/backlog794-inherited-classifier-round.test.ts` (four verdict arms at the station; the no-ladder and wrong-call absences; both arms end to end through `runToolGate`; the ancestor's ladder walk; the combined fold's constant and rewrite paths; the clone seam; allow-then-deny ordering, read both through the fold and directly).
51
+
52
+ ### Narrowing — a CONTRADICTORY classifier reply is decided where it was given; the candidate ladder does not walk past it (#813; @server @cli @test)
53
+ - **Before.** The ladder advanced on one predicate, `rungDidNotRule`, which returned the single word `parse_error` for BOTH parse-failure forms. So a reply carrying two answers — `<thinking><block>yes</block></thinking>no`, a scratch `yes` contradicted by a bare `no` — moved the classification to the model behind the classifier seat, and a `no` there ALLOWED the call. The classify leg had already refused to RE-ASK a contradiction, for exactly the reason that makes the walk wrong (the reply holds a `yes`, and a second attempt answering `no` erases it), so the two seats that offer a second attempt at the same question disagreed with each other.
54
+ - **After.** One rule for both second attempts: whatever a re-ask may not overwrite, a later rung may not overwrite either. The walk advances only on NO EVIDENCE — the leg threw, the round-trip cap elapsed, or the reply carried no answer at all (`missing`). A contradiction stays the fail-closed `parse_error` deny it already was, decided at the rung that produced it, and the card names THAT model.
55
+ - **Narrowing.** A call that a later rung would have allowed on a contradictory seat reply is now denied at the seat. Nothing widens: no deny becomes an allow, and the `missing` shape — the one the ladder exists for — is untouched.
56
+ - **What consumers see change, and what does not.** On a contradictory reply there is now no `classifier.fallback` notice and no `fallback` member on `GateDisposition.classifier`; the disposition reads `deniedBy: "classifier"`, `cause: "parse_error"`, and `classifier.modelUsed` is the SEAT. `AUTO_MODE_FALLBACK_CAUSES` and `AUTO_MODE_PARSE_FAILURES` are unchanged (no closed set moved, nothing added or removed), no export changes, and the deny sentence is the one `classifierParseFailureDenyMessage` already wrote.
57
+ - Contract sentence `auto_mode.ladder.advances_on_no_evidence` on `rungDidNotRule`.
58
+ - Pins: `test/backlog813-conflict-stops-the-ladder.test.ts` (the predicate; the two-rung walk's dispatch count and record; the same walk end to end through the Runner with a main model that WOULD allow; the deny card's model name; the broken-contract streak on both shapes; a `missing` negative control at every level).
59
+ ### Fix / **narrowing** — every engine-built child spec carries the WHOLE of its parent's tighten-only clamps, through one carrier and behind a gate (#777; @server @cli @client-core)
60
+ - **Before.** The engine builds child specs in seven places (the delegation lane, the observer sidecar, the wake/revival fold, the teacher's internal legs, the verify gate's two legs, the workflow lane), and each re-derived, per seat, which value is the tightening one. A seat therefore landed one constructor at a time, and the ones it did not reach were invisible: three separate rounds found the same root cause as three points (a workflow child spawned free of its host's clamp, a re-park's persisted seed carrying a face the station never re-applied, an observer sidecar with no containment seat). A `grep readFace` census cannot find the next one — a station that carries the face through a persisted seed or a synthesized fold has no such line to grep.
61
+ - **After.** ONE carrier, `childClampSeats` (`src/core/child-clamp-seats.ts`), owns the projection: only the restricting value of each seat travels, arrays are copied at spawn, and a seat the parent did not clamp adds NO key — so a constructor may state its own stricter default above the spread and keep it. `CHILD_CLAMP_SEATS` = `handsReadOnly` · `interactiveTools` · `readFace` · `writeFace` · `readDenyPatterns`. Two mechanical halves replace the census: **the compiler** — `ChildClampSource` requires every seat as a PRESENT key, so adding a member reds `tsc` at every construction site until each one says what it carries there; **the scan** — `gate:child-clamp-seats` (in the `gate:all` roster) checks that every name in `ENGINE_CHILD_SPEC_CONSTRUCTORS` resolves to a declaration that calls the carrier, that the carrier and its source shape cover every seat, that every tighten-only seat `tightenTaskSpec` governs is either carried or listed in `CHILD_CLAMP_SEATS_NOT_CARRIED` **with a reason**, and that no file under `src/agents` / `src/orchestration` hand-writes a clamp projection outside the carrier (a ledgered, only-shrinking exemption table holds the two lines that are not one).
62
+ - **Narrowing, two stations.** (1) The **observer sidecar** now carries the parent's `handsReadOnly` and `interactiveTools` alongside the containment trio it already carried: prepare mounts the built-in hand band on EVERY task regardless of the spec's own `tools`, so a read-only parent's sidecar had write hands. (2) The **verifier leg** folds the caller's `handsReadOnly` in after its own `verifierHandsReadOnly ?? true`, so the explicit `verifierHandsReadOnly: false` opt-out can no longer widen past a read-only caller. Everything else is byte-identical behavior through a different spelling.
63
+ - **The registry names a FILE, not just a name** (`ENGINE_CHILD_SPEC_CONSTRUCTORS` rows are `<repo-relative file>#<name>`). A registry keyed on a bare name asks a syntactic scan to enumerate every shape that can bind a callable — a plain `const`, a function, a destructured binding, an object member, a computed member — and that list has no end: three review rounds each produced another shape, and each made the SAME false green (the real constructor invisible to the scan while a namesake elsewhere answered for it). With the file as half the key the question is closed instead of open: a shape the scan cannot read produces no declaration THERE, and the gate says so rather than certifying through something else. The scan reads `const <name> = …` and `function <name>()`; anything else is red where it is registered, not invisible.
64
+ - **Seats deliberately NOT carried**, each with its reason in the table: `toolPolicy` (reaches children through the ancestor-constraint chain), `shellGate` (travels on the inherited-gate lane), `onAsk` (per-lane, provenance-wrapped), `hooks` (a deployment seat with no tightening value), `excludeTools` / `deferTools` (roster controls, carried beside these seats by the lanes that copy a roster).
65
+ - Nothing on the wire moves; the carrier and the registry are internal (not exported from `src/index.ts`).
66
+ - Pins: `test/backlog777-child-clamp-seats.test.ts` — the carrier's own law and its spawn-time copy, the two newly-reached stations, and the gate judged in BOTH directions over synthetic trees (a constructor that bypasses the carrier; an unregistered constructor that carries a clamp its own way; a seat the carrier drops; a seat the source shape stops requiring; a new tighten-only seat nobody placed; a stale exemption row).
67
+
68
+ ### Security / **narrowing** — a writable DOMAIN that covers the task root is refused, at both places one can be offered (#752; @server @cli @client-core)
69
+ - **Before.** A directory offered as an auto-approved write domain that RESOLVED onto the task root — or above it — was honored. Two doors reached it. (a) The prompt's advertised writable directories (the deployment's scratchpad, the memory engine's write root): a symlink at the workspace canonicalized onto the task root, the admission seat admitted it, and its canonical form rode `req.advertisedWritableRoots` into the write gate, so every ordinary source write in the workspace turned from `ask` into `allow`. (b) The deployment-authored `exemptDirs`, without the seat at all — and reachable with NO symlink: the scratchpad is a real directory, but the caller supplies the cwd, so a scratch-shaped workflow whose cwd IS the scratchpad (or a directory under it) made the exemption an ancestor of the root and silently switched write governance off.
70
+ - **After.** ONE predicate (`swallowedFenceRoot`, `src/core/runner/advertised-writable-dirs.ts`) states "a domain must not cover a root the fence holds" — equal or ancestor, one rule — and both doors apply it.
71
+ - **The admission seat** refuses the directory and announces it once through its existing outlet (`RunnerDeps.onError`, phase `config`): `… advertised writable directory skipped: <path> — it resolves to <canonical>, which covers task root <root> …`. The advertisement is withheld with it (the seat already decides the hole and the teaching together). The roots counted are every root the fence holds when the advertiser is judged: the task root, each declared `additionalDirectories` / `additionalReadDirectories` entry as the hands phase resolves it, and the directories this seat already admitted. Re-admitting a directory the seat already holds stays idempotent.
72
+ - **The write gate** refuses to be BUILT. `createFsWriteGatePolicy` throws `config.write_exempt_dir_covers_root` (exported as `WRITE_EXEMPT_DIR_COVERS_ROOT_CODE`; `detail: { exemptDir, exemptDirResolved, root }`) when an `exemptDirs` entry is, lexically, the gate's `rootPath` or an ancestor of it — fail-closed, the task never starts. An exemption that swallows the root turns write governance off, and a deployment that wants that must SAY it with the knob that means it: `defaultWrite: "allow"`, an open write face, or a permission rule. The message says so. The symlink shape, which no wiring-time check can see, is caught by the same predicate on the canonical view at judgment: the domain grants nothing and the resulting `ask` names it.
73
+ - **Narrowing, stated plainly.** (1) `exemptDirs` containing an entry equal to — or above — `rootPath` now REFUSES AT PREPARE; before, it silently exempted the whole root. (2) An advertised writable directory that resolves onto a root is skipped instead of admitted, so writes that rode that exemption now ask. Nothing widens in either direction.
74
+ - **Headless / unattended lanes.** The write-gate arm is a CONSTRUCTION refusal, not an `ask`: it is the same shape on every lane — a live approver, a durable park and a headless run all see the task fail to start with this code, and no lane gets an approvable prompt out of it. The admission-seat arm narrows an `allow` back to the run's ordinary `ask`, which on a headless lane resolves as that lane already resolves an ask.
75
+ - **Two ways the rule could be walked around, closed in the same window.** (a) The gate's `rootPath` is deployment text and may be spelled relatively; it is now absolutized ONCE against the env's cwd — which is where a relative domain entry lands too — so the wiring-time check and the per-call check ask about the same directory (before, the root was resolved against ITSELF, i.e. `<root>/<root>`). (b) A configured root the env cannot resolve no longer reads as "no root to cover": while the root cannot be read, every `exemptDirs` entry grants nothing and the ask says why — fail-closed, because "cannot tell" is not "does not". `acceptDirs` is unaffected by either.
76
+ - **The admission seat no longer depends on phase order.** It resolves the spec's declared roots (`additionalDirectories` / `additionalReadDirectories`) itself, once, before it judges anything — the memory advertiser is admitted BEFORE the hands phase canonicalizes those entries, so a memory home covering a declared root used to be admitted simply because nobody had named that root yet.
77
+ - **`acceptDirs` is deliberately untouched**: that domain is a grant a person won for the session ("allow all edits"), and a person may legitimately grant the whole root. This rule is about wiring-time configuration whose effective reach is not its written reach.
78
+ - Export +1: `WRITE_EXEMPT_DIR_COVERS_ROOT_CODE`. Contract sentence `fs_write_gate.exempt_dir_never_covers_root`.
79
+ - Pins: `test/backlog752-advertised-dir-cannot-hold-a-root.test.ts` (the seat: onto-the-root, above-the-root, an ordinary out-of-root scratchpad as the negative control, declared and already-admitted roots, the predicate's own edges; the gate: cwd == scratchpad and cwd ⊂ scratchpad both refusing at construction with the code and detail, the symlink shape granting nothing at judgment, mutually-non-containing as the negative control, and `acceptDirs` still granting). `test/rb108-live-cwd-write-guard.test.ts`'s cwd-anchoring pin was re-pointed off the degenerate root-IS-the-domain wiring it happened to use; its subject is unchanged.
80
+
81
+ ### Fix — a task root the host did not spell canonically is announced instead of silently judged as another directory (#759; announce-only, no behavior change; @server @cli @client-core)
82
+ - **Before.** Containment is judged on the CANONICAL root: the fence resolves every target through the `ExecutionEnv` and compares canonical keys, and the shell read boundary compares a command's words against the declared roots. A deployment that handed the runner a root which is not its own realpath (a symlinked directory; `/var` → `/private/var` on macOS) therefore reasoned in one spelling while the engine judged in another — and a target the host considers inside the root could be judged outside it. The engine canonicalized and said nothing.
83
+ - **After.** One resolver at the driver's root receiving point (`resolveTaskRootCanonical`, `src/core/runner/task-root-canonical.ts`) settles the root ONCE and announces `config.task_root_not_canonical` (audience `operator`, `detail: { given, canonical, sessionId, runId }`) exactly when the two spellings differ. The hands/read-face phase now takes that spelling as an input instead of canonicalizing the same path a second time, so the fence and the notice cannot disagree about which form is canonical.
84
+ - **No behavior change.** Judgment is unchanged and the host's spelling is never repaired (announce, never narrow — the engine cannot know which of the two a deployment's own gates key on). A root that already is its own canonical form announces nothing, and the notice's absence is the claim that the two agree. An unresolvable root announces nothing either: "the env could not resolve it" is not a second spelling.
85
+ - Closed set `ENGINE_NOTICE_CODES` +1 (`config.task_root_not_canonical`). Contract sentence `task_root.canonical_is_the_judge`.
86
+ - Pins: `test/backlog759-task-root-canonical.test.ts` (symlinked root ⇒ exactly one notice with both spellings and the read still lands in-root; canonical root ⇒ zero notices; the resolver's own two arms; the unresolvable arm).
87
+ ### Widening — the skills directory loader states each skill's own base directory (#799; @cli @server)
88
+ - **Before.** `SkillSpec.baseDir` (#786) is what lets a skill reach its bundled material: with it set, a `Skill` result opens with `Base directory for this skill: <dir>` and both root placeholder spellings (`${SKILL_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`) resolve to it. The seat existed and was honoured — but `createSkillsFromDirectory`, the ONE loader that knows the directory (it just read `SKILL.md` out of it), never stated it. Every disk-loaded skill was therefore delivered as if it had no directory at all: no opening line, and `${SKILL_ROOT}/ref.md` handed to the model verbatim, where its own attachment paths are relative to that very directory.
89
+ - **After.** The loader fills `baseDir` with the skill directory's PHYSICAL path — the same value the attachment walk already resolves and proves containment against, so `baseDir` and `files[].path` cannot name different places. Resolved once per skill, in one function, for both consumers.
90
+ - **ADDITIVE / widening, not BREAKING.** No shape changes and no reading of the field changes. What changes is the BYTES a disk-loaded skill delivers: one opening line is added and the placeholders resolve (they were being delivered unresolved, which is what a `baseDir`-less skill means). A spec a caller builds in code is untouched; a caller that sets `baseDir` on a returned spec still wins. The one arm that leaves the seat absent is a skill directory that will not resolve — disclosed as `read_failed`, naming both consequences.
91
+ - Pins: `test/backlog799-skills-loader-basedir.test.ts` (the physical directory on the spec; the opening line and both spellings through a real invoke; an attachment's own placeholders resolving against the directory its path is relative to; a symlink-mounted skill naming the resolved directory; the caller-set value winning; a code-built skill byte-identical; a source pin that one resolution feeds both consumers).
92
+
93
+ ### Fix — the legacy file memory store: a memory tree on its own mount, and a projection failure that read as a lost note (#784; @server @cli @client-core)
94
+ - **Before, staging.** `FileMemoryStore` rewrites its derived `MEMORY.md` by writing a temp file and renaming it over the target — and it staged every one of those temps under `<root>/tmp`, while the projections live under `<root>/memory/<scope>/`. `rename(2)` is defined only WITHIN one filesystem, so a deployment that mounts the memory tree on its own device (a volume per tenant, a persistent memory dir under a tmpfs root, a bind mount) got `EXDEV` on every note it tried to store. **After:** the temp is staged in the TARGET's own directory, so the two paths share a filesystem by construction rather than by deployment luck. The store no longer creates or touches `<root>/tmp` at all.
95
+ - **Before, phase.** Every projection write happens AFTER the note's event is appended and fsync'd. A failure there escaped as the raw filesystem error, and a caller cannot see which step threw — so a committed note read as a lost one, and a caller that retries on failure stored it TWICE. **After:** it is `MemoryProjectionError` (**new export**, `code: "memory.projection_failed"`, `committed: true`, `path`, original error as `cause`), whose message says the note is durably committed and that retrying would duplicate it. Deliberately NOT a `MemoryGateError` — that one means the write was refused and never reached the backend, the opposite action. New contract `memory.projection_failure_is_post_durable`.
96
+ - Pins: `test/backlog784-legacy-store-projection.test.ts` (a mocked filesystem where NO rename may cross a directory still takes every note; `<root>/tmp` absent after a committed note; a `<root>/tmp` that is a FILE no longer breaks writes; the typed throw with its four members; the note replayed from the durable log after that throw; and a healthy-write negative control).
97
+
98
+ ### Fix — a memory containment no longer overwrites a file another writer put back (#783; @server @cli @client-core)
99
+ - **Before.** The containment captures the suspect bytes, UNLINKS the path, and then writes the committed baseline back. Between those two steps the path is free, and the writer that put the suspect bytes there is usually still running — an editor's save, a sync client, the user's own next write. The restore's `O_TRUNC` open destroyed whatever landed in that window, to restore a copy the committed store still holds. Two containment arms carried it: the capture-residue sweep and the polluted-harvest record loop.
100
+ - **After.** Both arms go through ONE function, and the rule is one sentence: **the restore may only overwrite what the containment ITSELF left at the path.** The removal now reports that (`QuarantineOutcome.leftOnPlane` — absent when it unlinked, this engine's tombstone when it cleared in place, the suspect bytes when neither rung worked), and the restore re-reads the path and compares immediately before the write. It compares one byte PAST the expected bytes, so a file that merely STARTS with them is somebody else's; it reads through an open that refuses a link, a device or a writer-less pipe and anything but a regular file, and that LOOPS to EOF, because a single short read is exactly how "this file is only the marker" would be concluded about a file that is the marker plus somebody else's byte. Anything that is not what the containment left — a freed path, a path somebody took back, a path that could not be verified — takes an atomic **no-replace** write (`writeFileNoReplace`, `O_CREAT|O_EXCL`, which also refuses a symlink dropped into the same window): a free path is recreated, an occupied one KEEPS its bytes and is reported. The comparison is asked on every arm because the polluted arm awaits the committed store in between, and no statement the removal made survives that await as a statement about NOW. Declining never weakens containment where the suspect bytes were removed (they are in quarantine) or replaced (whoever replaced them took them off the plane as surely as the removal would have). **One case is genuinely weaker than the unconditional write and is stated rather than hidden:** a suspect file that neither removal rung could touch AND whose bytes cannot be re-read (unreadable, replaced by a link or a pipe) is now left standing instead of overwritten — it is reported as an uncontained path in the harvest's `quarantineFailures`, where before it was silently overwritten. The trade is deliberate: the alternative destroys bytes the engine cannot prove are its own.
101
+ - **New report member `HarvestReport.restoreWithheld?`** (`[{ path, entryId?, rev? }]` — `rev` is the committed generation that was declined) and **new notice code `memory.restore_withheld`** (audience `operator`). Absent/empty ⇒ every restore landed, which is what a pre-#783 report always meant. Dedup unit: once per containment PASS, listing every path it withheld — never per path.
102
+ - **Same-class residue, filed not fixed and now for a DIFFERENT reason:** the harvest's SCAN-REJECTION containment (`engine.ts`, the B1 restore that doubles as a third containment rung) has the identical shape — quarantine, `await` the committed store, unconditional truncating write — and the argument its comment gives for the unconditional write is the same one this ticket refuted. What stops a one-line swap is not that argument but its ACCOUNTING: that arm sets `contained` off the write landing, and a declined restore would have to decide what the rejection row then says. It is a third site, outside this ticket's two arms, and the question is a report-contract question. The sweep's derived-INDEX arm keeps the overwrite deliberately (the index is derived and rebuilt from entry frontmatter).
103
+ - Pins: `test/backlog783-containment-no-replace.test.ts` (a repeated sweep over an already-tombstoned file still reporting a successful containment — `removed` is the rung's answer, never a comparison of the value; the no-replace primitive's four arms including the symlink and the non-occupancy throw; the quarantine's three `leftOnPlane` answers, each driven for real — the read-only directory for the tombstone rung, a read-only file inside it for the neither-rung one; the polluted harvest raced through the one read that sits inside the window, so the competing write is hit deterministically rather than by timing, on the freed arm, the tombstoned arm AND the neither-rung arm; the free-path negative control; the marker still restoring; the ownership read refusing a link, a pipe and a marker-prefixed file; a vanished marker recreating a free path; and two Runner-level pins that the notice reaches `RunnerDeps.onNotice` once, with a negative control) and `test/backlog783-verification-read-short.test.ts` (one byte per read, both directions). The A-005 per-site no-follow audit is re-anchored: the two restore rows move to the shared function, whose no-replace arm is an exclusive create (which refuses a symlink at the final component outright) and whose overwrite arm still writes no-follow.
104
+
105
+ ### Fix — a backgrounded shell no longer shows a person the engine's own cwd marker (#743 / bugs B-098 core half; @cli)
106
+ - **Before.** Every tracked-cwd command carries an epilogue the shell leg appends — `printf '%s%s%s' '<marker>' "$(pwd)" '<marker>' 1>&2` — so a `cd` persists across calls. Only the FOREGROUND result path read that span back out. A command moved to the background is spooled by the watcher, long after the call that minted the marker returned, so its completion notification's `--- stderr (tail) ---` section handed a person `__cc_cwd_9f2c1b__<hex>/some/directory__cc_cwd_9f2c1b__<hex>` verbatim.
107
+ - **After.** One reader, `stripCwdSentinel`, minted beside `CWD_SENTINEL` in `fs-shared.ts` and used by both paths: the foreground passes the marker it minted (exact — another execution's span printed BY the command is the command's own output and stays), the spool assembly passes none and recovers the marker per span from the sentinel plus its per-execution hex suffix. A bare sentinel with no suffix is never treated as an opener: a real epilogue always carries one, so a bare occurrence is the command's text (a log line, a `grep` over this source), and pairing it with the next real span would have eaten everything between them. The captured directory is dropped on the background path — a background shell's cwd is not tracked. `readCwdEpilogue` is gone; the rule lives in one place.
108
+ - **Ordering matters and is pinned:** the strip runs BEFORE the 2 000-char tail cut, so a span straddling the cut cannot leave half a marker in view.
109
+ - **No behaviour change on the foreground path** (same law, same marker) and none for a stderr that carries no epilogue (delivered byte for byte).
110
+ - Pins: `test/backlog743-cwd-sentinel-background.test.ts` (the failed and killed background frames, the no-epilogue and unmatched-opener negative controls, the straddling cut, the two callers' agreement, the loose-bare-sentinel case that agreement found, mismatched suffixes, and two foreground end-to-end pins — a printed foreign span survives, `cd` still persists and no marker reaches the model).
111
+
112
+ ### Fix — a broken display sink can no longer end the worker: one contained door for every forward (#802; @server @cli @client-core)
113
+ - **Before.** `RunInternals.onForwardEvent` is the deployment's opt-in subagent display sink, declared `(event: TaskEvent) => void`. TypeScript accepts an `async` function at a void seat, so the seat admits TWO failure shapes and the type stops neither. Five engine sites fed it: four wrapped the call in a bare `try`/`catch` — the synchronous throw contained, silently, with no count and no word to the operator — and the tool-facing wrapper in `prepare-run-refs.ts` had no guard at all. None of the five contained a REJECTED promise: an async sink that rejects on one event type escaped as an unhandled rejection and ended the worker, over a render channel. (Reproduced with a sink that rejects only on `tool_start`, a type the engine forwarded long before any of this.)
114
+ - **After.** One door, `forwardSafely` (`src/core/runner/forward-sink.ts`), and all five sites call it: the frame is offered inside the shared host-callback primitive (`safe-notify.ts`), a synchronous throw and a returned promise's rejection are routed to the SAME counter, and delivery is never awaited — so a sink that never settles cannot hold a turn open either. The door takes the internals OBJECT, not the pulled-out function, so a sink written as a method keeps its receiver (the tool-facing wrapper previously called it receiver-less). Contract sentence `runtask.forward_sink_never_faults_the_run`.
115
+ - **New notice code `config.forward_sink_failed`** (closed catalog `ENGINE_NOTICE_CODES`, audience `operator`, `detail: { site, cause }`). Dedup unit: **once per run** — the counters ride `RunInternals.forwardScope`, an engine-owned seat minted by `Runner.runTaskStream` and carried by every later copy of the run's internals (the prepare re-spread, a placement-root rebind, a child's snapshot), so a sink that fails on every frame costs ONE line and not one per frame. A holder carrying no scope (a hand-built internals outside a Runner) is still contained and still bounded; only the channel falls back to the console.
116
+ - **No behaviour change on a healthy sink**, and none on a throwing one beyond the added disclosure: a throw was already swallowed at four of the five sites, and the fifth is the one that could take the process down.
117
+ - **The containment's own normalization is total, sentence included.** A hostile `message` ACCESSOR is the third shape: `instanceof` succeeds, so nothing but reading the sentence tells the value apart, and the read used to happen downstream of every guard where the notice is composed — the notifier swallowed the fault (a disclosure channel is host code too), the site was already marked reported, and that run announced nothing again. `message` is now read inside the normalization guard, and the composing site reads through a total accessor (`safeErrorMessage`) rather than touching `.message` itself.
118
+ - **The containment's own normalization is total.** Neither step of "turn a thrown value into an Error" is safe on a hostile value: `String(x)` raises on a null-prototype object, and `instanceof` walks a prototype chain, which a REVOKED Proxy throws on. Either one, run unguarded, is a second fault source at the exact place the first was being absorbed — the throw escaped the door and ended the run, where the bare `catch` blocks the door replaced had contained it. Both steps now live inside the guard in the shared primitive (`safe-notify.ts`), with a constant sentence for a value that cannot be inspected, so every site on that primitive inherits it. Pinned on both dialects (throw and reject) for both value shapes.
119
+ - Pins: `test/backlog802-forward-sink-containment.test.ts` (throw / reject / never-settle over a real Runner, the healthy negative control, receiver identity, the bounded announcement, the scope surviving a re-spread, the no-scope fallback, and a SOURCE pin over the five lanes — a bare `onForwardEvent(…)` call re-introduced anywhere in them is red, with the door's own file as the aliveness control).
120
+ ### BREAKING / widening — the `<skills>` ceiling is retired: the attachment lane has two delivery classes, and a frame that must arrive whole is bounded by its producer, not by the shared cap (#818; **BREAKING**: `skills.listing_truncated.detail.requestedBudgetBytes` removed; @server @cli @client-core @test)
121
+ - **Before.** Every turn attachment shared one 8 KiB byte cap (`ATTACHMENT_BYTE_CAP`), applied to the whole bundle: the first frame that did not fit was clipped or dropped and the budget closed. But a listing frame's record advances only when the frame arrives INTACT — a clipped `<skills>` or agent-roster frame could never be committed, so it re-rendered and re-clipped at every boundary. The `<skills>` budget was therefore clamped to 8 000 bytes in three places (`resolveSkillListingBudget`, the renderer, the drift frame), which made `TaskSpec.skillListingBudgetFraction` a one-way knob — a deployment could lower its listing budget and never raise it, whatever its context window. A starvation latch swapped the skills and agent frames' order to paper over the symptom, and the agent roster had no self-bound at all: a large roster was delivered unbounded on the first frame and clipped forever at a boundary.
122
+ - **After.** `ATTACHMENT_DELIVERY_CLASS` (`src/core/runner/turn-attachments.ts`) declares each source's class once, and `satisfies Record<AttachmentSource, …>` makes it total — a new source that is not classed, or an intact one without its `bound`, does not compile. **CLIPPABLE** sources are the window-based reminders that re-fire at the next quiet boundary; they keep the 8 KiB cap, whose semantics narrow to "the clippable bundle". **INTACT** sources (`tools_delta`, `agent_listing`, `skills_listing`, `mcp_instructions`, `mcp_dropped_tools`, `date_change`, `instructions_change`, `workflow_size_guideline_change`) ride whole and each declares the bound its own producer honours. The three 8 000-byte clamps, the `SKILLS_BLOCK_MAX_BYTES` constant, the starvation latch and the `skills.listing_budget_ceiling` contract are gone; the budget is now enforced exactly as declared (`@contract skills.listing_budget_enforced_as_declared`).
123
+ - **BREAKING — `skills.listing_truncated.detail.requestedBudgetBytes` is removed.** It existed only to disclose the gap between the declared budget and the lane's clamp. There is no clamp, so there is no gap: `budgetBytes` IS the budget in force. A consumer that compared the two to tell "lane-clamped" from "fraction-bound" must read `budgetBytes` alone. The notice's code, audience, firing rule and every other detail key are unchanged; the message text loses its "(N bytes were declared; … caps it at 8000)" clause.
124
+ - **Widening — `skillListingBudgetFraction` is two-way, and it is now every listing frame's budget.** Absent still reads `0.01`; the domain `(0, 1]` and the loud refusal `config.skill_listing_budget_invalid` on a bad value are unchanged. At the default 200 000-token window the listing is **byte-identical** to 7.19.0 (8 000 B). A 1 000 000-token window now renders up to 40 000 B at the default fraction, and 200 000 B at a declared `0.05`. The same resolved budget now also bounds the `agent_listing` frame.
125
+ - **The agent-type roster folds through the one listing engine.** `foldListingWithin` (`src/core/runner/listing-fold.ts`) is the single three-tier fold every listing frame renders through — the `<skills>` block, both drift frames' add arms and the roster. Within budget the roster is byte-identical; above it, every type stays listed (the last-declared ones lose their description first) behind a marker line, and the pathological case closes with an honest overflow count. `tools_delta`'s `added` arm collapses by server above 30 names, the form its `removed` arm already had.
126
+ - **New notice `agents.listing_truncated`** (`ENGINE_NOTICE_CODES` +1, audience `operator`): one per DELIVERED agent-listing frame whose budget cost a type its description or its line; a roster that fits emits nothing. `detail: { budgetBytes, total, kept, nameOnly[], dropped[], sessionId }` — no `descMaxChars` (the per-skill description cap does not apply to agent lines). A consumer that pins `ENGINE_NOTICE_CODES` membership must add this member.
127
+ - **Deployment operators (headless included).** A single boundary steer, and the first user message, may now exceed 8 KiB — by exactly what the deployment declared. On a 1 000 000-token model at a raised fraction that is tens of KB of listing per delivered frame. The postToolBatch `additionalContext` room grows correspondingly: it is now `cap − clippable bytes`, where a 7 KiB drift frame used to squeeze it to nothing.
128
+ - Pins: `test/backlog818-intact-lane.test.ts` (the delivery table's totality, every intact member's render measured against its own declared `bound` under adversarial input — including a producer whose own transform GROWS its input, and the widest tool name the roster admits, in multi-byte code points, beside the gate that refuses a wider one — the tiny-budget floor, the 1 M/0.05 delivery above 8 000 with the frame whole, the 200 000-default byte-identity negative control, the bad-fraction triple, the roster fold, the `tools_delta` collapse); `test/backlog806-skill-listing-budget.test.ts` re-pinned from the ceiling to enforced-as-declared; `test/prompt-prefix-stability.test.ts` unchanged — no listing frame enters the prompt prefix.
129
+
130
+ ### Fix — a listing frame is recorded as announced when the message carrying it reaches the transcript, not when it is handed to the queue (#826; @cli @client-core @test)
131
+ - **Before.** Both listing arms committed — announced map, checkpoint mirror, session snapshot append, `steering_injected` echo, the injected count and the truncation disclosure — BEFORE the carrying message existed. On the first-frame lane that was before `harness.prompt`; on the boundary lane it was before a `steer` that a halt could suppress or a run could end without draining. Six enumerated paths therefore recorded a listing as announced while the frame never reached the model, and because the record is the resume seed, the retry never announced it again.
132
+ - **After.** One ledger, `Prepared.frameReceipts` (`@contract frames.commit_on_receipt`): a frame parks a receipt keyed on the exact text of the message that carries it, and the `message_end` walk settles every receipt whose text matches — one carrying message can settle the git, agent-listing and skills-listing frames together. No receipt ⇒ no commit ⇒ the next boundary or the next leg announces again. The git-status frame's private `pendingReceipt` is retired into the same ledger, so the two lanes that used to hold opposite optimism now hold one rule. An in-flight guard keeps a queued-but-undelivered listing from being rendered a second time, and the parked snapshot write rides the git lane's existing owed-write station at each turn boundary.
133
+ - **Observable.** A run whose first user message never reaches the transcript now records no announcement, emits no `skills.listing_truncated` / `agents.listing_truncated`, no echo frames, and `attachmentsInjected = 0`; the next task on that session announces the listings. The first-frame echo frames move from before the objective's `human_input` event to the carrying message's `message_end` — same source, preview and count, now in the order the transcript itself has. A consumer that pinned "listing echo precedes `human_input`" must re-pin.
134
+ - **One station for the parked write, at the leg's exit.** A receipt's commit runs inside the `message_end` walk, where a second append would race the loop's own, so it parks the session snapshot. That write is now started in exactly one place — the leg's exit, which a resolved prompt, a rejected one and an aborted leg all reach — and under a bound, so deployment storage that never settles costs an attributable incident instead of the run. A turn boundary only MARKS the write owed: a boundary is the loop's own serialization point and the loop keeps appending after it, so a write started there is either unbounded (it wedges the turn) or bounded (its abandoned append races the loop's next append, and since every append is a compare-and-set on its parent leaf with no retry anywhere, that append is LOST — a message, not an announcement). The once-per-session arms ride the same entry and therefore the same station; their snapshot is now written at the leg's exit rather than at each boundary, which widens the crash window for a write whose stated degradation is one duplicate announcement next run.
135
+ - **Known and accepted for this version, with a ticket open.** Bounding that write means an abandoned one can still land later. Within a leg nothing else appends after it, but a NEXT task on the same session can: if the store hangs past the bound and then completes while that task's own append sits between reading the leaf and writing it, that append fails and its message is lost. Three conditions, stated plainly: a store that hangs ~15s and then completes; a next task on the same session starting inside that window (ordinary in production, not a rare event); and the landing falling between a leaf read and its compare-and-set. The engine has no bounded-and-non-abandoning session write today — that primitive is the real fix and is tracked separately.
136
+ - Pins: `test/backlog826-commit-on-receipt.test.ts` (the failed first-message write, the echo's position and exactly-once count, two receipts settled by one message — pinned inside the carrying message's own commit window, since a later re-announcement satisfies presence alone — a drained boundary steer recorded, a halt between collect and drain recording nothing, and a leg whose provider call threw still writing its parked snapshot); `test/git-status-frame.test.ts` as the negative control that the git lane's behaviour is unchanged.
137
+ ### Fix / security — an option's VALUE is read from the program's own option grammar, so the boundary stops inventing reads and stops missing them (#764; **narrowing** on the invented half, **widening the gate** on the missed half; @server @cli @client-core @test)
138
+ - **Before, two failures of the same missing fact.** The payload extractor approximates getopt: it cannot know where a short cluster's options end, so it offers EVERY suffix following a run of option characters and judges each. That INVENTED reads — admitting one more option character moves the split one place right, and where the next character is a `/` an absolute path appears the command never names: `sed -e's/a/b/' f` mandated on `/a/b`, `head -n1/x f` on `/x`, `tail -n1/x f` on `/x`, `diff -I1/foo a b` on `/foo`, `date -d1/2/2026` on `/2/2026`, `tar -C1/tmp x` on `/tmp`, and `grep -f1/x` carried a phantom `/x` beside its real `1/x`. It also MISSED reads — a character the scan does not count as an option ends it, so `curl -#K.npmrc` hid the `-K` config file behind curl's `#` progress flag and the deny row was read with no question at all under the default doctrine; so did the indirect form the scan has no concept of, `curl -d@<file>`, whose `@<file>` resolved as an ordinary relative name. Both were pinned residuals filed as this ticket, not new findings.
139
+ - **After.** One declaration, `VERB_OPTION_SLOTS`, says per verb which short letters and long names take a value and WHAT that value is — `path`, `indirect` (`@file`), `pattern`, `script`, `count`, `delimiter`, `glob`, `header`, `label`. One membership test decides whether a value becomes a path candidate, and the ATTACHED and SEPARATED spellings of one option read the same row, so `-e<pat>` and `-e <pat>` can no longer disagree. Ten rows ship: `head`, `tail`, `cut`, `grep`, `diff`, `cmp`, `sed`, `date`, `tar`, `curl`.
140
+ - **Rules removed, not added.** The two hard-coded verb branches in the boundary walk (`name === "cut"`, `name === "grep"`), the `/^-d./` delimiter regex and the three predicates behind them (`grepClusterValueOwner`, `isGrepPatternPayloadLongOption`, `isCutDelimiterPayloadLongOption`) are gone; what they did per program is a row per option now. Contract sentence `shell.read_boundary.option_value_slots`.
141
+ - **Fail-safe by construction.** The reader must account for every option character it walks, and the FIRST character a row does not declare hands the token back to the verb-blind scan — today's over-approximation, unchanged. So a verb with NO row is judged exactly as before, an UNDECLARED option of a verb that has one is too, and the optional-value options no getopt table can split (`grep --color[=WHEN]`, `sed -i[SUFFIX]`, `tail --follow[=HOW]`) are declared in neither map on purpose. A long-option ABBREVIATION reaching several names is settled only where those names agree about what THIS spelling does with its value, and the spelling itself answers one of the two questions: an attached `=value` binds the value to its own token, so arity does not arise and only "can this value name a file?" is left — the reading is then the widest of the reachable ones, keeping the candidate wherever any of them opens a file (`grep --ex=` reaches `--exclude-from`, so the candidate stays) and dropping it only where none can (`cut --o=` reaches `--output-delimiter` and `--only-delimited`; neither opens anything). A SEPARATED spelling must agree on arity too — consuming the next token where the option takes none would skip a real file operand — so `cut --o <sep>` falls back to the verb-blind scan and its tokens are judged one by one.
142
+ - **Three arities, because `getopt_long` has three.** An option whose value is OPTIONAL has no separated spelling: `diff --unified a b` hands diff two FILES and only `--unified=3` is a width. Filing such an option as a required-value one makes the walk eat `a`, and an eaten operand is judged by nobody — so they are declared in their own map, which reads the attached spelling and consumes nothing in the separated one (`diff --context`/`--unified`, `grep --color`/`--colour`, `tail --follow`, `sed --in-place`, `date --iso-8601`). A short letter whose value is optional is left out of both short maps, since an optional value can only ever be attached.
143
+ - **And a row is about a NAME, which two implementations may not spell alike.** `sed -l` takes a required line length in one widely deployed sed and NO value in another (`sed -n -e 1p -l <file>` prints that file); `grep --context` is optional in one and required in another; and the engine runs whichever the host ships. A disputed arity is resolved towards the reading that CONSUMES NOTHING, since only consuming can swallow a file operand: a disputed long name is filed optional-value, a disputed short letter is declared by neither short map. Where implementations disagree about a value's KIND instead, the file-naming reading is the one declared: over-approximating a value costs an ask, under-approximating it costs the read. Every row ships checked against a real binary rather than a manual page.
144
+ - **And a row applies while the command is still in its OPTION section.** POSIX ends option parsing at the first operand, GNU permutes, and a transfer program parses its own options anywhere — measured on one host, `head <file> -n <file2>` prints BOTH (there, `-n` is a filename) and `cut -f1 <file> -d <file2>` does the same, while grep permutes. Past the first operand of the program's own argv both readings are therefore taken and neither silences the other: the option-shaped TOKEN itself is judged as the filename it may be (measured: `head <file> -n<file2>` and `head <file> --lines=<file2>` both report `No such file or directory` on the whole word), the token after it is not consumed so it is judged as the operand it may be, and the slot's candidates are judged too so the file it may name is not lost. Adding a candidate is always safe; consuming is what hides one.
145
+ - **And a slot whose value is EITHER a date or a filename is declared the filename.** `--time-cond` reads the modification time of a file when its value names one and nothing in the value says whether it does (measured: the same transfer returns bytes against an older file and none against a newer one). Declaring it a path costs an in-root candidate on the ordinary date spelling and keeps the filename judged.
146
+ - **A URL value names a local file when its scheme is `file:`.** A transfer program reads the local filesystem through `file:/<path>`, `file:///<path>` and `file://<host>/<path>` alike (measured: `localhost` and `127.0.0.1` both read), so that slot is judged as the path it decodes to — every authority taken as local and none at all likewise, percent-escapes decoded to the name the program opens, and every other scheme naming a host and no file (an ordinary `http://localhost/<path>` is not a read).
147
+ - **And where the marker sits INSIDE a field spec, every file after one is named.** `--data-urlencode name@<file>` and `--form field=@<file>` / `field=<<file>` open that file (measured, all three); `--data <name>=@<file>` does not (it posts the text, also measured), so this is its own reading of its own slots rather than a widening of the `@`-at-the-head one. A multipart spec names more than one: `field=@<a>,@<b>` opens both, `field=@<f>;type=text/plain` opens `<f>` with the metadata stripped, and `x=y;headers=@<file>` opens that file and sends its lines as headers (measured at a listening socket — the header from the file arrived). So each `;`/`,`-delimited part's own tail is named BESIDE the whole tail rather than instead of it, since a slot whose value is arbitrary data may carry a delimiter inside a real filename. Naming only the first tail left an exact deny on the real path unable to fire; that gap was present on the base too.
148
+ - **A row travels with the PROGRAM, not with the deployment's read table.** An unlisted program's segment is still judged under the synthetic head that gives it no per-verb ARM (`cd`'s base threading, the recursive forms, a pattern slot) and is still refused by name; only its option grammar is read under its own name, because "what curl does with `-d`" is a fact about curl while "curl may be read from" is a claim only a deployment can make.
149
+ - **Behaviour, for consumers — the RANGE.** Under the DEFAULT doctrine (`shellGate` absent) these spellings that ran with zero asks are now ONE mandated ask each and do not run: `curl -d@<outside>`, `curl -d @<outside>`, `curl -#K<outside>`, the abbreviations reaching the same slot (`curl --da=@<outside>`), the header form curl reads a file through in all four of its spellings (`curl -H @<outside>`, `-H@<outside>`, `--header @<outside>`, `--header=@<outside>` — one header per LINE of that file), the local-file URL in all of its spellings (`curl --url file:/<outside>`, `file:///<outside>`, `file://<host>/<outside>`, attached and separated, percent-escapes included), the field specs whose marker is embedded, including every file a multipart spec names and the metadata stripped off each (`curl --data-urlencode <name>@<outside>`, `curl -F <field>=@<outside>`, `-F <field>=@<a>,@<b>`, `-F <field>=@<outside>;type=…`, `-F x=y;headers=@<outside>`), and the file-time condition in both spellings (`curl -z <outside>`, `curl --time-cond=<outside>` — a metadata read rather than a content one, and judged like any other), the same value slots written AFTER the endpoint (`curl <url> -d @<outside>`, `curl <url> -H @<outside>`), a delimiter option written after the first operand where the program reads it as a filename instead (`cut -f1 <in-root> -d <outside>`), and the deny-row spelling of each. Every one of these was confirmed by running the program: each really opens the file the boundary now names. In the other direction, one refusal the blind scan used to raise is gone for the same reason — past the first operand `head <file> -n/<in-root path>` opens a file spelled `-n/<in-root path>`, which is inside the roots, and the `/<in-root path>` the scan refused on was never named at all; the escaping spelling of the same shape is still refused, now on the word the program really opens.
150
+
151
+ - **Known residual, filed rather than fixed because it is a different class.** A transfer program expands ranges and lists in its own URLs (`file:///p/node_module[s-s]/x`), so the decoded candidate is a literal the filesystem does not have while the program reaches a different one. This is the program's own expansion rather than a question about what its option's value IS, it is missed identically on the base, and closing it needs the unresolved-operand path rather than a candidate. Ten that raised a mandated ask on a path the command never opens now run unasked: `sed -n -e'<script>'`, `head -n1/x`, `tail -n1/x`, `diff -I1/foo`, `date -d1/2/2026`, `tar -C1/tmp`, `grep -f1/x`, `grep -fe/x` (the phantom is the `/x` one place right of the real, in-root, value `e/x`), the SEPARATED spellings of grep's pattern slot when the pattern begins with a `/` (`grep -e /api/v1`, `-He`, `-ie`, `--regexp` alike — that is grep's pattern TEXT and it is never opened; the attached spelling `-e/api/v1` already ran unasked, and the `-f` spellings of the same clusters, whose value IS a pattern file, are unmoved and still refuse), `cut --delim <sep>` (an unambiguous abbreviation of `--delimiter`, previously conservatively refused because only the exact spelling was recognised), a curl header whose TEXT looks like a path (`curl -H/etc/x`, `curl --header=X-A:/etc/x` — curl sends that text and opens nothing; only the whole-value `@<file>` form is a read), and the optional-value spellings whose value is an enum and not a name (`grep --color=<WHEN>`, `tail --follow=<HOW>`). Under `"classify"` the unlisted names (`curl`, `tar`, `date`) keep their own retirable ask either way. The `grep --exclude=`/`--include=`/`--label=` over-fire pinned at 7.17.4 is retired with them; `--exclude-from=` and `--file=` still gate, because those slots are declared files.
152
+ - **Not folded in, and measured rather than argued.** The stdin floor keeps its own option model (`SEPARATED_VALUE_OPTIONS`): it asks whether a value fills a counted SLOT, this table asks whether a value can NAME a file, and grep's pattern is the row where the two answers differ. Reading the floor off the new table was measured over a 580-row corpus and moved twelve of its verdicts, eight of them wrongly. Both doc blocks now say so and point at each other.
153
+ - Pins: `test/backlog764-verb-option-slots.test.ts` (both faces and the real load path under the default doctrine; the negative control on a program with no row and on an undeclared option of a program that has one; the getopt cluster reading; the abbreviation rule in all three of its directions (settled both ways on an attached value, fallen back on a separated one); the traversal-root split; `--` end-of-options; the seat's agreement) plus the six rows this closes in `test/backlog763-attached-payload-deny.test.ts` and `test/backlog768-bash-canonical-recheck.test.ts`, each rewritten from "pinned residual" to "closed, with its negative control beside it".
154
+
155
+ ### Docs / pins — #773 closes as already fixed: the execute-time recheck's exclusion is keyed on WHICH QUESTION WAS ASKED, not on who answered it (no behaviour change)
156
+ - **The ticket's premise was true of one tree and not of this one.** #773 reported that a pattern ask retired by a stored rule or an auto tier is never re-checked, because the recheck was handed an empty pattern list and "the pattern arm belongs to whoever answered the ask" — while a rule-cleared ask has no answerer. That was the scope ruling's shape at 7.17.5; the next car on the same leg (7.17.6) restored the pattern arm, and the leg now expands what the shell will expand and judges every match. Measured through the real load path on both doctrines: `ls vendor/*` over a link out of the roots is zero asks and REFUSED under a covering `Bash(ls:*)` rule, under `permissionMode:"auto"`, and with nothing covering it at all.
157
+ - **The correction the ticket needs, and the reason no seat is added.** The exclusion is keyed on the QUESTION, not the settler: a path the walk itself placed outside the roots rides `outOfRootPaths`, is a mandated ask, and is excluded — a person's yes on it runs, and a covering allow rule cannot retire it. Everything else the walk vouched is re-judged whoever cleared the call. So the proposed "how did this call's gate settle" member on the execute context would answer a question the leg does not ask; the cheaper invariant it would encode — nobody's answer releases a candidate nobody was asked about — already holds. The ticket's own sub-claim that a person's allow suppresses the recheck for the same command is not the tree's behaviour either: `ls vendor/*` raises no ask to say yes to.
158
+ - Pins: `test/backlog773-recheck-scope-by-question.test.ts` (rule-cleared, auto-cleared and uncovered × both doctrines; the mandated out-of-root pattern a rule cannot retire and a yes does run; the in-root lower bound; two mutation directions measured in the header). No `src` change.
159
+
160
+ ### Security / narrowing — the execute-time read boundary asks what an adapter DECLARES about its own resolver's silence (#787; opt-in, absent reads as today; @server @cli @client-core @test)
161
+ - **Before.** Resolving a spelling walks it from the deepest answerable ancestor; when NO ancestor answered — not even `/` — `escapesAfterResolution`'s judge returned with no finding and the lexical verdict stood. Its two siblings on the same factory were already fail-closed (`canonicalOf` reports unknown, `allResolveInside` returns false), and a PARTIAL failure a few lines below is the `unverifiable` refusal; only the whole-chain silence passed. The premise behind it ("`/` resolves in any normal env") is a statement about a LOCAL filesystem — on an RPC-backed lane `canonicalPath` is a call into somebody else's namespace, and one dropped call released every candidate of the command.
162
+ - **Why not simply fail closed.** That was tried and reverted before this car (`696c1f2c` → `32776b7e`): an env that answers no path question at all — a test double, an adapter with no resolution surface — had every in-root read refused, twelve red in the read-boundary suite. The same `undefined` means two different things and the leg cannot tell them apart from the value.
163
+ - **After.** The adapter says which, in the form #782 established for this same leg: `RemoteExecutionEnv.capabilities.canonicalPathAuthoritative?: boolean`, read STRICTLY by `canonicalPathIsAuthoritative` (exported), because a `true` makes a containment leg refuse where it used to pass. **Absent — and every value that is not the boolean `true` — is the reading every adapter has today**, so no existing env moves and `NodeExecutionEnv` (not remote, carries no capability block) is unchanged. Declared ⇒ a chain nothing could be established about is `unverifiable`, and the read is refused rather than passed, with the same sentence the partial-failure arm already issues.
164
+ - **Capability declarations of the wrong SHAPE are announced, and the suspend reader is now strict (narrowing).** The three boolean capability keys are read by readers that take the value as given, so a PRESENT non-boolean was turned into a verdict with no sound at all — and the three verdicts disagreed: the two containment readers take only the boolean, so a malformed value read as false (the safe side), while `isSuspendable` took anything truthy, so an adapter that wrote `suspendable: "false"` was snapshotted. One census at the run's execution-env admission point now announces each malformed key once per run (`config.execution_env_capability_invalid`, operator audience, `detail: { key, valueType }`), and `isSuspendable` reads `=== true` like the other two. **ABSENCE is unchanged and stays silent on every key**, which is each key's documented reading, so no adapter that is correct today gains a notice or moves. **The narrowing is confined to malformed declarations**: any `suspendable` value that is not the boolean `true` no longer suspends — previously a non-empty string did. A correct `true`/`false` behaves exactly as before.
165
+ - **Who should declare it.** Any adapter whose `canonicalPath` answers for the very filesystem the commands run on — a host lane, an SSH host, an RPC-backed env driving a real machine. An env that cannot answer path questions should stay silent. Orthogonal to `isolation`: a declared isolated sandbox skips the resolving pass entirely, so the key changes nothing there; it is the NON-isolated remote lane #782 turned the pass back on for that this is about. `docs/wire-consumption/unreleased-764.md` carries the row, the export and the open question for @server (which adapters should declare it).
166
+ - Pins: `test/backlog787-canonical-silence-declared.test.ts` (a lane whose resolver is silenced on purpose, declared and undeclared, both doctrines; the strict reading of five non-`true` values; a declared lane whose resolver WORKS is unchanged in both directions; the missing-path and plain-local arms untouched; three mutation directions measured — the unconditional one reproduces the reverted fix's twelve rb413 reds).
167
+ ### Tooling — the release's wire manifest states the closed sets it cannot read, instead of reading them as empty (#828)
168
+ - **Before.** A closed set is read out of the source syntactically: an exported array of string literals is a vocabulary, and a member lost from one is a breaking change for everyone who pinned the word list. An array that spelled some of its members and derived the rest (`[...spelled, ...derivedElsewhere()]`) was unreadable to that reader, and the reader expressed "unreadable" by returning nothing — indistinguishable from "this name is not a closed set". The set then dropped out of the model and the diff spent its absence as an EMPTY vocabulary, so a set that merely stopped being spelled out was announced as having lost every member it had. Measured on the 7.19.0 first cut: 26 announced removals and zero additions, against a real delta of +17/−2, one step from shipping in a manifest three downstream repositories read with a machine table.
169
+ - **After.** Unreadable is a third answer with its own list, `closedSetsUnreadable[]` (`set` · `from` · `to` · `reason` = the element form plus `file:line` · `unreadable`). It carries **no `members` key** — an empty list is a claim, and the missing claim is the point — and such a set contributes **no member delta row and no breaking entry**, so neither a false BREAKING nor a false additive can be derived from it. Two readings produce it: the syntax could not read the array at that ref, or the set was readable at `from`, still carries its name at `to`, and no longer reads as a vocabulary (its literals lifted into another constant and spread back in, a union alias degraded to a reference). An EMPTY array under a string-list annotation stays a readable vocabulary with no words — emptied and unreadable must not collapse, because the first owes the post a breaking line and the second owes it a regeneration.
170
+ - **The gate.** A manifest carrying an unreadable set at `to` is REFUSED by the ship post's WIRE leg, naming the set, its coordinate and the element that stopped the read; `WIRE: none` is no way past it. The mirror (unreadable at `from`, spelled out at `to`) is a note with the new baseline, never a gain of the whole vocabulary. The generator still writes the manifest and exits 0, printing `UNREADABLE ·` beside its `BREAKING ·` lines — the gate is the single red exit.
171
+ - **Source side.** `test/closed-sets-literal-members.test.ts` refuses a half-spelled vocabulary at commit time, from two rosters rather than a hand-kept list: every `array:` row of `docs/CLOSED-SETS.md` (read through that registry's own parser, and cross-checked word for word against the manifest's reader — the incident was a disagreement between exactly these two) and a sweep of all of `src/`. A vocabulary that spells NO member of its own (`[...OTHER_TABLE]`, `Object.freeze(REGISTRY.filter(…).map(…))`) claims nothing and is read exactly as its derivation source is: out of scope, deliberately, since spelling a copy would replace a reading with a hand-kept duplicate.
172
+ - **`BASH_CLASSIFY_DEFAULT_ALLOW` is retired as a constant** (internal; never on the public export surface or `src/index.ts`) in favour of the spelled table `BASH_CLASSIFY_EXTRA_ALLOW` = `["find", "sed", "cd"]` and the function `bashClassifyDefaultAllow()`, the one point where the two tables are folded (contract sentence `shell.classify_allow.one_union_point`; registry row `BashClassifyExtraVerb`). The union is the same 39 verbs in the same order — **zero behaviour change**, and both halves are now vocabularies the manifest reads, so a verb added to or dropped from the classify allowlist appears in a release's wire delta instead of moving nothing.
173
+ - **Four more spellings the reader had been blind to**, found by the adversarial round on this change and closed with it. (a) `satisfies` and parentheses are seen through, the way the closed-set scanner already did — four more spelled vocabularies (`REVIVED_ROW_CLEARED_FIELDS`, `COMPLIANCE_CAPABILITIES`, `TOTAL_TOKENS_REMINDER_MODES`, `WHITELIST_KEYS`). (b) `Object.freeze([...])` is the same spelled vocabulary with a runtime guard around it; not unwrapping it left **six vocabularies on the public export surface** (`PEER_SEND_VERDICT_CODES`, `CROSS_SESSION_HOLD_CAUSES`, `CROSS_SESSION_INBOUND_SETTINGS`, `PEER_ADDRESS_PREFIXES`, `PEER_HELD_REVIEW_CAUSES`, `PERMISSION_MODE_CLASSES`) moving nothing in any manifest, whatever was added to or dropped from them. Exactly that one wrapper is unwrapped — an arbitrary call is a derivation the reader cannot vouch for. (c) One identifier carrying BOTH a value vocabulary and a type vocabulary (TypeScript keeps the namespaces apart) is now unreadable rather than merged: publishing their union as one word list would hide a member dropped from either inside the other's words. (d) The manifest's own entries are validated, not just their container — the refusal keys on one boolean, so a drifted manifest that spells it any other way would have read as "nothing to refuse".
174
+ - **A union arm that REFERENCES another type is resolved, not read as an absent member.** `"a" | "b"` → `type Tail = "b"; "a" | Tail` used to report `"b"` as REMOVED — a breaking entry for a change no consumer can observe — and the reverse reported a member gained; six exported vocabularies in this tree were publishing a partial word list that way (`PathIdentityRefusal`, `PeerInboundHoldCause`, `ToolOrigin`, `LenientConnector`, `CheckpointSeat`, `ModelThinkingLevel`). A reference now resolves three ways: to a vocabulary (exported or file-local, directly or through a `(typeof CONST)[number]` alias) whose words are folded in; to a SHAPE (an interface — `"unset" | SomeStore` is one word plus a store), which contributes none; or to nothing this reader has, which makes the set unreadable rather than published short. The pointer alias itself gets no manifest entry, or one moved word would be reported twice under two names.
175
+ - **Two more ways the statement could be dodged, closed.** From `UNREADABLE_LEG_FROM` (7.19.1) on, a manifest must CARRY `closedSetsUnreadable` — deleting the list from a generated manifest no longer reads as "generated before the reader could say it", and an empty list is how a release says the reader looked and every vocabulary read. And `WIRE: none` now counts an unresolved delta as movement, the way it already counts a narrowing: the opt-out claims this release moved no wire face, and a set whose words could not be read at one end is precisely a face nobody can claim stood still (the ordinary path keeps it a note). The freeze unwrap also declines to fire in a file that binds its own `Object`, since it reads a spelling.
176
+ - **Every union arm is CLASSIFIED, and two derived spellings became readings.** An arm this reader had no branch for — a generic reference (`Id<"b">`), a qualified name, a conditional or mapped type — used to be skipped like the open arm, so the literals beside it went out as the whole vocabulary; it is now reported by its form and the set is unreadable. The false-alarm half is pinned beside it: an inline shape, a function, an array, a tuple, a keyword and a non-string literal carry no word and leave the set read (two live types were wrongly flagged before that control existed). Two spellings this tree actually uses are now READ rather than refused: a table COLUMN (`(typeof TABLE)[keyof typeof TABLE]["resume"]`) is enumerated from the rows — the index must be shown to cover every row, a narrower one is a different vocabulary — and a TEMPLATE-LITERAL type is the cross product of its parts, with a span the reader cannot enumerate poisoning the whole arm. Measured on this tree: `ResumeGate` went from 1 published word to 6, `RoutePairingStatus` from 1 to 7, nothing else moved, and the 7.19.0 manifest regenerates byte for byte.
177
+ - **Names are scoped the way the language scopes them, and readability travels the graph.** A file-local vocabulary is keyed by its FILE: one flat bare-name index let an unrelated module's private `Tail` supply the words, so renaming that unrelated type reported a member REMOVED from a set nobody touched. A local alias that cannot be finished now makes every union naming it unreadable, however many hops away (dropping a local's failure because "a local owes no statement" published `["a","b"]` for a set with an arm pointing outside the tree), and one memoized walk keyed by the CANONICAL declaration — following a re-export's other spelling — makes the answer independent of the order the files were scanned. The freeze unwrap reads a BINDING, so `const { Object } = …` blocks it as surely as `const Object = …`.
178
+ - **The derived readings refuse what they cannot evaluate.** An intersection is the open-arm idiom only while it spells no word (`("b" & {})` is `"b"` with a brand on it, and calling it wordless dropped `b` and announced it lost); a template SPAN interpolates a number, a bigint or a boolean to the word a person reads there, and a span naming a type rather than words makes the arm unreadable; a table row carrying a spread or a computed key cannot be read whole, so its columns are unreadable rather than published from the cells that were visible; and a cross product past a stated budget is unreadable instead of unbounded — a valid 232-byte type asked the reader for roughly a million intermediate strings.
179
+ - **A name resolves in the file and the namespace it has, and a cycle is never cached.** A pointer's target belongs to the pointer's own file (an exported `type Tail = (typeof WORDS)[number]` names the `WORDS` beside it, not one private to the file that imported `Tail`); an ambiguous table name — two modules exporting a table of one name — answers nothing rather than whichever was scanned first; a local VALUE and a local TYPE of one name are two declarations, so a pure reordering no longer reads as a member removed; a `namespace Object` (and an enum, and an `import X =`) blocks the freeze unwrap as surely as a const; a local alias whose only reading is derived resolves instead of reporting itself unresolvable; and an answer computed while a cycle was cut is not memoized, which had made the reading depend on the name the walk started from.
180
+ - **A reading that is SHORT for where it is used is never spent as a whole one, and a bound name never falls through.** `(typeof WORDS)[number]` names a VALUE, so a type of the same name beside it no longer supplies the words; a template span that resolves through a union reading — where a number carries no word but an interpolation does — is unreadable rather than quietly missing `x1`; an intersection is wordless only when every constituent provably is (`(Tail & {})` is `Tail` with a brand on it, and reading it as wordless announced all of its words as removed); a table cell keeps only `as const`, since `col: "b" as "b" | "c"` declares a wider type than the value spells; a table this reader cannot read whole still BINDS its name, so the lookup stops there instead of publishing another module's private table; and a private `keyof typeof` alias is looked up in the namespace it is stored in — the two spellings disagreeing made an unchanged, fully spelled vocabulary unreadable, which blocks a release for a refactor nobody can observe.
181
+ - **The two derived readings are pinned WORD FOR WORD against a second reader**, because a reader that can see a derived vocabulary fails by misspelling rather than by missing: `ResumeGate` is checked against the registry's runtime values (the manifest's reader never evaluates; that one never parses), and `RoutePairingStatus` against a cross product expanded independently from the two declarations. Mutating the reader to drop a row, drop a span word or alter a spelled chunk reds them.
182
+ - **An emptied set is a removal, not an unreadable one.** `["a","b"] as const` → `[] as const` reports both members as lost, with its BREAKING line, instead of going red as a set that could not be read. `[]` claims nothing on its own; what it means is decided across the range — this name's empty vocabulary where the other ref spells one, and just an array where neither does.
183
+
184
+ ## 7.19.0 — 2026-09-16
185
+
186
+ ### Fix — a refusal's note seat is filled by ONE rule, so an empty rejection reason cannot mean two different things (#812 / B-123; **narrowing** on the durable-resume path; @server @cli @test)
187
+ - **Before.** The `note` on a `human_refused` settlement is the batch boundary: absent = the decider said "no" and nothing else, so every never-started sibling of the same assistant batch settles `gate.batch_halted`; present = a reasoned deny that stops only the call it named. Two paths filled that seat and they folded empty text differently. The synchronous approver's `{ allow: false, reason: "" }` was folded to absent in the resolver (`tool-policy.ts`), so the batch halted. The durable decide's `{ decision: "deny", reason: "" }` was kept verbatim as `note: ""` (`resume-apply.ts` filtered only `undefined`), so the record read "reasoned" and every sibling RAN — while the model-facing continuation for that same decide, which tests the reason for truthiness, told it the call `was DENIED` with no reason at all. One deny, one reason text, opposite fates for the rest of the batch depending on which path carried it.
188
+ - **After.** A person's settlement has ONE composer, `settledByPerson` in `gate-outcome.ts` (beside the record it builds and its screen), and it applies the note seat's own rule, `decidersNote`: empty text is a decider who attached nothing. Both paths compose there — the resolver's human arms and the durable decide lane's person arm — and the resolver's live text now quotes the same folded note it records, so the words the model reads and the record a consumer reads cannot disagree. `maybeHumanRejectionHalt` is untouched: it still reads absence, and nothing else, as the boundary. Contract sentence `settlement.note` on the mint; registry row `settledByPerson` in `gate:single-mint` (a second inline composition of the record is red).
189
+ - **Narrowing (durable resume only).** A decide carrying `reason: ""` now halts the rest of the batch instead of letting the siblings run — it is read as the bare refusal the live path always read it as. Nothing widens: no deny becomes an allow, and a decide with any non-empty reason (whitespace included) behaves exactly as before. `reason: undefined` is unchanged on both paths.
190
+ - **Whitespace is unchanged and pinned, not promised.** `" "` / `"\n\n"` are a note today on BOTH paths (siblings run). That is pinned as current behaviour; nothing in the record's contract requires a note to carry content, and narrowing it would be its own decision with its own consumers to warn.
191
+ - Pins: `test/backlog812-settlement-note-fold.test.ts` (the same five reasons down both paths, compared to each OTHER rather than to a remembered number; the boundary read directly; the fold; the declaration-count pin).
192
+
193
+ ### Security / narrowing — a write spelled RELATIVELY from inside a write-protected directory is now judged the same as the absolute spelling (#815; found by #804's adversarial round; every deployment)
194
+ - **Before.** The write-protection judge (`compileWriteProtection` / the gate's fourth tighten) was purely lexical over the string in the tool's arguments, and never read the run's live tracked cwd. So `cd ~/.ssh` followed by a bare `Write("authorized_keys")` carried no guarded segment at all and produced **zero ask**, while the same file spelled absolutely asked. The hole is not one family's: it applied to **every `segment` row of the always-on table** — `.ssh`, `.git`, `.gnupg`, `.vscode`, `.idea`, `.husky`, `.cargo`, `.devcontainer`, `.yarn`, `.mvn`, `.claude`, `.sema` — and a bare deployment (no opt-in deny policy wired) had no second line behind it.
195
+ - **After.** A relative spelling is ANCHORED against the call's live tracked cwd — the same ref the fs hand tools resolve with, read at judgment time, handed by the gate — before the table is read. Two spellings of one file get one verdict. This is the law RB-108 already set for the path policies (its measured example was `cd .git/hooks` + `Write("pre-commit")`); the write-protection table shipped later and had never joined it.
196
+ - **Scope of the change.** Anchoring only; the join is the shared `joinForFamily` mint, with no normalization, so the two-view reading and the data-root passthrough are byte-identical on an absolute target. The passthrough now also covers relative spellings from inside the data root (a memory write from `cd <dataRoot>/memory` is exempt, as its absolute twin already was).
197
+ - **Seat (additive, absence reading preserved).** `WriteProtectionMatcher.matchPath(path, cwd?)` and `ToolGateInput.writeProtectionCheck(toolName, args, face?, cwd?)` take an OPTIONAL trailing cwd. **Absent ⇒ the spelling is judged exactly as given, i.e. today's lexical reading byte for byte** — a host driving `runToolGate` without `trackedCwd` sees no change, and an existing caller of `compileWriteProtection` keeps its arity.
198
+ - **Reach.** A deployment can now see an ask where a relative spelling previously produced none. That is a narrowing in the safe direction (one explicit look, clearable by an approver or a persisted allow rule); nothing that asked before stops asking.
199
+ - Pins: `test/backlog804-agent-config-write-tier.test.ts` (`#815` block — `.ssh` / `.git/hooks` / `.gnupg` / `.claude` judged identically on both spellings; the absent-cwd reading; an absolute target unmoved by the cwd in both directions; the passthrough on both spellings; end-to-end through `runToolGate` with and without `trackedCwd`).
200
+
201
+ ### Write protection is layered by what the file IS: agent configuration FILES deny, the configuration DIRECTORY asks (#804; @server @cli @client-core @test; deployment operators)
202
+ - **Before.** The opt-in write deny set (`RECOMMENDED_SENSITIVE_PATTERNS`) carried the agent configuration DIRECTORIES whole — `.claude*` and `.sema` as segment entries — so every path beneath them was deny severity. A person who told the agent to edit a file in their own library under `~/.claude/projects/<slug>/memory/*.md` got a hard refusal they could not lift: a deny outranks an ask under the deny > ask > allow fold, so the always-on ask table's `.claude` / `.sema` rows — which an approver or a persisted allow rule CAN clear — never got to answer. Measured on a live deployment (three refused Write/Edit calls on a file the user had named).
203
+ - **After — the law.** The class is split by what the file is, not by which paths were reported. An agent's own configuration FILE stays at deny severity (writing it re-authorizes the agent or hands the host a command line to launch); the configuration DIRECTORY around it holds the person's own working material — memory libraries, agent and skill definitions, project notes — and is guarded one severity down by `WRITE_PROTECTED_DEFAULT_TABLE`'s `.claude` / `.sema` segment rows, which are default-on and need no opt-in. Every write there still stops for one explicit look; it is now a look a person can answer.
204
+ - **After — one table, two judges.** The deny set's agent-configuration block is DERIVED from the read face's `agent-config` tier (`READ_FACE_BUILTIN_DENY_TABLE`, filtered) rather than spelled a second time, so the two faces cannot drift about which files are an agent's own configuration. Contract sentence `sensitive_paths.agent_config_tier_is_shared` (on `agentConfigDenyPatterns`): **the write deny set's agent-configuration block IS the read face's `agent-config` tier, row for row; the configuration DIRECTORIES (`.claude`, `.sema`) are on neither face's deny side — their write guard is the ask-side write-protection table.** A set-EQUALITY pin (not a sample) reds the moment either side gains or loses a row.
205
+ - **After — the ask tier is the SAME canonical judge, not only the lexical table.** `createSensitivePathPolicy` now answers TWO severities from one judge: `patterns` (deny) and a new **`askPatterns`** seat, consulted only where the deny set misses. Contract sentence `sensitive_paths.two_severities_one_judge` (on `createSensitivePathPolicy`): **a deny is a refusal no approver and no persisted allow rule can lift; an ask is one explicit look either of them may clear; lowering a path's severity never lowers its REACH** — both tiers see the real target (symlinks resolved, relative spellings resolved against the live cwd), which the synchronous write-protection TABLE deliberately does not. Without this, demoting the config directories to the lexical table alone would have traded a severity for a reach: a symlink whose spelling is innocent walks past a lexical judge (measured on the branch).
206
+ - **New export `RECOMMENDED_SENSITIVE_ASK_PATTERNS`** (additive; `readonly string[]` = `[".claude", ".sema"]`). **`askPatterns` ABSENT ⇒ this set** — a deployment that already wires this guard keeps the canonical reach it had over those directories **without changing a line**, at the severity a person can clear. `askPatterns: []` is the explicit no-tier posture and the only way to have none; a degenerate entry (`"/"`) throws at wiring time exactly as a degenerate deny pattern does. The data-root passthrough applies to both tiers, so the memory library is never asked about — alias included.
207
+ - **WIDENED (deny → ask), the whole point of the ticket.** Removed from the deny set: `.claude*`, `.sema`. Every path under a `.claude` / `.sema` directory that is NOT one of the configuration files below moves from a hard refusal to an approvable ask — `.claude/projects/**`, `.claude/agents/**`, `.claude/skills/**`, `.sema/projects/**`, `.sema/agents/**`, `.sema/skills/**`, and anything else under those directories. The two directories move to `askPatterns`' default set, so they keep their CANONICAL reach at the new severity. `.claude.json` / `.sema.json` are unaffected: `.claude.*` / `.sema.*` still deny them.
208
+ - **NARROWED (ask → deny), the two hook directories.** `.claude/hooks` and `.sema/hooks` join the deny set as WRITE-FACE rows of their own (hand-kept beside the derived block, absent from the read tier — reading a script is harmless, writing it is the escalation). A hook script is the command line the host runs on its next tool call, so editing one hands the host a command without touching the settings file that names it: the `.git/hooks` argument, one ecosystem over. Consequence for a deployment that wires the recommended set: a write anywhere under those two directories is a refusal no approver and no persisted allow rule can lift, where the directory relaxation above would otherwise have made a blanket `Write(~/.claude/**)` rule clear it. Two segments each, so the rest of the configuration directory is untouched and stays at ask severity.
209
+ - **NARROWED (allow → deny), eight patterns.** Taking the whole shared tier brings eight rows the write set did not carry. A write to any of these is now a refusal where it used to be an ordinary write (an approvable ask, or no friction at all):
210
+ - `.ai-agent/.env` — the engine host's data-root key file under the default root;
211
+ - `.sema/engine-data/.env` — the same file under the shell's layout;
212
+ - `.codex/config.toml` — `[mcp_servers.<name>] env` takes literal values;
213
+ - `.cursor/mcp.json` — MCP server definitions (`.cursor/rules` stays writable);
214
+ - `.continue/config.json` and `.continue/config.yaml` — `apiKey` fields, both serializations;
215
+ - `.aider.conf.yml` — `openai-api-key` / `anthropic-api-key` keys;
216
+ - `.gemini/settings.json` — `mcpServers` env.
217
+ Each is an agent configuration file whose `env` / `mcpServers` block is a credential or a command line the host launches — the argument the already-present `.mcp.json` row makes, applied to the same class by the same table.
218
+ - **Deployment operators.** This changes the RECOMMENDED set only. A deployment that sets `SENSITIVE_WRITE_PATTERNS` (or otherwise passes its own `patterns` to `createSensitivePathPolicy`) does NOT inherit the two deny-set changes — but it DOES get the ask tier: `askPatterns` absent = `RECOMMENDED_SENSITIVE_ASK_PATTERNS` (`.claude`, `.sema`), so a symlink alias or a relative spelling into those directories that was silently allowed before now asks once (on a headless lane: refuses). Pass `askPatterns: []` to opt out. The deny tier still ships no implicit default list.
219
+ - **Unchanged.** The data-root passthrough (HRD-PRM-10) and its exemption asymmetry; the `!canon.ok ⇒ deny` fail-closed arm for a target the guard could not resolve; the READ face in every respect (its table, its tiers, its verdicts — pinned as a negative control); the export `RECOMMENDED_SENSITIVE_PATTERNS`, whose name and type (`readonly string[]`) are byte-identical in shape — only its contents moved; `.sema-notifier-build`, the one directory of the brand namespace that keeps deny severity (its contents are compiled into an app the shell launches).
220
+ - **Two arms the ask tier's own review turned up, both fixed in it.** (i) The policy's "nothing to judge" early exit keyed on the DENY list alone, so an ASK-ONLY deployment (`patterns: []` with an ask set) silently got no protection — it now exits only when BOTH tiers are empty. (ii) The ask message named the model-authored path, and an ask's text is the auto-mode classifier's `askMessage`, so a newline-bearing filename could forge a turn inside the sentence the call is judged by; the ask now names the matched PATTERN only (the deny arm still names the path — a refusal reaches no classifier, and the approval card shows the real arguments either way).
221
+ - **Known gap, declared (#817).** The always-on write-protection TABLE — the floor a deployment gets without wiring this guard at all — is a synchronous LEXICAL judge with no canonical reach: a symlink whose spelling is innocent produces no ask there, and always has (`notes.txt -> .bashrc`, measured, unchanged by this ticket). That is a property of the whole table, not of these two rows; unifying the sync-lexical tighten seat with the async-canonical policy seat is a design question, filed rather than patched here.
222
+ - **@server (the one consumer of the recommended set).** No code change is required: `askPatterns` absent reads as the recommended directory set, so a deployment that wires `RECOMMENDED_SENSITIVE_PATTERNS` recovers the canonical reach automatically. Pass `askPatterns: []` to decline the tier, or your own list to replace it.
223
+ - Pins: `test/backlog804-agent-config-write-tier.test.ts` (the reported path and its class; the configuration files on both brand spellings plus the seven third-party rows; the data-root passthrough; a persisted allow rule silencing the ask and failing to touch the deny; the headless lane on both severities; the read face as a negative control; the derivation set-equality; and the ask tier — a symlink alias onto a config directory asks while the same alias onto a settings file still denies, `askPatterns: []` as the explicit no-tier posture with the remaining lexical gap pinned honestly, the passthrough through an alias, a replacement set, and the loud degenerate entry). Re-cut: `backlog549-sema-config-root-guard` (the config FILES keep their pins; the directory assertions say which half they mean; the passthrough-asymmetry fixtures now site their root beneath `.ssh`, a segment row still on the list), `p4-sensitive-patterns-completeness` (the coverage table follows the list — near-misses are now mostly a NON-config file in the very same directory, which is the law under test).
224
+
225
+ ### The `<skills>` disclosure block's budget is a DECLARED seat, sized to the model's window (#806; ruling C-R41; @server @cli @client-core @test)
226
+ - **Before.** The `<skills>` listing was capped at a flat 8 000 UTF-8 bytes, hard-coded, with no way for a deployment to say anything about it — and the per-skill description cap (1 536) was equally fixed. The three degradation tiers (full / priority-ordered descriptions / name-only + overflow count) were already in place and are unchanged.
227
+ - **After.** Two optional `TaskSpec` seats, CC 2.1.259's (`Jar`/`yTe`): `skillListingBudgetFraction` (absent ⇒ `0.01`, domain `(0, 1]`) and `skillListingMaxDescChars` (absent ⇒ `1536`, positive integer). The budget is `(contextTokens ?? contextWindow ?? 200 000) × 4 × fraction` bytes. Both are read at the config door, before session acquisition; a value outside its domain REFUSES the prepare with `config.skill_listing_budget_invalid` rather than folding back to the default.
228
+ - **Narrowing (behaviour face, no key required).** A model declaring a window SMALLER than 200 000 tokens now gets a proportionally smaller listing: 128 000 tokens ⇒ 5 120 bytes, 32 000 ⇒ 1 280, where all of them previously got 8 000. Every skill is still listed; the lower-priority ones (declaration order) lose their descriptions first and the `Skill` tool still serves all of them. At 200 000 tokens and above the default resolves to exactly the old 8 000, so the default render is byte-for-byte unchanged — pinned against the published 7.18.0 dist.
229
+ - **The fraction is ONE-WAY in this engine (`@contract skills.listing_budget_ceiling`).** The block is the body of the `skills_listing` turn attachment, and a bundle over `ATTACHMENT_BYTE_CAP` is clipped; a clipped frame never satisfies the intact-survival commit predicate, so it would replay at every boundary forever. The resolved budget is therefore clamped to 8 000 bytes: a raised fraction lowers nothing and raises nothing, a lowered one takes effect exactly. When the clamp costs a skill its description, `skills.listing_truncated` carries `requestedBudgetBytes` beside `budgetBytes` so both numbers are legible.
230
+ - **ONE RULER FOR EVERY FRAME — the drift frame is measured too.** The boundary's "New skills are now available" frame rendered every added line at full length with no block budget at all, and the removal arm listed every departed name however many there were. A drift that added ten 1 536-character descriptions produced a ~15 KB body (and a single 10 000-character description under a wide `skillListingMaxDescChars` produced a 10 KB one): over `ATTACHMENT_BYTE_CAP`, so the bundle CLIPPED it, so the intact-survival commit predicate could never match, so the same frame was re-rendered and re-clipped at every boundary for the life of the task. Both arms and the joiners are now priced against the SAME resolved budget the initial block uses, and the add arm degrades through the same three tiers (full → name-only in declaration-rank order → an honest overflow count). The frame's size is now the declared budget or, when even the two arms' floors do not fit it, a small constant — never a function of the roster. Stated cost: a departed name the removal arm cannot fit is not announced (the count line says how many), against a frame that would otherwise replay forever.
231
+ - **New notice `skills.listing_truncated`** (closed-set addition to `ENGINE_NOTICE_CODES`; audience `operator`): emitted by the lane that DELIVERS a listing frame — the leg's first-frame producer, or the turn boundary a drift (or a resume leg's initial listing) rides — once per delivered frame the budget actually degraded. `detail: { budgetBytes, requestedBudgetBytes, descMaxChars, total, kept, nameOnly[], dropped[], sessionId }` — every count is read off the frame that shipped, so `total` is the whole roster on an initial listing and the newly announced set on a drift frame. Three consequences of announcing at the delivery points rather than at the config door (#819): a deployment that turned the listing off (`attachments.skillsListing: false`) is no longer told that a listing it does not get would not fit; a session running many tasks hears the fact per delivered listing instead of per prepared task; and a **durable-resume leg**, whose first-frame producer never runs, is no longer silent about a listing the budget degraded on its way in (found by the adversarial round).
232
+ - **Performance.** The overflow (tier C) leg recomputed a prefix sum — and re-sliced the name array — on every candidate, making it quadratic in the roster size. One prefix-sum pass replaces it, byte-identically: 2 000 / 10 000 / 20 000 skills now render in 17 / 64 / 133 ms against 407 / 6 645 / 24 499 ms before.
233
+ - Pins: `test/backlog806-skill-listing-budget.test.ts` (six byte/sha fixtures against the published 7.18.0 dist; the budget boundary at exactly-equal and one byte under; the ceiling clamp and the renderer's re-clamp; eight + six bad-value refusals; the disclosure's silence and its detail; the multi-turn no-replay run; a source census that every render site is handed a budget; the tier-C rewrite against a naive reference plus its timing; the drift frame under both arms at 20 / 2 000 / 20 000 skills, the wide-description-cap construct, the removal flood, a durable-resume seed, and the delivered frame's own counts against the notice's).
234
+
235
+ ### One rejected-value describer, not two (#809; no wire face)
236
+ - The config-doors phase carried a second local `describeRejectedValue` built on `JSON.stringify`, beside the shared one every other config seat uses. Same three defects: the rejected value narrated its own refusal through `toJSON` (an illegal object described itself as a legal word), `NaN` and `Infinity` both serialised to `null`, and a circular value degraded to `a object value`. The local copy is deleted and the door imports the shared describer; codes and sentence structure are unchanged.
237
+ - Pins: `test/backlog809-shared-rejected-value-describer.test.ts` (the three value shapes through the attachments door; one description across four seats; a source census that `src/` declares exactly one describer).
238
+
239
+ ### The out-of-root read approval card names the ONE directory that would end the asking (#803; additive; @server @cli @client-core @test)
240
+ - **Before.** A read outside the session's declared directories raised a MANDATED ask — a persisted allow rule cannot clear it, so the card offered no "don't ask me again" either. Everything a person could do about it was say yes, and say yes again on the next call. The configuration that WOULD end it existed (`TaskSpec.additionalReadDirectories`, the read-only half of the `--add-dir` parity) and the option's label minter existed (`formatOutOfRootReadApprovalOption`); the ask simply never said which directory to name. Two contract sentences stated the gap as settled — `ReversibilityVerdict.mandated` ("no clearing channel in this version") and `AskRequest.ruleOffersAbsence`'s `"mandated"` arm ("must not point at a directory GRANT either").
241
+ - **After.** The ask carries `readRootCandidate: { dir, clearsThisAsk: true }` (new export `ReadRootGrantCandidate`) when the read boundary can name one directory whose addition to the session's read containment would make the same call run with no card at all. The same member rides `ReversibilityVerdict`, so a deployment's own `reversibilityProbe` can speak it; the gate validates it at the seam and a malformed value costs the OFFER only, never the ask. The two sentences above now say what is true.
242
+ - **Internal, no behaviour face.** The structural containment gate (`classifyOutOfRootReadGate`) asked the walk to mint a grant candidate and then discarded it — its answer has two members and neither is the candidate — so every out-of-root read paid for a second full walk that nothing read. It now asks for no candidate; the offer is minted by the verdict face, whose caller can put it on a card.
243
+ - **@contract `shell.read_boundary.grant_candidate_is_the_grant`.** The string a card DISPLAYS is byte for byte the string a deployment ADDS. A surface may shorten it for the eye and must send this spelling back when the person accepts. Pinned end to end: the directory read off the card, handed back verbatim as `additionalReadDirectories`, turns the same command into a zero-ask call.
244
+ - **One rule, not a table of arms.** The candidate rides the SAME arm as the read walk's containment sentence and is dropped wherever that sentence is dropped. So everything that would make `clearsThisAsk` a lie refuses by construction: a sensitive-path deny row and an unresolvable operand return before that arm; an unexpanded glob, a recursive walk and a `..`-traversing spelling are tested on it; a shape refusal (`tail -f <out-of-root>`) takes the sentence and the candidate together. A heredoc, a line break, a substitution and an argument-as-program read (`python3 -c …`) are refused whole by the grammar gate, which never reaches the walk at all.
245
+ - **Deliberately narrow.** The candidate is the deepest directory that PROPERLY contains every out-of-root path the walk resolved, folded lexically (this file is zero-I/O by contract, so an operand that IS a directory contributes its parent — `ls /etc/ssl` ⇒ `/etc`, one level wider than the reference implementation's stat-then-dirname step; the card shows what it grants, so a person grants what they see). Refused rather than widened when the result would be a filesystem root, or would contain the declared home or one of the declared roots. A backgrounded call and an ask another layer already raised get the mandate and NOT the offer — widening the roots would leave both standing.
246
+ - **The offer is withdrawn when a layer ABOVE the tighten is also asking** (adversarial round 1, measured hole). The persisted-rule and org lanes run AFTER the tighten that names the directory, and either can leave an ask standing that no grant answers: with a person's own standing `Bash(cat:*)` ask row, `cat <out-of-root>` carried the offer and the same call after declaring the directory was still asked about. The withdrawal is ONE rule at the ONE derivation that already answers "why is this an ask" — the ask-ORIGIN ladder: a disclosure speaking for the tighten alone survives only the two rungs the tighten produces (`shell_gate_tighten` / `safety_tighten`), and every rung above (a standing `ask` row, an unreadable rule store, an org verdict, a hook, the denial-limit fallback) takes it with them. The three origin-stamp sites now share one named act, `stampAskOrigin`.
247
+ - **The claim is MEASURED, not argued** (adversarial round 2, measured hole). The first form derived the offer from a conjunction of guards, and `cd /opt/data && cat id_rsa` passed every one — no glob, no unresolvable operand, no deny row, because a BARE operand outside the roots is not walked at all — while the grant brought the file into range of the deny judge and left the ask standing. The walk is pure and zero-I/O, so it now simply runs AGAIN with the proposed root in place and offers the directory only when that walk has no objection left. The guard list is gone: the replay sees exactly what the next call will see.
248
+ - **The probe seat enforces its declared shape** (adversarial round 2). `readRootCandidate.dir` is documented as an absolute, lexically folded directory; the validator checked length and control characters but not that. A deployment-supplied probe could put `../../..` on a card, which resolves to the filesystem root where the deployment applies it. Absolute / already-folded / not-a-family-root are now enforced family-aware with the same primitives the boundary compares with, and a value that fails is dropped rather than repaired.
249
+ - **The common-directory search is LINEAR** (adversarial round 3, measured hole). Climbing one directory at a time re-folded the whole remaining path on every step: one 32 KB command naming a 16 000-segment operand cost **4.9 s** of synchronous work — on the walk that runs BEFORE the gate arms its probe deadline, so the deadline could not interrupt it. The paths are already folded, so the answer is their common prefix cut at a separator; the family-aware comparator still confirms the result, and a prefix it cannot confirm contains both paths yields no candidate. Same answer, **9 ms** at that depth, which is the cost the same command had before this seat existed.
250
+ - **A Win32 ALIAS of another directory is never named** (adversarial round 4, measured hole ×2). Win32 trims a trailing dot or space from every path component, so `x. ` and `x` are one directory to the OS and two strings to a lexical comparator — a fact this repo already stated in its UNC key minter and now reads from one predicate (`hasWin32AliasedComponent`). Two ways it bit: an alias of the DRIVE ROOT reached the card (`C:\.. ` passes absolute + folded + not-a-root and resolves to `C:\` wherever the OS strip runs), and a byte cut BETWEEN two aliased spellings (`…\reports.` beside `…\reports`) landed one level above the directory they share, offering the parent's other children along with what was read. Both boundaries now withhold; POSIX is untouched (a trailing dot there is an ordinary filename character) and the UNC anchor keeps its exemption (a host's trailing dot is DNS, not the client-side strip).
251
+ - **A device namespace is never named** (adversarial round 5). The validator accepted `\\.\C:\Windows`, which the read boundary's own namespace screen refuses outright and which does not even contain itself — a deployment-supplied probe could advertise a grant the boundary can never match. It now consults that one screen; the screen's other verdict (a spelling it would rewrite) needs no test, because every such spelling is one the fold check already changed.
252
+ - **Reach: zero decision moved.** Advisory display metadata, read by nothing in the engine; the ask's prose, the rule-offer door, the shadowed-rule disclosure and the deny tier are all byte-unchanged, and the deny tier is judged by pattern rather than by root so no grant reaches it. WRITE face and the DURABLE PARK row are out of scope in this version — a parked out-of-root ask renders the confirmation alone, as before.
253
+ - Pins: `test/backlog803-read-root-candidate.test.ts` (28: the arm table in both directions, both probe seats, the closed loop for every offered command on both seats, the deny tier under the widest possible grant, the gate's carry and its refusal to merge onto someone else's ask, the validator's refuse-don't-repair posture, and an end-to-end negative control over the whole `AskRequest` member list).
254
+
255
+ ### Fix — adapter truthfulness: a stream that carried text and then ended without its finish frame is a torn stream, not a clean `stop` (#805 adversarial rounds 5–6; every run; @server @cli @test)
256
+ - **Before.** All THREE stream parsers marked only the EMPTY torn stream as an error — openai (no content, no `finish_reason`), anthropic (no content, no `message_delta.stop_reason`) and the Responses adapter (no content, no terminal `response.*` frame); a body that closed cleanly after a `No` delta with no finish frame finalised as a clean `stop` with no `partialFinalized` — indistinguishable downstream from a complete answer. The auto-mode classifier's bare-answer arm read such a cut `No` (of a `No, this must be blocked`) as ALLOW and executed the gated tool (reproduced through the real openai brain).
257
+ - **After.** All three parsers stamp `partialFinalized: true` and the CC-verbatim `Connection closed mid-response. The response above may be incomplete.` on content-without-finish — the same marker and the same words the design/124 mid-stream promotion stamps when the body reader throws (one fact, one word; the sentence is now single-sourced in `stream-shared.ts`). Consumers already in place act on it: the loop's bounded continue nudge (`partialFinalized && stopReason === "stop"`), and the classifier's tag-only rule (the cut reply is re-asked, never a bare verdict).
258
+ - **Reach (behaviour face, every run).** A gateway that ends a stream with `[DONE]`/EOF but never sends `finish_reason` (openai form), `stop_reason` (anthropic form) or a terminal `response.completed`/`.incomplete`/`.failed` frame (Responses form) now reads as incomplete on every turn — the conforming shape sends one, and this is the fail-closed direction (an incomplete turn is marked incomplete; a complete turn is untouched). The mid-stream promotion path (body throw) is unchanged: the parser's own finalize under `truncated: true` leaves the stamp to the facade, so the warning is never doubled.
259
+ - **Round 6 (an independent review of the rebased tree), two shapes on the SAME wire.** (i) Round 5 had said "both parsers" and patched two of the three: on the Responses wire a cut `No` still finalised as a clean `stop` and the gated tool RAN. (ii) The Responses wire can also declare a turn unfinished WITHOUT dropping the connection — `response.incomplete` whose `incomplete_details.reason` is absent, null, or a word this client does not map (`max_output_tokens` ⇒ `length` and `content_filter` ⇒ the refusal arm are the two it does) — and that terminal SETS the status the first arm keys on, so it read as a clean `stop` too. The provider's own verdict outranks the absence of its metadata: same `partialFinalized` marker, its own honest sentence (`The response above may be incomplete: the endpoint reported status "incomplete" with no reason this client recognises.` — nothing was dropped, so the torn-stream words would misdescribe it). The SAME terminal with nothing delivered joins the empty-torn arm instead: `stopReason: "error"` with the existing `[stream_torn]` sentence naming which shape it was (no new error code — the outcome is the same, the response was lost), which is what the two mapped reasons already do (`max_output_tokens` ⇒ `length_empty`, `content_filter` ⇒ the refusal arm).
260
+ - Pins: `test/backlog805-adapter-torn-stream.test.ts` (all three brains, torn vs whole; the four reasonless `incomplete` shapes with content and the empty one without, `length` / `content_filter` / `completed` as controls; the real openai brain and the real Responses brain through the Runner as the classifier — the cut `No` is re-asked and the block stands, the whole `No` allows).
261
+
262
+ ### The auto-mode classifier on single-model deployments — the seat learns from the reply, the reader accepts a bare answer, the card names the model (#805; ruling C-R44; @server @cli @test; deployment operators)
263
+ - **Before.** A catalog entry with no `reasoning`/`compat` declaration was read as "a model that never thinks" (`thinkingOffExpressible` answered `true` for a falsy `reasoning`), so the classifier ran with `reasoning:"off"` and a 256-token cap while the wire carried NO thinking key at all — the gateway's default governed, and three of four live gateways think by default. A provider error (`stopReason:"error"`) was read as a tagless reply. The parse-failure card never said which model, nothing said which model stood in for the missing `classifier` role, and the rung deadline was a sema-invented 15 s where CC runs 60 s.
264
+ - **After — seat.** `thinkingOffSpelled(model)` (renamed from `thinkingOffExpressible`, **BREAKING** on the export face; no sibling repo imports it) is true exactly when the wire will CARRY a disable key; a falsy `reasoning` spells nothing and reads `false`. Off spelled ⇒ `off` + 256 (unchanged); otherwise `low` + 256 + 2048 (CC 2.1.259 `MU`: spell off, or leave thinking to the provider and pay 2048). One rule, the special arm gone.
265
+ - **After — re-ask.** A first attempt whose reply is MISSING a verdict (empty at the cap, or text with no answer in it) is asked ONCE more on the THINK seat (`low` + 256 + `AUTO_MODE_CLASSIFIER_THINK_BUDGET_TOKENS` 8192, CC's stage-2 room), unless the run's signal is already aborted. A CONFLICTING reply (two `<block>` answers) is never re-asked; an empty reply with a non-cap stop stays CC's refusal arm. `parse_error` carries its form (`AutoModeParseFailure`: `missing` | `conflict`; a new closed set, internal). Each attempt's user message ends with CC 2.1.259's own suffix (`CLASSIFIER_ATTEMPT_SUFFIX.first` = the fast-mode "your ENTIRE response MUST begin with <block>", `.reask` = the stage-2 "use <thinking> first") — live 09-15 the first suffix took a thinking-off Qwen 3.8 from 70% tagged replies to 90% at 8 output tokens.
266
+ - **After — reader.** `parseAutoModeResponse` strips `<think>…</think>` beside `<thinking>…</thinking>`, and when NO `<block>` tag is present reads a reply that IS a bare answer — the whole thinking-stripped text, nothing else: `no` / `No.` / `**no**` / `block: no` / `Answer: yes` / `Verdict = NO` (a closed spelling set; a question followed by its answer, two words, a `?`, or prose with a bare `no` in it is still `parse_error`). Divergence D-805-1 (CC reads the tag only), narrowed from the ruling's "any yes/no" on live evidence: the last-line form read a prose `no` under a soft-block conclusion as ALLOW. A TAG-ONLY reply — a `length` stop, a stream cut (`partialFinalized`), a tool-calling reply (`toolUse` stop or a tool-call block; the classifier has no tools) — never reaches the bare arm: the leg judges it with `parseAutoModeResponse(text, { bareAnswer: false })` (additive optional parameter) and hands the decider an empty text unless a `<block>` tag ruled, so a cut `No` is re-asked, never allowed (the adapters now mark every such cut — see the adapter-truthfulness fix above).
267
+ - **After — attribution.** A reply with `stopReason:"error"` (other than the cap cut `length_empty`) or `"aborted"` is thrown by the leg and becomes `unavailable`/`error` (ladder cause `error`, the "temporarily unavailable" card), as the sibling side-channel legs already read it.
268
+ - **After — the card.** `classifierParseFailureDenyMessage(attribution?)` replaces the constant `CLASSIFIER_PARSE_FAILURE_DENY_MESSAGE` (**BREAKING**, export face): with a ladder wired the tail reads `— the classifier model "<modelUsed>" replied without a verdict.`; the CC stem is unchanged and stays a prefix. Divergence D-805-2.
269
+ - **After — role default.** No `classifier` role that NAMES a model (`roleSpecNamesModel`, exported from `roles.ts`: a string ref, a Model object, `{model}`, `{select}`; a preset-only `{thinking}` does not) ⇒ with tiers declared and a main model on a tier, the classifier is the nearest tier strictly BELOW the main's (over the Runner's expanded tier keys; the main's tier = the lowest its route sits on; a different route required), else the main itself; a main bound to no tier keeps the static `flash` row; no tiers keeps the `summarize → default` chain. New notice `classifier.role_defaulted` (operator, once per run, after the route pre-flight; `detail: { model, reason ∈ tier_below_main|tier_table|role_fallback|main_only|role_unresolvable, thinkingOffSpelled }`; a role naming a model the catalog cannot resolve falls back to the main model as the old leg did, now disclosed as `role_unresolvable`). Divergence D-805-4. The `roles.side_channels_deployment_only` sentence is narrowed for this one seat: the classifier default follows the run's main model.
270
+ - **After — loud.** New notice `classifier.contract_broken` (operator, once per run per ladder RUNG — keyed by position and id, since two rungs may share a wire id; `detail.rung`): the same rung answered `AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER` (2) consecutive classifications without a verdict (each already re-asked); a ruling from it resets the count, an `unavailable` round neither counts nor resets. Fires before the denial limit's third block hands the user a card. Both codes join `ENGINE_NOTICE_CODES` / `NOTICE_AUDIENCE` (**BREAKING** for a total table over the set; an unknown code must never be fatal to a consumer).
271
+ - **After — timeout (behaviour face, deployment operators).** `AUTO_MODE_DEFAULT_TIMEOUT_MS` 15_000 → 60_000 (CC 2.1.250 `oue` = 2.1.259 `j7e`, the per-attempt base). The deadline bounds ONE RUNG — both attempts, the transcript read, the credential resolution — so the worst-case walk is 60 s × 2 rungs = 120 s. An explicit `RunnerDeps.autoMode.timeoutMs` is unchanged. Arming recipes now MATERIALISE `timeoutMs` (a recording states its bound), and a recording that carries none — one written before this version — folds as the 15 s it ran under (`AUTO_MODE_RECORDED_ABSENT_TIMEOUT_MS`; contract `auto_mode.arming.absent_timeout_reads_recorded_default`): the wider default reaches NEW recordings only, never an ancestor's bound at redemption.
272
+ - **Contract sentence** `classifier.budget_is_room` (on `classifierRungSeat`): every classifier output cap is ROOM, never a bound the engine can vouch for — a gateway may exclude reasoning tokens from `max_tokens` (measured), so an over-long think is cut by the round-trip timeout (`unavailable`/`timeout`), not by the cap (`parse_error`), and the two are attested as two different facts.
273
+ - **After — the seat is the model that was RESOLVED (round 6).** Route identity (`id` + base URL) names the REASON the ladder folds to one rung; it never substitutes the `Model` object. Two catalog entries may match on it while carrying different headers, credential, api family or `compat` — and `compat.thinkingFormat` is what decides this seat's reasoning word — so the resolver returns the model the role chain resolved and only labels it `main_only`.
274
+ - **Not changed.** Verdict semantics of a tagged reply; the denial limit; the ladder's walk rule; the classifier system prompt (SHA-locked asset) — the suffixes ride the user message.
275
+ - Pins: `test/backlog805-classifier-robustness.test.ts` (seat predicate and seats through the real Runner; the re-ask's trigger, exceptions and suffixes; the reader's bare-answer grammar with the live false-allow counter-example; error-stop attribution; the tier-relative seat incl. a main bound to two tiers; both notices; the card). Re-pinned: `backlog661-classifier-cap-retry` (the re-ask lands on THINK; a missing text is re-asked, a conflict is not), `backlog618-*` (an undeclared model is on the `low` seat), `backlog742` (the seat is asked twice before the walk moves), `design503-parked-automode-arming` (materialised and legacy recordings). Design note and live tables: the design note lives in the internal design register (`core/design/805-classifier-robustness.md`; not part of this repository).
276
+
3
277
  ## 7.18.0 — 2026-09-15
4
278
 
5
279
  ### The WRITE face becomes a declarable containment seat — `writeFace: "open" | "roots"` (#748; @server @cli @test)
@@ -247,11 +521,11 @@
247
521
  ### Narrowing — an option's ATTACHED payload is a word like any other: the boundary walk judges it (#763; the residual filed at 7.17.3; @server @cli @test)
248
522
  - **Before.** The boundary walk offered an attached option payload (`-fVALUE`, `-xVALUE` in a cluster, `--flag=VALUE`) as a path candidate only when the payload was path-SHAPED, and a sensitive-path deny row names BARE filenames (`.npmrc`, `.env`, `id_rsa`) — no separator, no shape. The payload was dropped before anything judged it, so the deny row was read with no question at all, for EVERY head, listed or not: measured on the real load path (Runner prepare → NodeExecutionEnv → the gate) under the default doctrine, `grep -f.npmrc`, `grep --file=.npmrc`, `awk -f.npmrc` and `base64 -i.npmrc` were asks=0 and EXECUTED (`awk -f.npmrc` put the file's bytes into the model record, since awk quotes the source it cannot parse), while the separated (`grep -f .npmrc`) and path-shaped (`-i./.npmrc`) spellings of the IDENTICAL read were one mandated ask. Under `"classify"` the same defect showed as `grep --file=.npmrc` asks=0 and the other three raising only the doctrine's own retirable ask, with no mandate. This is the same wrong assumption #758 removed one level down ("a word that does not look like a path is not a file"), surviving at the walk; it is NOT the #758 class — the read-only allowlist has nothing to do with it.
249
523
  - **After (a rule removed, none added).** The payload arm's shape filter is gone. Every judged word — an attached payload and a positional operand alike — takes ONE lane split (`judgeOperand`): a word that can denote a location outside the working directory (path-shaped, or an absolute form) is a containment candidate; a separator-less word can only land under the working directory, so it is deny-judged and reported as a checked path but cannot demote. That is the lane the positional operands always used; the payload now shares it instead of being filtered out of the judgement entirely. The per-verb carve-outs are unchanged (grep's `-e`/`--regexp=` PATTERN payloads, cut's delimiter values), and an option payload is still NOT a traversal root.
250
- - **Same class, second spelling: the cluster is read to its END.** `attachedOptionPayloads` walked a short-option cluster's option characters as LETTERS only, so a DIGIT stopped the scan: `grep -2f.npmrc` offered `2f.npmrc` (a bare word under the working directory) and never `.npmrc`, while the installed grep opens `.npmrc` — verified against the real program, not inferred from an option table. Digits are ordinary getopt option characters (`grep -1`…`-9`, `head -1`, `tar -0`), so the repair is the CHARSET: one constant (`CLUSTER_OPTION_CHAR`) read by BOTH readers of a cluster — the payload scan and grep's value-owner scan, which must agree about where the options end, or a digit-prefixed PATTERN payload (`grep -2e/x`) would become an out-of-root path candidate and a refusal on the readonly face. No per-program option list is introduced, and the charset closes the same digit hole in the DEVICE arm, which extracts payloads through the same function: `grep -2f/dev/zero a b` was measured auto-allowed (grep consuming the endless device as its pattern file) and is now the device refusal, beside the `grep -f/dev/zero` spelling that was already caught. `grep -2f.npmrc a b` is now a deny-row mandate and `grep -2f/etc/passwd a b` an out-of-root mandate, on both seats; every everyday numeric spelling (`head -n10`, `head -c100`, `tail -n+100`, `make -j8`, `git log -n5`, `grep -A2`/`-B3`/`-C2`/`-m1`, `ls -1`, `head -5`, `cut -c1-5`, `base64 -w0`, `ssh -p2222`, `docker run -p8080:80`, `mysql -h127.0.0.1`, `chmod -R755`, `ip -4`) keeps its zero-ask verdict, measured. **The over-fire it buys, swept for and named row by row:** payload extraction is program-blind and over-approximates by design (`-if../x` offers `f../x` AND `../x`), and the charset is what stops a split from being taken INSIDE a relative path (`-fsrc/a.txt` breaks at the `/` and never invents `/a.txt`). Admitting one more option character therefore moves some split points one place right, and where the next character is a `/`, an absolute reading appears that the letters-only scan did not produce. Three rows move, each an attached value whose DIGITS are followed by a separator: `date -d1/2/2026` (offers `/2/2026`), `diff -I1/foo` (a regex value) and `sed -e'1s/a/b/'` (a line-addressed script) — the last two a mandated ask on the shell lane and a hard refusal on the `bash_readonly` face. Their non-moving neighbours are pinned beside them (`date -d '1/2/2026'`, `sed -e'1,5s/a/b/'`, `sed -e'1d'`, `diff -Ifoo`, `head -n1`), as are the letters-only analogues that already mandated before this car (`date -da/b/c`, `sed -e's/a/b/'`). The repair for the whole row-set is #764's declared non-path-slot table (`sed -e` is a script, `diff -I` a regex, `date -d` a time string), not a per-name special case here and not narrowing the charset back — that reopens the attached-file reads this closes. **Residual, disclosed and pinned:** a PUNCTUATION option character still ends the scan, so `curl -#K.npmrc` hides the `-K` payload behind curl's `#` flag (verified against the installed curl) and the deny row is read unasked under the default doctrine; enumerating punctuation IS the per-program option table this file refuses to carry, and the sound answers are #764's value-ownership table or mandating on a cluster the scan cannot fully interpret.
524
+ - **Same class, second spelling: the cluster is read to its END.** `attachedOptionPayloads` walked a short-option cluster's option characters as LETTERS only, so a DIGIT stopped the scan: `grep -2f.npmrc` offered `2f.npmrc` (a bare word under the working directory) and never `.npmrc`, while the installed grep opens `.npmrc` — verified against the real program, not inferred from an option table. Digits are ordinary getopt option characters (`grep -1`…`-9`, `head -1`, `tar -0`), so the repair is the CHARSET: one constant (`CLUSTER_OPTION_CHAR`) read by BOTH readers of a cluster — the payload scan and grep's value-owner scan, which must agree about where the options end, or a digit-prefixed PATTERN payload (`grep -2e/x`) would become an out-of-root path candidate and a refusal on the readonly face. No per-program option list is introduced, and the charset closes the same digit hole in the DEVICE arm, which extracts payloads through the same function: `grep -2f/dev/zero a b` was measured auto-allowed (grep consuming the endless device as its pattern file) and is now the device refusal, beside the `grep -f/dev/zero` spelling that was already caught. `grep -2f.npmrc a b` is now a deny-row mandate and `grep -2f/etc/passwd a b` an out-of-root mandate, on both seats; every everyday numeric spelling (`head -n10`, `head -c100`, `tail -n+100`, `make -j8`, `git log -n5`, `grep -A2`/`-B3`/`-C2`/`-m1`, `ls -1`, `head -5`, `cut -c1-5`, `base64 -w0`, `ssh -p2222`, `docker run -p8080:80`, `mysql -h127.0.0.1`, `chmod -R755`, `ip -4`) keeps its zero-ask verdict, measured. **The over-fire it buys, swept for and named row by row:** payload extraction is program-blind and over-approximates by design (`-if../x` offers `f../x` AND `../x`), and the charset is what stops a split from being taken INSIDE a relative path (`-fsrc/a.txt` breaks at the `/` and never invents `/a.txt`). Admitting one more option character therefore moves some split points one place right, and where the next character is a `/`, an absolute reading appears that the letters-only scan did not produce. Three rows move, each an attached value whose DIGITS are followed by a separator: `date -d1/2/2026` (offers `/2/2026`), `diff -I1/foo` (a regex value) and `sed -e'1s/a/b/'` (a line-addressed script) — the last two a mandated ask on the shell lane and a hard refusal on the `bash_readonly` face. Their non-moving neighbours are pinned beside them (`date -d '1/2/2026'`, `sed -e'1,5s/a/b/'`, `sed -e'1d'`, `diff -Ifoo`, `head -n1`), as are the letters-only analogues that already mandated before this car (`date -da/b/c`, `sed -e's/a/b/'`). The repair for the whole row-set is #764's declared non-path-slot table (`sed -e` is a script, `diff -I` a regex, `date -d` a time string), not a per-name special case here and not narrowing the charset back — that reopens the attached-file reads this closes. **Residual, disclosed and pinned:** a PUNCTUATION option character still ends the scan, so `curl -#K.npmrc` hides the `-K` payload behind curl's `#` flag (verified against the installed curl) and the deny row is read unasked under the default doctrine; enumerating punctuation IS the per-program option table this file refuses to carry, and the sound answers are #764's value-ownership table or mandating on a cluster the scan cannot fully interpret. **Superseded (Unreleased, #764):** the value-ownership table is in place and `curl -#K.npmrc` is a mandated ask; the residual now reads as stated there — a punctuation option character still ends the scan for a program the table has no row for.
251
525
  - **The zero-option reading narrowed to where it names a location.** The suffix scan also offered `tok.slice(1)` — the whole post-dash text as a value no option consumed — unconditionally. getopt has no such form, and on an everyday cluster it is a word no program opens (`-f` offered the phantom `f`, `-xzf` offered `xzf`; `cat -/etc/passwd` is an illegal option on the installed cat/head/grep rather than a read), which stayed invisible only while payloads were shape-filtered. Dropping it outright was measured against the pre-car tree and lost three mandates — `grep -/etc/passwd f`, `cat -//` and `chpst -// cat etc/passwd` went SILENT where the baseline mandated — because that reading is what covers an option character this scan cannot enumerate: `-//` is `-/ /` to a program whose `/` takes a value, and the chroot that follows reads from the filesystem root. So shape decides here exactly as it does in the lane split — a location-shaped whole is offered, a bare word is not. An exact-`checkedPaths` pin for `tail -f app.log` therefore reads `[<root>/app.log]` as before, and every out-of-root boundary the pre-car scan drew is still drawn.
252
526
  - **Behaviour, for consumers.** A shell command whose option payload names a deny row now asks ONCE, mandated, under both doctrines — a stored allow rule is disclosed as shadowed and does not clear it — and the ask is the same one the separated spelling already raised. The lower bound is unchanged and pinned: `grep -epattern f`, `head -n10 f`, `tar -xzf x.tgz`, `curl -Hheader`, `wc -l f`, `cut -d, -f1 f`, `sort -k1 f`, `ls -la`, `npm run build -- --silent`, `git log -n5`, `node --max-old-space-size=4096 x.mjs`, `make -j8`, `docker run -it img`, `jq -r .name pkg.json`, `sed -n '1,5p' f`, `base64 -w0 f` keep their zero-ask verdict on both seats. The widened judgement adds `checkedPaths` entries for payload words that resolve inside the roots (the extractor is getopt-blind and offers every suffix that follows at least one option character, so `curl -Hheader` now reports `header`, `eader`, … beside `curl`): those are candidates a filesystem-holding caller re-checks with symlinks resolved, and they never affect a verdict. @test: the class criterion is the SPELLING (attached short / cluster / long `=`) held against the separated and path-shaped spellings of one read, on a listed program and a synthetic unlisted name alike — a verification that enumerates tool names repeats the defect.
253
527
  - **A pre-existing hole CLOSED beside the defect (a narrowing, not an over-fire).** Folding the two arms' shape tests into one predicate also gave the POSITIONAL arm the absolute-form half the payload arm always had. Measured over a win-form corpus on all three faces, exactly one answer moves: on a WIN32-rooted boundary `classifySimpleCommandReadBoundary` returned an EMPTY verdict for `cat \\host\share\secret` and `cat C:\other\secret` — no reason, no `refusedWhole`, no out-of-root finding, i.e. a read outside every root AUTO-ALLOWED on the one face whose doctrine is hard refusal, while the compound face refused the identical command whole. Both faces now name the out-of-root read. The in-file note that called the shape unreachable is corrected: the backslash hard-reject that makes it true covers the COMPOUND faces only, and never covered the single-command face. @server @cli: on a win32 tree a `bash_readonly` command naming a UNC or drive-letter path outside the roots is refused where it previously ran.
254
- - **Stated over-fire, measured and pinned.** A payload that SPELLS a deny row gates even where the program would not open it: `grep --exclude=.npmrc`, `--include=`, `--label=` are filename patterns and labels, not files grep opens, and they now raise a mandated ask that a standing rule cannot retire (the ordinary values — `--exclude=build`, `--include=src` — keep their zero-ask verdict). Telling "a payload that names a file" from "a payload that names a pattern" needs the per-program option semantics this file refuses to carry; the file's stated error direction is that over-reading a token as a path costs a prompt while under-reading one is a boundary circumvention. The two existing carve-outs stay exactly where they are — payloads that provably are not filesystem targets AND whose over-read would be a hard REFUSAL rather than a prompt. Related, NOT fixed here: a listed verb's non-path slots have no declared table (`sed -e` is a script, `grep -e` a pattern, `head -n` a number), so `sed -e's/a/b/'` mandates on the `/a/b/` its cluster also spells — pre-existing, identical before and after this car, and filed as #764 rather than answered with a `sed` special case.
528
+ - **Stated over-fire, measured and pinned.** A payload that SPELLS a deny row gates even where the program would not open it: `grep --exclude=.npmrc`, `--include=`, `--label=` are filename patterns and labels, not files grep opens, and they now raise a mandated ask that a standing rule cannot retire (the ordinary values — `--exclude=build`, `--include=src` — keep their zero-ask verdict). Telling "a payload that names a file" from "a payload that names a pattern" needs the per-program option semantics this file refuses to carry; the file's stated error direction is that over-reading a token as a path costs a prompt while under-reading one is a boundary circumvention. The two existing carve-outs stay exactly where they are — payloads that provably are not filesystem targets AND whose over-read would be a hard REFUSAL rather than a prompt. Related, NOT fixed here: a listed verb's non-path slots have no declared table (`sed -e` is a script, `grep -e` a pattern, `head -n` a number), so `sed -e's/a/b/'` mandates on the `/a/b/` its cluster also spells — pre-existing, identical before and after this car, and filed as #764 rather than answered with a `sed` special case. **Superseded (Unreleased, #764):** those three slots are declared a glob, a glob and a label, so they raise no ask; `--exclude-from=` and `--file=` still gate, and an option no row declares still over-reads.
255
529
  - Pins: `test/backlog763-attached-payload-deny.test.ts` (the real load path, two doctrines: the four measured-silent spellings, the mandated-ask lane under a covering rule, the listed/unlisted × attached/separated/path-shaped grid with its non-deny negative twin, the everyday-payload lower bound end to end and at both seats, the digit-cluster deny/out-of-root mandates with the pattern carve-out control and the numeric lower bound, the out-of-root payload and positional deny-row regressions, the accepted over-fire, the payload-is-not-an-operand-slot cases, and the closed win-form hole on both root families; four mutation directions measured in the header).
256
530
 
257
531
  ## 7.17.3 — 2026-09-13