@sema-agent/core 7.19.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 (106) hide show
  1. package/CHANGELOG.md +183 -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 +3 -1
  6. package/dist/brain/open-responses.js +4 -2
  7. package/dist/brain/openai.js +3 -1
  8. package/dist/brain/stream-engine.d.ts +4 -0
  9. package/dist/brain/stream-engine.js +4 -0
  10. package/dist/core/auto-mode.d.ts +37 -18
  11. package/dist/core/auto-mode.js +1 -1
  12. package/dist/core/child-clamp-seats.d.ts +79 -0
  13. package/dist/core/child-clamp-seats.js +29 -0
  14. package/dist/core/engine-notice.d.ts +67 -49
  15. package/dist/core/fs-write-gate-policy.d.ts +17 -9
  16. package/dist/core/fs-write-gate-policy.js +51 -4
  17. package/dist/core/gate-bounded-await.d.ts +38 -0
  18. package/dist/core/gate-bounded-await.js +53 -0
  19. package/dist/core/gate-fold.js +16 -48
  20. package/dist/core/gate-lanes.js +11 -3
  21. package/dist/core/gate-outcome.d.ts +9 -6
  22. package/dist/core/governance-codes.d.ts +16 -1
  23. package/dist/core/governance-codes.js +12 -0
  24. package/dist/core/hooks.d.ts +8 -8
  25. package/dist/core/memory-engine/engine.d.ts +14 -9
  26. package/dist/core/memory-engine/engine.js +21 -8
  27. package/dist/core/memory-engine/layout.d.ts +31 -0
  28. package/dist/core/memory-engine/layout.js +68 -3
  29. package/dist/core/memory-engine/types.d.ts +24 -0
  30. package/dist/core/memory.d.ts +19 -0
  31. package/dist/core/memory.js +12 -0
  32. package/dist/core/remote-env.d.ts +32 -0
  33. package/dist/core/remote-env.js +4 -1
  34. package/dist/core/runner/advertised-writable-dirs.d.ts +59 -1
  35. package/dist/core/runner/advertised-writable-dirs.js +51 -3
  36. package/dist/core/runner/announce-once-ledger.d.ts +15 -0
  37. package/dist/core/runner/announce-once-ledger.js +8 -0
  38. package/dist/core/runner/classifier-ladder.d.ts +5 -0
  39. package/dist/core/runner/classifier-ladder.js +13 -2
  40. package/dist/core/runner/contracts.d.ts +44 -1
  41. package/dist/core/runner/denial-limit-arms.d.ts +14 -1
  42. package/dist/core/runner/denial-limit-arms.js +5 -4
  43. package/dist/core/runner/env-capability-census.d.ts +30 -0
  44. package/dist/core/runner/env-capability-census.js +38 -0
  45. package/dist/core/runner/forward-sink.d.ts +32 -0
  46. package/dist/core/runner/forward-sink.js +41 -0
  47. package/dist/core/runner/gate-exit.d.ts +4 -0
  48. package/dist/core/runner/gate-exit.js +4 -1
  49. package/dist/core/runner/git-status-frame.d.ts +0 -9
  50. package/dist/core/runner/listing-fold.d.ts +57 -0
  51. package/dist/core/runner/listing-fold.js +75 -0
  52. package/dist/core/runner/prepare-execution-env.d.ts +1 -1
  53. package/dist/core/runner/prepare-execution-env.js +2 -0
  54. package/dist/core/runner/prepare-gate-stations.d.ts +8 -0
  55. package/dist/core/runner/prepare-gate-stations.js +6 -2
  56. package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
  57. package/dist/core/runner/prepare-hands-readface.js +3 -5
  58. package/dist/core/runner/prepare-listings.d.ts +3 -0
  59. package/dist/core/runner/prepare-listings.js +4 -2
  60. package/dist/core/runner/prepare-memory.js +7 -1
  61. package/dist/core/runner/prepare-policy-chain.js +5 -5
  62. package/dist/core/runner/prepare-prompt-inputs.d.ts +2 -2
  63. package/dist/core/runner/prepare-run-refs.d.ts +4 -2
  64. package/dist/core/runner/prepare-run-refs.js +5 -4
  65. package/dist/core/runner/prepare-task.js +7 -5
  66. package/dist/core/runner/run-clock-and-content.js +3 -7
  67. package/dist/core/runner/run-harness-handlers.js +10 -10
  68. package/dist/core/runner/run-leg.js +28 -19
  69. package/dist/core/runner/run-settle-and-teardown.js +2 -5
  70. package/dist/core/runner/run-turn-boundary.js +44 -33
  71. package/dist/core/runner/runtask.js +2 -1
  72. package/dist/core/runner/synthetic-tools.d.ts +13 -39
  73. package/dist/core/runner/synthetic-tools.js +15 -59
  74. package/dist/core/runner/task-root-canonical.d.ts +45 -0
  75. package/dist/core/runner/task-root-canonical.js +18 -0
  76. package/dist/core/runner/turn-attachments.d.ts +125 -49
  77. package/dist/core/runner/turn-attachments.js +124 -66
  78. package/dist/core/safe-notify.d.ts +5 -0
  79. package/dist/core/safe-notify.js +24 -2
  80. package/dist/core/sensitive-path-policy.d.ts +53 -27
  81. package/dist/core/sensitive-path-policy.js +16 -26
  82. package/dist/core/skill-listing-budget.d.ts +34 -40
  83. package/dist/core/skill-listing-budget.js +25 -26
  84. package/dist/core/skills-directory.js +17 -10
  85. package/dist/core/task-registry.js +2 -1
  86. package/dist/core/task-spec.d.ts +8 -7
  87. package/dist/core/tool-policy.d.ts +12 -1
  88. package/dist/core/tool-policy.js +39 -25
  89. package/dist/core/write-protect.d.ts +98 -18
  90. package/dist/core/write-protect.js +88 -34
  91. package/dist/index.d.ts +6 -5
  92. package/dist/index.js +5 -4
  93. package/dist/orchestration/workflow-primitives.js +10 -20
  94. package/dist/stores/file/memory-store.d.ts +5 -2
  95. package/dist/stores/file/memory-store.js +8 -4
  96. package/dist/tools/fs/bash-readonly-classifier.d.ts +13 -2
  97. package/dist/tools/fs/bash-readonly-classifier.js +317 -35
  98. package/dist/tools/fs/fs-bash.d.ts +1 -1
  99. package/dist/tools/fs/fs-bash.js +12 -25
  100. package/dist/tools/fs/fs-shared.d.ts +22 -0
  101. package/dist/tools/fs/fs-shared.js +30 -0
  102. package/dist/tools/fs/index.d.ts +1 -1
  103. package/dist/tools/fs/index.js +1 -1
  104. package/package.json +2 -1
  105. package/test/export-surface.snapshot.json +19 -3
  106. package/wire-changes/core@7.20.0.json +568 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,186 @@
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
+
3
184
  ## 7.19.0 — 2026-09-16
4
185
 
5
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)
@@ -340,11 +521,11 @@
340
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)
341
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.
342
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.
343
- - **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.
344
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.
345
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.
346
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.
347
- - **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.
348
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).
349
530
 
350
531
  ## 7.17.3 — 2026-09-13
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { applyParentSpecSeat, parentSpecSeatOfCtx } from "../core/parent-spec-seat.js";
4
+ import { childClampSeats } from "../core/child-clamp-seats.js";
4
5
  import { stripSpawnTurnQuestionFace } from "../core/ask-question.js";
5
6
  import { withDelegationProvenance } from "../core/tool-policy.js";
6
7
  import { errorResult } from "../core/tools.js";
@@ -20,6 +21,7 @@ import { SUBAGENT_PROMPT } from "../prompts/default.js";
20
21
  import { hasSessionFork } from "../core/session.js";
21
22
  import { isDurablePause, pauseFamily } from "./suspend-guard.js";
22
23
  import { terminalProjection } from "../core/runner/terminal-projection.js";
24
+ import { forwardSafely } from "../core/runner/forward-sink.js";
23
25
  import { delimitUntrusted, inlineUntrusted } from "../core/untrusted-text.js";
24
26
  import { createSafeNotifier } from "../core/safe-notify.js";
25
27
  import { boundedRedactedSummary } from "../core/untrusted-egress.js";
@@ -556,13 +558,13 @@ export function createSubagentResume(deps) {
556
558
  sessionId: entry.childSessionId,
557
559
  requireExistingSession: true,
558
560
  ...(deps.currentOnQuestion !== undefined ? { onQuestion: deps.currentOnQuestion } : {}),
559
- ...(entry.specSnapshot.handsReadOnly === true || deps.currentClamps?.handsReadOnly === true ? { handsReadOnly: true } : {}),
560
- ...(entry.specSnapshot.readFace === "roots" || deps.currentClamps?.readFaceRoots === true ? { readFace: "roots" } : {}),
561
- ...(entry.specSnapshot.writeFace === "roots" || deps.currentClamps?.writeFaceRoots === true ? { writeFace: "roots" } : {}),
562
- ...(entry.specSnapshot.readDenyPatterns !== undefined || deps.currentClamps?.readDenyPatterns !== undefined
563
- ? { readDenyPatterns: [...(entry.specSnapshot.readDenyPatterns ?? []), ...(deps.currentClamps?.readDenyPatterns ?? [])] }
564
- : {}),
565
- ...(entry.specSnapshot.interactiveTools === false || deps.currentClamps?.interactiveTools === false ? { interactiveTools: false } : {}),
561
+ ...childClampSeats({
562
+ handsReadOnly: entry.specSnapshot.handsReadOnly === true || deps.currentClamps?.handsReadOnly === true,
563
+ interactiveTools: entry.specSnapshot.interactiveTools === false || deps.currentClamps?.interactiveTools === false ? false : undefined,
564
+ readFace: entry.specSnapshot.readFace === "roots" || deps.currentClamps?.readFaceRoots === true ? "roots" : entry.specSnapshot.readFace,
565
+ writeFace: entry.specSnapshot.writeFace === "roots" || deps.currentClamps?.writeFaceRoots === true ? "roots" : entry.specSnapshot.writeFace,
566
+ readDenyPatterns: [...(entry.specSnapshot.readDenyPatterns ?? []), ...(deps.currentClamps?.readDenyPatterns ?? [])],
567
+ }),
566
568
  ...(entry.specSnapshot.oneShot === true || deps.currentClamps?.oneShot === true ? { oneShot: true } : {}),
567
569
  ...(entry.specSnapshot.memoryPersistenceCapable === false || deps.currentClamps?.memoryPersistenceCapable === false
568
570
  ? { memoryPersistenceCapable: false }
@@ -643,11 +645,7 @@ export function createSubagentResume(deps) {
643
645
  ? {
644
646
  onForwardEvent: (e) => {
645
647
  resumeStepRecorder.record(e);
646
- try {
647
- entry.internalsSnapshot.onForwardEvent?.(deps.taskId !== undefined ? { ...e, bgAgentId: deps.taskId } : e);
648
- }
649
- catch {
650
- }
648
+ forwardSafely(entry.internalsSnapshot, deps.taskId !== undefined ? { ...e, bgAgentId: deps.taskId } : e);
651
649
  reviveActivityBeat(e);
652
650
  if (reviveEmit !== undefined && e.type === "task_progress") {
653
651
  reviveEmit({
@@ -1904,9 +1902,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1904
1902
  ...(ctx.alwaysLoadTools !== undefined ? { alwaysLoadTools: [...ctx.alwaysLoadTools] } : {}),
1905
1903
  ...(ctx.restoreGatedTools !== undefined ? { restoreGatedTools: ctx.restoreGatedTools === true ? true : [...ctx.restoreGatedTools] } : {}),
1906
1904
  ...(ctx.promptProfile !== undefined ? { promptProfile: ctx.promptProfile } : {}),
1907
- ...(ctx.readFace === "roots" ? { readFace: "roots" } : {}),
1908
- ...(ctx.writeFace === "roots" ? { writeFace: "roots" } : {}),
1909
- ...(ctx.readDenyPatterns !== undefined ? { readDenyPatterns: [...ctx.readDenyPatterns] } : {}),
1905
+ ...childClampSeats({ handsReadOnly: ctx.handsReadOnly, interactiveTools: ctx.interactiveTools, readFace: ctx.readFace, writeFace: ctx.writeFace, readDenyPatterns: ctx.readDenyPatterns }),
1910
1906
  enableBlockedReport: false,
1911
1907
  limits: {
1912
1908
  maxTurns: typeof observerDef.maxTurns === "number" && Number.isFinite(observerDef.maxTurns) && observerDef.maxTurns > 0 ? observerDef.maxTurns : 8,
@@ -2160,11 +2156,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2160
2156
  ...(def?.memoryPersistenceCapable !== undefined ? { memoryPersistenceCapable: def.memoryPersistenceCapable } : {}),
2161
2157
  ...(ctx.checkpointStoreForChildren !== undefined ? { checkpointStore: ctx.checkpointStoreForChildren } : {}),
2162
2158
  ...(childOnAsk !== undefined ? { onAsk: childOnAsk } : {}),
2163
- ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
2164
- ...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
2165
- ...(ctx.readFace === "roots" ? { readFace: "roots" } : {}),
2166
- ...(ctx.writeFace === "roots" ? { writeFace: "roots" } : {}),
2167
- ...(ctx.readDenyPatterns !== undefined ? { readDenyPatterns: [...ctx.readDenyPatterns] } : {}),
2159
+ ...childClampSeats({ handsReadOnly: ctx.handsReadOnly, interactiveTools: ctx.interactiveTools, readFace: ctx.readFace, writeFace: ctx.writeFace, readDenyPatterns: ctx.readDenyPatterns }),
2168
2160
  ...(ctx.excludeTools !== undefined ? { excludeTools: [...ctx.excludeTools] } : {}),
2169
2161
  ...(ctx.deferTools !== undefined ? { deferTools: [...ctx.deferTools] } : {}),
2170
2162
  ...(ctx.alwaysLoadTools !== undefined ? { alwaysLoadTools: [...ctx.alwaysLoadTools] } : {}),
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { MAX_STRATEGY_INJECTION_TOTAL_BYTES, MAX_STRATEGY_PROBLEM_BYTES, MAX_STRATEGY_TEXT_BYTES } from "../core/strategy-store.js";
3
3
  import { createSafeNotifier, observeThenableRejection } from "../core/safe-notify.js";
4
+ import { childClampSeats } from "../core/child-clamp-seats.js";
4
5
  import { mapNestedSuspend, isDurablePause } from "./suspend-guard.js";
5
6
  import { isDefineToolProduct, rebindDefineToolCtx, stampDefineToolBrand } from "../core/tools.js";
6
7
  import { delimitUntrusted, inlineUntrusted } from "../core/untrusted-text.js";
@@ -255,11 +256,7 @@ async function runTeacherCore(runner, studentSpec, teacher) {
255
256
  ...(studentSpec.principal !== undefined ? { principal: studentSpec.principal } : {}),
256
257
  ...(studentSpec.clientContext !== undefined ? { clientContext: studentSpec.clientContext } : {}),
257
258
  ...(studentSpec.promptProfile !== undefined ? { promptProfile: studentSpec.promptProfile } : {}),
258
- ...(studentSpec.handsReadOnly === true ? { handsReadOnly: true } : {}),
259
- ...(studentSpec.readFace === "roots" ? { readFace: "roots" } : {}),
260
- ...(studentSpec.writeFace === "roots" ? { writeFace: "roots" } : {}),
261
- ...(studentSpec.readDenyPatterns !== undefined ? { readDenyPatterns: [...studentSpec.readDenyPatterns] } : {}),
262
- ...(studentSpec.interactiveTools === false ? { interactiveTools: false } : {}),
259
+ ...childClampSeats({ handsReadOnly: studentSpec.handsReadOnly, interactiveTools: studentSpec.interactiveTools, readFace: studentSpec.readFace, writeFace: studentSpec.writeFace, readDenyPatterns: studentSpec.readDenyPatterns }),
263
260
  ...(studentSpec.excludeTools !== undefined
264
261
  ? { excludeTools: Array.isArray(studentSpec.excludeTools) ? [...studentSpec.excludeTools] : studentSpec.excludeTools }
265
262
  : {}),
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { CODE_AGENT_PROMPT } from "../prompts/default.js";
3
3
  import { releaseSession } from "./session-util.js";
4
+ import { childClampSeats } from "../core/child-clamp-seats.js";
4
5
  import { mapNestedSuspend, isDurablePause, pauseFamily } from "./suspend-guard.js";
5
6
  import { delimitUntrusted, sanitizeUntrustedText } from "../core/untrusted-text.js";
6
7
  import { createSafeNotifier } from "../core/safe-notify.js";
@@ -136,17 +137,21 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
136
137
  const runVerifier = async (impl, round) => {
137
138
  refuseUnhonorableInternals(internals, "before_leg");
138
139
  const roundEvidence = round === 1 ? config.evidence : undefined;
139
- const v = await runner.runTask({
140
+ const verifierSpec = {
140
141
  objective: verifierObjective(objective, impl, roundEvidence),
141
142
  systemPrompt: verifierPrompt,
142
143
  ...(config.verifierModel ? { model: config.verifierModel } : { modelRole: "verifier" }),
143
144
  roles: specBase.roles,
144
145
  tools: verifierTools,
145
146
  handsReadOnly: config.verifierHandsReadOnly ?? true,
146
- ...(foldedRootsFace ? { readFace: "roots" } : {}),
147
- ...(foldedRootsWriteFace ? { writeFace: "roots" } : {}),
148
- ...(foldedReadDeny.length > 0 ? { readDenyPatterns: [...foldedReadDeny] } : {}),
149
147
  interactiveTools: false,
148
+ ...childClampSeats({
149
+ handsReadOnly: specBase.handsReadOnly,
150
+ interactiveTools: specBase.interactiveTools,
151
+ readFace: foldedRootsFace ? "roots" : undefined,
152
+ writeFace: foldedRootsWriteFace ? "roots" : undefined,
153
+ readDenyPatterns: foldedReadDeny,
154
+ }),
150
155
  outputSchema: VerdictSchema,
151
156
  enableBlockedReport: false,
152
157
  limits: { ...(specBase.limits?.maxWalltimeMs !== undefined ? { maxWalltimeMs: specBase.limits.maxWalltimeMs } : {}) },
@@ -167,7 +172,8 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
167
172
  ...(specBase.clientContext !== undefined ? { clientContext: { ...specBase.clientContext } } : {}),
168
173
  ...(specBase.promptProfile !== undefined ? { promptProfile: specBase.promptProfile } : {}),
169
174
  signal: specBase.signal,
170
- }, legInternals());
175
+ };
176
+ const v = await runner.runTask(verifierSpec, legInternals());
171
177
  try {
172
178
  const cost = (v.stats.costMicroUsd ?? 0) + (v.stats.nested?.costMicroUsd ?? 0);
173
179
  return { verdict: v.structuredOutput, cost, ...(isDurablePause(v.terminal) ? { paused: { ...v, terminal: v.terminal } } : {}) };
@@ -241,14 +247,19 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
241
247
  if (config.costCeilingMicroUsd != null && spend >= config.costCeilingMicroUsd)
242
248
  break;
243
249
  refuseUnhonorableInternals(internals, "before_leg");
244
- current = await runner.runTask({
250
+ const fixLegSpec = {
245
251
  ...specBase,
246
252
  sessionId: current.sessionId,
247
253
  objective: fixObjective(outcome.findings),
248
- ...(foldedRootsFace ? { readFace: "roots" } : {}),
249
- ...(foldedRootsWriteFace ? { writeFace: "roots" } : {}),
250
- ...(foldedReadDeny.length > 0 ? { readDenyPatterns: [...foldedReadDeny] } : {}),
251
- }, legInternals());
254
+ ...childClampSeats({
255
+ handsReadOnly: specBase.handsReadOnly,
256
+ interactiveTools: specBase.interactiveTools,
257
+ readFace: foldedRootsFace ? "roots" : specBase.readFace,
258
+ writeFace: foldedRootsWriteFace ? "roots" : specBase.writeFace,
259
+ readDenyPatterns: foldedReadDeny,
260
+ }),
261
+ };
262
+ current = await runner.runTask(fixLegSpec, legInternals());
252
263
  spend += (current.stats.costMicroUsd ?? 0) + (current.stats.nested?.costMicroUsd ?? 0);
253
264
  implAccount.add(current.stats);
254
265
  if (isDurablePause(current.terminal)) {