@sema-agent/core 7.20.0 → 7.21.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 (58) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/dist/core/ask-origin.d.ts +95 -8
  3. package/dist/core/ask-origin.js +37 -5
  4. package/dist/core/engine-notice.d.ts +9 -0
  5. package/dist/core/gate-bounded-await.d.ts +6 -1
  6. package/dist/core/gate-bounded-await.js +13 -3
  7. package/dist/core/gate-fold.js +18 -5
  8. package/dist/core/gate-lanes.js +6 -12
  9. package/dist/core/governance-codes.d.ts +1 -1
  10. package/dist/core/governance-codes.js +2 -0
  11. package/dist/core/memory-engine/consolidation-driver.d.ts +3 -0
  12. package/dist/core/memory-engine/consolidation-driver.js +20 -1
  13. package/dist/core/memory-engine/consolidation.d.ts +7 -0
  14. package/dist/core/memory-engine/consolidation.js +4 -1
  15. package/dist/core/memory-engine/distiller.d.ts +26 -1
  16. package/dist/core/memory-engine/distiller.js +9 -2
  17. package/dist/core/memory-engine/tools.js +6 -0
  18. package/dist/core/path-identity.d.ts +13 -0
  19. package/dist/core/path-identity.js +14 -0
  20. package/dist/core/runner/env-capability-census.d.ts +3 -1
  21. package/dist/core/runner/env-capability-census.js +24 -7
  22. package/dist/core/runner/gate-exit.d.ts +7 -0
  23. package/dist/core/runner/inherited-ask-grants.d.ts +1 -0
  24. package/dist/core/runner/inherited-ask-grants.js +5 -2
  25. package/dist/core/runner/memory-consolidation-driver.d.ts +72 -2
  26. package/dist/core/runner/memory-consolidation-driver.js +57 -0
  27. package/dist/core/runner/prepare-config-doors.js +2 -0
  28. package/dist/core/runner/prepare-gate-stations.d.ts +6 -8
  29. package/dist/core/runner/prepare-gate-stations.js +1 -7
  30. package/dist/core/runner/prepare-policy-chain.d.ts +1 -0
  31. package/dist/core/runner/prepare-policy-chain.js +52 -20
  32. package/dist/core/runner/prepare-task.js +2 -2
  33. package/dist/core/runner/prepare-wiring-manifest.d.ts +8 -1
  34. package/dist/core/runner/prepare-wiring-manifest.js +7 -2
  35. package/dist/core/runner/run-settle-and-teardown.js +5 -0
  36. package/dist/core/runner/write-protection-station.d.ts +59 -0
  37. package/dist/core/runner/write-protection-station.js +32 -0
  38. package/dist/core/runner-deps.d.ts +21 -0
  39. package/dist/core/sensitive-path-policy.js +13 -0
  40. package/dist/core/wiring-manifest.d.ts +47 -0
  41. package/dist/core/wiring-manifest.js +2 -0
  42. package/dist/core/write-protect.d.ts +41 -3
  43. package/dist/core/write-protect.js +40 -14
  44. package/dist/engine/execution-env/node-execution-env.d.ts +10 -0
  45. package/dist/engine/execution-env/node-execution-env.js +29 -1
  46. package/dist/engine/harness/types.d.ts +10 -0
  47. package/dist/index.d.ts +2 -2
  48. package/dist/index.js +1 -1
  49. package/dist/internal/harness-types.d.ts +1 -1
  50. package/dist/tools/fs/bash-readonly-classifier.js +36 -7
  51. package/dist/tools/fs/read-deny.d.ts +4 -2
  52. package/dist/tools/fs/read-deny.js +5 -0
  53. package/dist/tools/fs/safety.d.ts +12 -0
  54. package/dist/tools/fs/safety.js +43 -38
  55. package/package.json +2 -1
  56. package/test/export-surface.snapshot.json +9 -1
  57. package/wire-changes/core@7.20.1.json +183 -0
  58. package/wire-changes/core@7.21.0.json +452 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,89 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.21.0 — 2026-09-17
4
+
5
+ ### Widening (ask → allow), one spelling — the shell read boundary's word scan now resolves a home-prefixed operand (#856; completes #854 for `~/x`; deployments declaring `readFace: "open"` widen, `"roots"` widens only where the home lies inside the roots; @server @cli re-pin)
6
+ - **Before.** The word scan that judges a command the grammar refused WHOLE (`stdinFileJudgedInside`) listed the tilde in its unreadable-word class, so `cmd < ~/notes.txt` / `cmd ~/notes.txt` were refused before the read face was consulted — while the resolver every arm hands its words to already expands exactly that prefix against the boundary's declared home (and refuses `~user`). One character, two readings: the 7.20.1 widening (#854) held for `/home/u/notes.txt` and not for the spelling a person types most.
7
+ - **After.** The tilde leaves the class; the resolver is the only judge of it. `~/x` now behaves exactly like its absolute spelling on both faces: under `"open"` a resolved, non-deny-listed home-prefixed literal is judged inside (`reversible: true`, no mandate); under `"roots"` it is compared against the roots as before. On the roots face every tilde spelling that RESOLVES INSIDE the roots is now judged inside instead of refused unread — a home that lies inside the roots, a quoted tilde (literal `./~/x`), a tilde that is not a prefix (`notes.txt~`), and a home prefix that climbs back in (`~/../../<root>/x`) — and every one that resolves outside stays mandated. `~user/x`, and `~/x` on a boundary that declared no home, stay unresolvable ⇒ still a read ⇒ still mandated (fail-closed, unchanged). The deny half judges through the expansion on both faces (`cmd < ~/.ssh/id_rsa` still mandates). An attached option payload's tilde was and is literal (`--file=~/x` opens `./~/x`; bash expands a tilde only at the start of a word), so that spelling is byte-identical. The one spelling bash DOES expand inside a word — the value of a `NAME=value` word — is judged by #860 below.
8
+ - **Reach.** Pinned as G8 of the #854 file, with the tilde put back as the mutation (red). @cli: a pin that expects a card for `sometool < ~/x` under bypass is now the `/x` spelling's twin — expect no card unless the path is deny-listed. Headless lanes: one fewer refusal under `"open"`, none under `"roots"` unless the home is inside the roots.
9
+
10
+ ### Narrowing (allow → ask), one word shape — a `NAME=value` word's value is a path candidate on the shell read boundary (#860; both faces, both the boundary walk and the refused-whole word scan; @cli @server re-pin, headless lanes: one more refusal)
11
+ - **Before.** Every face judged a `NAME=value` word as the single filename it spells, which a separator inside the word turns into a relative path under the working directory: `if=/etc/passwd` ⇒ `./if=/etc/passwd`, inside. Measured on the roots face: `dd if=/etc/passwd` read outside the roots at zero asks under the default doctrine, `FOO=/etc/passwd <unlisted program>` likewise, and — once the tilde left the word scan's unreadable class (#856) — `<unlisted program> a=~/secret` too, because bash applies its assignment-word expansion to such a word even as an argument (a tilde at the start of the value and after each `:` expands), so the program receives `a=/home/u/secret`.
12
+ - **After.** ONE arm, read by both boundary faces and the word scan: a `NAME=value` word offers its value as a candidate beside itself (judged where the value points, relative or absolute). The leading environment words bash strips before choosing the program are judged the same way on an unlisted program, through a launcher too (`FOO=/etc/passwd <unlisted>`, `FOO=/etc/passwd env <unlisted>` gate). Two spellings the folded text cannot settle are reported UNRESOLVED and ask on BOTH faces, never guessed: a value carrying a tilde (bash expands it only where it is unquoted, which the folded word no longer says — `dd if=~/x` and `dd if='~/x'` get one verdict) and the append form (`NAME+=value`, which names what `NAME` already held). The program-position reader is deliberately NOT widened to the append form: on folded text it cannot see the quoting that makes `'A+=x'` a command name, and reading that as an assignment would skip the real program — pinned as a negative control; the value arm reads `+=` on its own, where a wider reading can only over-ask. An OPTION word is not an assignment word — `--file=~/x` keeps its literal reading (#856). Under `"open"` a plain value satisfies containment and only the deny half speaks (`dd if=/home/u/.ssh/id_rsa` mandates on both faces).
13
+ - **Reach.** Roots face, unlisted programs: `NAME=<outside path>` anywhere in the segment goes from zero asks to a mandated ask (headless: refused) — `make PREFIX=/usr/local`, `docker run -e HOME=/root …` now ask on that face. Both faces: `NAME=<value with a tilde>` and `NAME+=…` on an unlisted program ask (before: the tilde spelling was refused by the word scan and vouched by the boundary walk — two readings; the append spelling was read as a path-shaped program name). Stated over-fire, pinned: a LISTED reader handed `NAME=<outside path>` as a positional operand (the filename it will open is the whole word, inside) now asks too — a rare spelling, and the fail-closed direction. Path-less and in-roots values (`NODE_ENV=production`, `TZ=Asia/Shanghai`, `a=/work/in.txt`, `git -c core.editor=vim`) stay at zero asks on both faces. Pinned in `test/backlog860-assignment-word-payloads.test.ts` (G1–G8), each arm with its own mutation.
14
+
15
+ ### Narrowing (a stored yes no longer answers it) — a write onto the write-protection table is not a persisted allow rule's to retire (#850; the classifier and a person still clear it; @server @cli re-pin)
16
+ - **Before.** The fourth post-fold tighten demoted a write whose target lands on the always-on table (the agent's own configuration directories, the shell rc files, the deployment's own rows) to `ask`, and that ask carried no mandate word at all. The persisted-rule lane's allow arm therefore cleared it like any classifier hesitation: one stored "allow this from now on" covering the path retired every later write onto the protected table, silently. The judge also ran ONLY over a surviving `allow`, so a deployment whose write face asks first (`defaultWrite: "ask"`, any policy that asks) never reached it at all — the standing rule cleared that question with the table never consulted.
17
+ - **After.** The demotion is a member of the mandate family with a lane table of its own: the rule lane may not clear it (a matching rule is DISCLOSED as shadowed — the existing `persistedRuleShadowed` member plus a sentence naming the table), while an armed auto-mode classifier and a person still may, and the durable park stays budgetable (this is not an irreversibility mandate — `safetyAxisOf` is unchanged). The judge now also runs over an ask another layer already raised and merges the mandate ALONE: that ask's action, message, provenance and every disclosure are byte-identical to before, which is pinned. Its OTHER non-clear verdict — a target the judge could not resolve, which a judge that times out or throws also produces — carries a second word (`write_protection_unresolved`): the action stays the owning arm's (a surviving allow is still denied; an ask another layer raised is untouched), and the fact that the table's answer for this call is UNKNOWN keeps the classifier out. The sandbox-admission leg still admits it — containment holds the effect whether or not the target is on the table — which is why the family's lane table now has a column per lane rather than one shared answer. A judge that times out or throws also reaches the deployment's error lane (`onError(phase:"hook")`), naming the judge, the tool and the call: the ask it leaves is one fewer lane can answer, so the failure behind it is not silent. Upstream parity: the write-protection judge runs before the general allow rules and the ask it mints is marked classifier-approvable.
18
+ - **Reach.** A deployment whose store answers `allow` for a write tool (core's own rule DSL mints no such rule — `parseRuleText` refuses an allow on a path tool; a host store that imported foreign settings can hold one) sees those writes ask again: once per write, clearable by an approver or an armed classifier. Headless lanes with no approver and no classifier: a write that a stored rule used to wave through is now refused — the same refusal the table already produced for every deployment without such a rule. The mandate family is now a registered closed set (`PersistedRuleMandate`) with two total tables, so a word added later cannot join the permissive side by omission. A deployment whose write-protection judge is SLOW (a hung filesystem, a remote environment that stops answering) now keeps its writes behind a person or an approver seat on the ask-first path as well, where before a stored rule cleared them; a write inside a declared sandbox is unaffected (the admission leg still admits it, and #715's positive control pins that). Pinned in `test/backlog850-write-protection-rule-immune.test.ts` (G1–G9), each arm with its own mutation; `#804` ④ and `#817` ③ re-pinned to the new reading.
19
+ ### Narrowing (allow → ask / allow → deny), one alias shape — the write-protection judge and the sensitive-path deny set judge every hop of a target's LEAF symlink chain, through a new optional environment seat (#851; every deployment whose environment declares the seat — the local `NodeExecutionEnv` does; @server five adapters; @cli @client-core one new ask sentence; deployment operators)
20
+ - **Before.** Both judges saw two views of a covered write: the spelling and the resolved real target. A symlink whose spelling is innocent and whose real target is innocent, but whose chain PASSES THROUGH a protected name — `plainname -> ~/.ssh/newfile` where `~/.ssh` is itself a symlink to an unguarded directory — was cleared by both (measured on 7.20.0 by the black-box acceptance: zero asks, `disposition.kind: "allowed"`). The reference CLI (2.1.259) judges every variant a path has, and its variant set includes each target the leaf's links write, so it asks there.
21
+ - **After — one seat, one absence law, two readers.** `FileSystem.canonicalChain?(path)` (optional, on the execution-environment seam beside `readLink?`) answers `{ hops }`: the target each link on the LEAF chain writes, in order, a relative one anchored unfolded at its own link's directory (`..` is the filesystem's to resolve on the next lookup — a lexical fold would read `bridge/../next` as `next` and miss every link behind `bridge`; the reference CLI folds and misses, so this is a registered divergence in the safe direction, pending ruling); the addressed spelling and the canonical terminal are not repeated, and a symlink that is a DIRECTORY COMPONENT is not expanded (the reference CLI's own variant set; contract sentence `fs.canonical_chain.leaf_hops_only`). `NodeExecutionEnv` implements it (`readlink` per hop, 64-hop bound, a cycle ends it, a `\\`/`//` hop on a win32 host is kept and ends the walk unprobed). ONE reader (`symlinkChainHops`, internal) hands the hops to both judges, and it carries the only new rule: an environment that omits the seat, or answers `ok: false`, is read as "no hop" — the two judges then answer exactly as before this change, never with a refusal (the terminal's own canonicalization already refuses what it cannot resolve). Both judges consult the chain LAST, and only for a terminal that names no row and is not inside the data root: an exemption is by IDENTITY (the file the tool will write) and ends the judgment, a hit by identity keeps its 7.20.0 word (`via: "target"`, the "real target" sentence), and a hop names a row only where the terminal hides one. The write-protection judge judges each hop as a SPELLING (the data-root passthrough and the three readings apply) and reports `via: "chain"`; the deny set judges each hop as a written spelling (both readings, no passthrough — that exemption compares canonical keys).
22
+ - **Reach.** A covered write whose chain passes through a table row now asks where it executed: `tool "Write" writes to a write-protected path — its symlink chain passes through table entry ".ssh" — explicit approval required` (the spelling and target sentences are byte-identical). The same chain through a deny-set row is now refused: `write to "<path>" is blocked: its symlink chain passes through the guarded sensitive path ".ssh"`. **Headless lane (no approver): the aliased write is refused where it executed; a deployment whose environment declares no `canonicalChain` is byte-identical.** The direct spelling `~/.ssh/x` under a symlinked `~/.ssh` asked before and asks now (the spelling view); a write through a DIRECTORY alias (`dir-alias -> ~/.ssh`, `dir-alias/x`) is cleared by both judges before and after — the reference CLI clears it too (its component walk adds no variant), and it is pinned as current reach, not as a promise.
23
+ - **Bounded, zero new timers.** The chain call runs inside the judge's promise, which both gate call sites already await on the fold's one deadline seat, so a chain that never answers is the judge's own `unresolvable` refusal.
24
+ - **Cost.** Per covered write whose spelling AND terminal name no row: +1 `canonicalChain` call per judge (Node: one `readlink` per hop plus the final non-link answer; a remote adapter: one round trip); a spelling hit, a terminal hit and an exempt terminal cost no chain call. The existing per-judgment counts (2 env calls for an existing file, 4 for a new one) are unchanged.
25
+ - **Loud absence of the wrong shape.** The env-admission census now also reads the seam's OPTIONAL METHOD seats (`writeFileExclusive`, `writeFileGuarded`, `setFileMode`, `readLink`, `canonicalChain`): one PRESENT as a non-function is announced once per run as `config.execution_env_capability_invalid` (`detail: { key, valueType }`, the existing shape; message says "not a function") — a class fix, not one key. Absent stays silent; the readers stay strict (a non-function reads as absent).
26
+ - **Seats (additive).** `SymlinkChain` (exported type); `FileSystem.canonicalChain?`; `WriteProtectionVerdict.via` gains `"chain"` (a union member, not a wire key); `ReadDenyMatcher.matchTarget(…, { hops? })` gains a pure seat the structured read faces do NOT wire yet (the wiring point is `resolveKey`; a follow-up ticket carries the per-read cost reading). **@server:** each adapter decides whether to implement `canonicalChain` (one `readlink` loop round trip on the shell lanes; a protocol op on the device lane); an adapter that stays silent keeps today's reach on its lane.
27
+ - Pins: `test/backlog851-symlink-chain-hops.test.ts` (①–⑫, each with its mutation); `test/backlog817-*` ①–⑦ unchanged (the chain call is not among the counted primitives, and the passthrough pins hold with the chain enabled).
28
+ ### Additive — memory-distillation GA prerequisite chain: the GA door, the contract-hash ratchet, the pd-2 root-elimination arm, and `RunnerDeps.autoRunOnRecommendation` (#761; design/383 slice 4 + #464 + design/376 ④; default OFF = byte-unchanged; @server @cli; deployment operators)
29
+ - **The GA door.** `gate:memory-ga` (`scripts/gate-memory-distillation-ga.mjs`, in `gate:all`'s static tier) reads `docs/memory-distillation-ga.json` and enforces design/383 §1.8: distillation may be DECLARED GA only with the pd-2 A/B readings archived, its three-state verdict RULED (a draft hold certifies nothing), the shipping-gate trio archived, all under the contract version the tree ships. The manifest ships `status: "pre-ga"` — green, and LOUD: the gate prints the NOT-GA line every run and both product docs (`docs/memory-distillation-can-say.md`, `docs/memory-distillation-matrix.md`) must open by saying so. Flip the status to `ga` with a conjunct missing and the gate is red naming it (the §1.8 negative controls are pinned).
30
+ - **The contract-hash ratchet.** `test/memory-contract-hash-ratchet.test.ts` freezes `MEMORY_DISTILLER_CONTRACT_V1`'s canonical hash against the manifest's `contract.canonicalHash`: a prompt byte change under an unchanged version id is red; the raise is a manifest edit with a reason, after a version bump and rig readings (the standing obligation on the contract).
31
+ - **The pd-2 root-elimination arm (mechanism).** `mintLlmConsolidationPlan` / `mintExposurePartitionedPlan` gain `writingIntent: "none-stated"` (the grouping-authored intent never reaches the authoring prompt) and `productKeyFallback: false` (a nameless answer is a counted write failure, never a product named after the grouping key); non-default choices are recorded on the plan as `minting.writingChannel`. Defaults = the shipped form, byte-identical (no version move: topology, not wording — design/376-C1 D-10). The live A/B readings and the verdict draft ride the ship post.
32
+ - **`RunnerDeps.autoRunOnRecommendation` (design/376 ④).** DEPLOYMENT seat. Explicit `false` = the off switch; an ABSENT seat reads the named default `AUTO_RUN_ON_RECOMMENDATION_DEFAULT` (`memory-consolidation-driver.ts`; ships `false`), applied only where a run could happen (both consolidation seats wired) — under the shipped value the `memory.consolidation_recommended` advisory mints and the host decides, exactly as before. Effective `true` ⇒ when the scope's gate row holds an UNCONSUMED recommendation at the post-task tail (asked of the row through `recommendationUnconsumed`, the recommendation edge's exact negation — never of one harvest's report, so a crossing a suspend-checkpoint harvest consumed is still owed its run and no scope is stranded), ONE host consolidation run fires for that scope (after the terminal harvest, never on it; fire-and-forget, result-first; the driver's own R1–R7 failure table settles it; a CONVERGED run — fold or no-op — consumes the recommendation per design/376 §1.1, a failed/parked run leaves it owed and the next settle retries). One resolver (`autoRunOnRecommendationEffective`) answers "armed?" for the prepare door, the manifest and the trigger. **Bad values refuse at prepare** (`config.auto_consolidation`): a non-boolean; `true` without `memoryConsolidation`; `true` without a resolvable model seat — never folded to off. **Org-visible**: the effective wiring manifest carries `autoConsolidation: { onRecommendation: true }` PRESENT IFF armed (in `configFingerprint`; an unarmed manifest is byte-identical). **Reach.** @server: arming is the deployment's config beside the driver it already hosts — an armed worker auto-egresses the eligible library to the consolidation model after every recommending task; @cli: render the manifest cell as the egress posture it is. Headless / no-approver lanes: unchanged (no ask is involved; the trigger is a deployment declaration screened at boot). Deployment operators: nothing changes unless you set the seat; if you do, read the EGRESS warning on `memoryConsolidationDriver` first.
33
+ - **CC 2.1.259 divergence, registered pending ruling.** The reference CLI's auto-dream auto-EXECUTES background consolidation under a threshold gate and a remote default flag; sema's default stays recommendation-only with host-explicit execution, and `autoRunOnRecommendation: true` recovers the auto-execute form on a deployment-supplied model. Stated in `docs/memory-distillation-can-say.md` N6.
34
+ ### Security / narrowing — a win-form UNC path is RESOLVED like every other spelling, and every resolution is now bounded (#840; win-family trees only; @server @cli; deployment operators)
35
+ - **Before.** `canonicalizeTarget` — the one throat every model-spelled path passes on its way to root containment, the sensitive-path deny set, the session `allowDirs` and a skill manifest's `allowPaths` — answered a win-form `\\host\share\…` target from the SPELLING alone: a share-anchored lexical fold, zero filesystem calls. Every guard downstream then judged that key believing it was a real target. On a UNC-rooted workspace it was not one: a link inside the share was never followed, so a target spelled in-root whose real destination is another share (or a guarded directory) was admitted by all of them. Measured on the previous tree, all four fences. The exemption bought LIVENESS, not trust: an access against an unreachable share blocks in the kernel for the mount timeout and an abort signal does not interrupt it, so probing such a path could hang the tool with no way out.
36
+ - **After — one resolution path, and the liveness answer moved to where the hazard lives.** The UNC arm is deleted (`foldUncLexically` with it — its only caller). A win-form UNC spelling takes the ordinary flow: resolve, follow the symlink chain, canonicalize the deepest existing ancestor for a target that does not exist yet. The hang hazard belongs to the ENVIRONMENT'S filesystem calls rather than to one spelling — a wedged NFS hard mount does it to an ordinary POSIX path — so the whole probe now runs under the tree's single bounded-await law (`withGateTimeout`, `DEFAULT_GATE_AWAIT_TIMEOUT_MS`, the same helper and the same default the write-protection judge takes; no second timer, and none of the twelve call sites changed). An elapsed or aborted wait comes back as a refusal carrying no key — the fail-closed answer every consumer already takes. Contract sentence `fs.canonical_target.probed_and_bounded`. The registered divergence `unc-early-allow-containment-not-waived` retires: there is no early allow left to diverge about.
37
+ - **Narrowing (allow → refuse), the shape this closes.** A win-form UNC spelling that resolves out of the declared roots — through a link inside the share, an alias, or a `..` — is refused where it used to be admitted, by whichever fence is wired: root containment (`path_not_in_root`, naming the destination), the sensitive-path deny set, the fs write gate, the session `allowDirs`, a skill manifest's `allowPaths`. **Headless lanes: such a write/read is refused rather than executed.** A UNC-rooted workspace whose files are ordinary files is unchanged.
38
+ - **Widening, one spelling.** `\\host\share\..\other\x` was refused by name ("climbs above its UNC share root"); the platform CLAMPS such a spelling at the share root (`path.win32.resolve` and the Win32 client normalizer both do, and the comparison side always clamped), so the clamped target is now judged by containment — out of root ⇒ `path_not_in_root` (code moves `invalid` → `path_not_in_root`, no closed-set membership change), in root ⇒ admitted. The throat and the readers stopped disagreeing about one spelling.
39
+ - **Cost, stated plainly and measured.** A win-form UNC target is now TOUCHED where the exemption touched nothing (`absolutePath` + `exists` + `canonicalPath` for an existing leaf — pinned): on Windows that is one access against the share, with the credential-offer that implies. A namespace-refused spelling still costs zero probes (the screen is lexical and runs first). **The deadline bounds the CALLER'S wait, not the filesystem call** — the underlying `stat`/`realpath` is not cancellable and may hold a runtime I/O slot until the kernel gives up. This is not a claim that an unreachable host became cheap.
40
+ - **Known and accepted.** (a) The ordinary POSIX/drive arm always probed and had NO bound: against a filesystem that does not answer the call never returned. It now refuses at 30 s — pinned, and the same pin times out against the previous tree. (b) A deployment whose `approvalTimeoutMs` exceeds 30 s meets the canonicalizer's own 30 s ceiling first: same fail-closed verdict, reached earlier. The gate's own bounded awaits still honour that seat exactly as before; whether the deployment's seat should reach the fs throat at all is an open question (the seat is the GATE's approval deadline and the throat is not the gate), filed rather than decided. (c) A win-form UNC target that DOES NOT EXIST keeps its model-spelled tail verbatim, trailing-dot/space aliases included — the precision a `C:\…` target has always had, no more and no less; no judgment weakens, because the deny and sensitive-path judges read both views whenever the minter reports no alias collapse, and containment compares identities, which fold the alias themselves.
41
+ - **Third moved cell, measured.** A bare share ROOT (`\\host\share`) now mints `\\host\share\`, the trailing separator the resolver returns; a DRIVE root always carried one, so what changed is that the two roots stopped disagreeing. Both spellings still mint ONE key, and containment compares identities, which fold the separator away.
42
+ - **Five defects this change introduced were found and fixed before it left the branch, four of them by making the reachable set bigger rather than by changing a rule.** Making the UNC form reachable also made the not-yet-exists ANCESTOR WALK reachable for it, and that walk's floor was written for the families whose anchor is one component in. A win-form UNC anchors at `\\host\share`; the walk went past it to `\\host` and then a bare `\`, which on Windows names the CURRENT DRIVE's root, exists, and canonicalizes — so `\\host\share\x` came back as `C:\\host\share\x` and a `C:\`-rooted fence ADMITTED it. The same defect then survived twice more in spellings the first fixtures normalised away: the forward-slash form (`//host/share/x`, which an environment returns verbatim because win32 reads it as absolute) and a separator RUN (`\\host\\share\x`, whose empty components shift the anchor). The floor is now the path's own anchor, found by counting components on the TREE's family. Alongside them: the rejoin could double a separator when the base was a root (`\\host\share\\new.txt` — one file, two keys, which containment folds away and a byte-wise reader does not); the trailing-separator strip that fixed it was at first family-blind and renamed a POSIX directory literally called `dir\`; and the deleted lexical arm had been folding a UNC's separators to the native form as a side effect, without which `//host/share/x` and `\\host\share\x` minted two keys for one file (restored through `nativeUncSpellingOf`, whose whole job it is). None of these reaches a POSIX tree.
43
+ - **Known and NOT fixed here, pinned as a limit (G15).** A separator RUN inside the part of the path that does not exist yet is rejoined as written, so `\\host\share\missing\\new.txt` and `\\host\share\missing\new.txt` mint two keys for one file. It is the same on a drive path and on a POSIX path and always was; this change only adds the UNC spelling to the set (the deleted lexical arm collapsed runs as a side effect, so for UNC alone it is a regression). Fixing it for UNC only would rebuild the special case this ticket deletes, and fixing it for everyone means normalising keys on two paths this ticket does not own. The failure mode is fail-closed — the per-file read state sees two files, so an edit is refused, never wrongly allowed.
44
+ - **Not changed, pinned as such.** Non-win trees: byte-identical, spelling for spelling (a POSIX tree refuses the UNC form at the namespace screen as it always did, and the negative-control matrix was re-run against the previous tree). The namespace screen, the device-namespace refusal and the extended-length fold are untouched and still cost zero probes. Pinned in `test/backlog840-unc-probed-under-deadline.test.ts` (G1–G14), each arm with its own mutation (sixteen, each red).
45
+ ### Record fidelity — the inherited chain's final-form re-check no longer drops the engine's own attestations (#831; delegated/subagent runs whose ancestor rewrites a call; @server @cli @client-core read `gate.disposition`)
46
+
47
+ - **Before.** When an ancestor constraint rewrites a call's arguments, the chain re-runs the layers that never audited the final form, and those layers adjudicate for real — an ancestor's frozen classifier judges the bytes that will RUN. The engine's records (which model judged, which form of round, the settlement) ride identity-keyed seats, so a reconstruction loses them unless it carries them. The deny arm carried them; the two exits beside it did not. Net effect on `gate.disposition` for such a call: the re-check's own classification was dropped on the floor — an inherited ALLOW cleared by the re-check's classifier reported the model that judged an EARLIER shape (or, when the main pass reached no station, no model at all), and an ask the re-check surfaced lost every record across the rewrite spread.
48
+ - **After.** All four exits of that closure carry, per seat, through the one helper that walks the seat LIST (never a seat by name): the deny arm as before, the surfaced ask across its rewrite spread, and the allow exit absorbing the re-validation's own records. Layer order, the module's one rule: the main pass's records first, the re-check's over them — so the record names the station that judged the bytes that RUN, while a seat the re-check did not fill keeps what the main pass recorded.
49
+ - **It was never only about which model judged — a person's yes rides the same carrier** (found by an adversarial round, and the reason this entry names `gate.settlement` too). An ancestor constraint content with the ORIGINAL arguments but asking about the REWRITTEN ones consults its own frozen approver DURING the re-check. Before, the engine asked a person and then dropped the record: the disposition read `{ kind: "allowed" }` and nothing else. Now the record carries `settlement: { kind: "human_allowed", who: { party: "person" } }` with its `origin`, invariant-clean (I4 permits a person's yes on an allowed disposition; the screen reports no defect). **A consumer answering "was a person involved in admitting this call?" off `gate.settlement` flips from NO to YES on that lane** — audit and approval surfaces should re-read `docs/wire-consumption/unreleased-831.md`.
50
+ - **Written onto a chain-OWNED clone, never onto the fold's decision.** On this path the fold hands back the EMITTING LAYER's own object whenever nothing was attested in the main pass, and a policy may return one object for every call. A record is bound to (`toolCallId`, `toolName`), so writing there is read by a later call replaying that pair: measured on the real lane before the form was settled, a second run whose chain classified nothing reported the first run's classifier. A fabricated record is worse than a missing one; the clone is what makes it impossible. Pinned as a negative control.
51
+ - **Reach.** Only calls that are BOTH inside a delegation chain AND rewritten by an ancestor at chain index ≥ 1. A caller policy that emits its own rewrite sits at index 0, leaving an empty re-check prefix — nothing is re-adjudicated there and the record is unchanged (pinned, both with and without an attested ancestor behind the emitter). No verdict moves in any direction: nothing is allowed that was denied, nothing asked that was not asked. What changes is what the RECORD says about calls that were already being decided the same way. Consumers that render "judged by <model>" or branch on `disposition.cause` see a value where they saw an absence, and the ancestor's re-check model where they saw the first pass's.
52
+ - Pinned in `test/backlog831-recheck-attestation-lane.test.ts` through the production loading path (`Runner` + `internals.inheritedGate.parentConstraints`), on a reusable lane fixture (`test/lane-fixtures/inherited-recheck-lane.ts`) — the repo's first. The seam pin it replaces claimed this lane and could not see it: reverting every landing left it, and 58 pins beside it, green (#834).
53
+
54
+ ### Record fidelity — a person's yes now survives the arguments being edited afterwards (#864; delegated/subagent runs whose ancestor constraint resolves an ask at its own approver; @server @cli read `gate.settlement`)
55
+
56
+ - **Before.** An ancestor constraint that raises an `ask` resolves it at its OWN frozen approver, and the engine attests that resolution on an identity-keyed seat. The wrapper handed the attested object back when the ask carried no rewrite of its own — and a bare SPREAD of it when it did; the approved-EDIT helper beside it minted fresh allow objects on every arm. So one thing decided whether the record said a person allowed the call: whether the arguments were touched after the person said yes. Measured on the tree, one ancestor, one approver, one yes: the ask WITHOUT a rewrite recorded `settlement: human_allowed`; the ask WITH one recorded `{ kind: "allowed" }` and nothing else. The operator-edit path dropped it on both of its arms.
57
+ - **After.** The reconstructions carry, the way every other reconstruction in that file already does — same law, no new rule. Three lanes now report the approval they always performed: an ancestor ask carrying its own rewrite, an approver answering with an operator EDIT, and an edit that was re-asked and approved again. Where several approvals settled one call the record names the LATEST (pinned through `who.approver`).
58
+ - **Reach.** `gate.settlement` and its `gate.origin` are PRESENT where they were absent on those three lanes — nothing else moves. **No verdict changes in any direction**: nothing is allowed that was denied, nothing asked that was not asked, no approver is consulted a different number of times. The record stays invariant-clean (I4 permits a person's yes on an allowed disposition; the screen reports no defect). **@server @cli: a consumer answering "was a person involved in admitting this call?" off `gate.settlement` flips from NO to YES on those lanes** — audit and approval surfaces should re-read `docs/wire-consumption/unreleased-864.md`.
59
+ - **A third arm, found by a later adversarial round and fixed with the rest.** When the ancestor's frozen classifier CLEARS the edited bytes, the helper hands back the station's own object — which carries the classification and not the approval that produced those bytes. Measured: a standing rule raises the ask (a rule ask is out of the frozen classifier's reach, so the approver is consulted), the person approves WITH an edit, the classifier then clears the edit, and the record named the model and forgot the operator. It now carries both, onto an object this scope owns. `origin` names the RULE that asked and `settlement` the person who answered — two different facts, both on the record.
60
+ - **The refusal half too — a refusal of edited bytes is a VETO, not an erasure.** Every refusal this helper hands back (the ancestor policy's own deny of the edit, its frozen classifier's refusal, the re-approval round cap, the re-check error arm) now carries the approval it vetoes, through ONE local rule rather than four copies. One record states both true things: a person allowed this call, and a layer refused the final form anyway — a consumer asking "was anyone involved?" is answered yes, one asking "did it run?" is answered no. That is invariant I3's own shape (`deniedBy` resolves to `policy`, a veto layer) and the screen stays clean, pinned. The record is written onto an object this scope owns, never onto the layer's or the station's — a policy may return one deny object for every call, and a call-bound record written there is read by the next call replaying the same id (pinned as its own negative control).
61
+ - **Not changed, and the limit of that decision is stated rather than implied.** The same-instance arbitration adapter's tail mints a fresh allow the same way and was left alone: when the two approver seats are DIFFERENT functions the child's own gate resolves and records the person, so the adapter's copy is redundant (measured, pinned). **With ONE approver function on both seats it is not redundant** — the inherited-ask grant collapses the duplicate frame and the executing call reports no settlement at all although a person answered, which is not specific to that adapter (a plain ancestor entry sharing the child's approver loses it identically) and is not fixable at any arm here: the record is dropped at the grant seam (`consumeInheritedAskGrant` returns a bare allow, and the stored grant carries no settlement to return — while booking the consultation in the human-review ledger, so the engine's two records of one approval disagree). A separate, PRE-EXISTING defect, reported for its own ticket and deliberately left unpinned rather than pinned green.
62
+ - Found by an adversarial round on #831, which reached the settlement seat through the final-form re-check; following it to the arms beside the re-check found the same disease one frame up, on the main pass, where it had always been. Pinned in `test/backlog864-approved-edit-settlement.test.ts`, four landings each with its own mutation.
63
+
64
+ ### Record fidelity — collapsing a duplicate approval frame no longer collapses the record of the approval (#866; delegated/subagent runs whose ancestor shares the child's approver; @server @cli read `gate.settlement`)
65
+
66
+ - **Before.** When an ancestor constraint and the child's own caller slot would consult the SAME approver function about the SAME presented bytes, the engine deliberately asks once: the ancestor's arm resolves the ask and records an inherited GRANT, and the child's gate reuses it instead of showing a second card. The collapse is right. What it also did was lose the answer — the stored grant kept the presented snapshot, the approver identity and the wait, but not the engine's attested settlement, and the reuse minted a bare `{ action: "allow" }`. So the executing call reported no settlement and no origin although a person had said yes, **while the same function booked that consultation in the run's human-review ledger** (`stats.humanReview`): two records of one approval, disagreeing. Reachable on both shapes of the collapse — a shared policy instance and a plain ancestor entry — and measured on both.
67
+ - **After.** The grant stores the attested resolution and the reuse carries it, the way every other reconstruction in this family does. The record now names the person, with the attribution the answer supplied (`who.approver`), and agrees with the ledger.
68
+ - **Reach.** `gate.settlement` and its `gate.origin` are PRESENT where they were absent on that lane; nothing else moves. **The collapse is unchanged — the same person is still asked exactly once** (pinned on both shapes). No verdict changes in any direction. Absence keeps its old meaning and consumers must keep reading it as "this pass settled no ask", never as "no person was involved". **@server @cli: a consumer answering "was a person involved in admitting this call?" flips from NO to YES on that lane, and one that cross-checks `gate.settlement` against `stats.humanReview` stops seeing them contradict** — see `docs/wire-consumption/unreleased-866.md`.
69
+ - Found by an adversarial round that refused #864's negative control as over-general; three configurations were then measured and two of them were losing the record. Pinned in `test/backlog866-inherited-grant-settlement.test.ts`, including a ledger-vs-record agreement pin so the two cannot drift apart again, and a negative control holding the collapse itself in place.
70
+
71
+ ## 7.20.1 — 2026-09-17
72
+
73
+ - memory: `memory_search` / `memory_index` descriptions now state that entries written during the current run are indexed at the run boundary and are not searchable or listed until then (Read the file just written instead). Text-face only; no behavior change. (#754)
74
+ ### Widening (ask → allow), one cell — the shell read boundary's word scan now follows the read face (#854; deployments declaring `readFace: "open"` only; @server @cli @client-core @test; deployment operators)
75
+ - **Before.** The shell read boundary's containment half follows the resolved read face everywhere but one place: the operand walk reclassifies an out-of-roots finding as an ordinary checked candidate under `readFace: "open"`, and the text-level containment gate carries the face through — but the word scan that judges a command the GRAMMAR refused whole (`stdinFileJudgedInside`, reached through `refusedCommandMayRead`) compared each resolved literal against the roots unconditionally. On the open face that produced two readings of one path: `cat <out-of-roots file>` vouched with no ask, while the same read spelled with syntax the grammar refuses — a stdin redirection into an unlisted program, a subshell, a line break — still raised a MANDATED ask. Two read faces of one process answering differently about one path is the thing this seat exists to prevent.
76
+ - **After.** That scan reads `BashReadonlyRootBoundary.face` like every other containment judgment on this seat: under `"open"` a resolved, non-deny-listed literal is judged INSIDE wherever it points. Absent face ⇒ `"roots"`, so a deployment that declared no read face is byte-identical.
77
+ - **Reach, measured on the real load path (Runner prepare → NodeExecutionEnv → gate), not inferred.** One row of an eight-command battery × two faces × two deployment shapes changes: `zzunlisted < <out-of-roots file>` on `readFace: "open"` goes from one ask to zero. Every other row — including the same command in-roots, the same command on `readFace: "roots"`, and the deny-listed path on either face — is unchanged.
78
+ - **NOT changed, and pinned as such.** The deny half still judges in BOTH faces (the face opens containment, never the deny table). The interpreter/unjudged families still mandate on both faces AND for in-roots paths, because neither verdict is about containment: an inline program or script slot nobody read (`awk 1 f`, `sed -n '1r f'`, `python -c`), and a listed reader carrying a file operand inside a grammar-refused command (`cat f > out`). `readFace: "roots"` — the engine default — is untouched in every arm.
79
+ ### Loud absence — the write-protection judge now DISCLOSES which reading of a target it has (#853 / B-128; every deployment that wires no execution environment; @server @cli @client-core @test; deployment operators)
80
+ - **Before.** The always-on write-protection judge re-judges a table-missing spelling on its RESOLVED real target — but only when it was compiled with an execution environment. With none (a tool-only / MCP-only deployment, `handsEnabled === false`) it has no filesystem to resolve in, so the spelling IS the whole judgment and a write that reaches a protected row only through an alias is cleared. That degrade was correct and it was SILENT: nothing on the wire said which of the two readings a given leg's write floor had.
81
+ - **After — the judge says so, once.** `WriteProtectionMatcher` self-reports `targetView: "target" | "spelling-only"` at its compile (the one place that knows), the leg's wiring manifest relays it verbatim at `writeProtection.targetView`, and a `"spelling-only"` judge with at least one path-confinable write tool mounted announces `config.write_protection_target_view_absent` (audience `operator`, `detail: { tools, sessionId, runId }`) **exactly once per prepared leg**. New closed set `WRITE_PROTECTION_TARGET_VIEWS` (registered in `docs/CLOSED-SETS.md`, exported). Contract sentence `write_protection.target_view_disclosed`. `ENGINE_NOTICE_CODES` +1.
82
+ - **No behavior change.** Announce, never narrow: no verdict moves, no ask is added, no write is refused that was not refused before. The environment-less judge's fail-open is the reference CLI's own shape (its resolver falls back to the spelling set when resolution is unavailable) and is deliberately kept; what changes is that an operator can now read the degrade instead of inferring it.
83
+ - **Absence readings.** The manifest section is ABSENT iff the leg compiled no judge at all (the deployment replaced the table with `[]`) — a third fact, and strictly weaker than either value. The notice is ABSENT iff the judge has its target view, or no covered write tool was mounted AT PREPARE, or there is no judge; the manifest section reports the judge's reading either way. The covered set is read off the LEG-START roster (a row counts under its own name or any alias it dispatches under, using the judge's own per-call predicate), so a run that gains its first covered write tool through a mid-run roster delta is not announced — one announcement per prepared leg, not a subscription.
84
+ - **Fingerprint.** `writeProtection` rides inside `configFingerprint` (an assembly fact — legs of one assembly agree on it), so the fingerprint value moves for every deployment on this version. It has no other consumer.
85
+ - Internal: the judge is compiled ONCE, by the new `src/core/runner/write-protection-station.ts` (`mountWriteProtection` — the compile, the covered-tool read off the roster and the notice), called from the wiring-manifest phase (which runs first and must report the word); the compiled check is handed to the gate-stations phase instead of being compiled a second time there. `createWriteProtectionCheck` (not exported from the package root) returns `{ check, targetView }`.
86
+
3
87
  ## 7.20.0 — 2026-09-16
4
88
 
5
89
  ### 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)
@@ -209,7 +209,7 @@ export type RealApprovalTableCoversEveryOrigin = AssertAllKeysHandled<Exclude<As
209
209
  * may answer it live).
210
210
  * A PROBE mandate riding the same `shell_gate_tighten` word (a read-boundary / out-of-root /
211
211
  * recursive-read probe verdict, or a probe that did not answer) is NOT this table's to admit: the
212
- * gate's `probeBoundaryMandate` excludes it at both classifier stations, the way the allow layer
212
+ * gate's mandate table excludes it at both classifier stations, the way the allow layer
213
213
  * excludes it from a standing rule. This table says which QUESTIONS are the classifier's; the probe
214
214
  * words say which asks are nobody's but a person's.
215
215
  * - `policy`: YES — including a safety policy's `requiresRealApproval` ask (a live verdict is
@@ -235,6 +235,84 @@ declare const CLASSIFIER_MAY_ANSWER: {
235
235
  export type ClassifierTableCoversEveryOrigin = AssertAllKeysHandled<Exclude<AskOrigin, keyof typeof CLASSIFIER_MAY_ANSWER>>;
236
236
  /** Whether the auto-mode classifier may resolve an ask of this origin (see the table's contract). */
237
237
  export declare function classifierMayAnswer(origin: AskOrigin): boolean;
238
+ /**
239
+ * The MANDATE family — the closed set of words for "this ask stands by STRUCTURE, so a standing allow
240
+ * rule may not retire it". TWO AXES, not one word each: EVERY member binds the persisted-rule lane
241
+ * (membership IS that answer), and the table below says, per word, whether a LIVE decider may answer.
242
+ *
243
+ * - `operator_always` — the coarse doctrine's per-call confirmation for shell commands.
244
+ * - `tool_marks` — the tool's own egress / irreversibility declaration.
245
+ * - `probe_mandate` — this call's reversibility probe declared the demotion STRUCTURAL.
246
+ * - `probe_unanswered` — the probe was consulted and did not answer: the boundary is UNKNOWN.
247
+ * - `write_protection` — the table covers this write's target. A write onto load-executed configuration
248
+ * re-authorizes the agent, so the question is a property of the TARGET, not of anyone's hesitation.
249
+ * - `write_protection_unresolved` — the judge could not RESOLVE the target: `probe_unanswered`'s
250
+ * doctrine one layer over.
251
+ */
252
+ export declare const PERSISTED_RULE_MANDATES: readonly ["operator_always", "tool_marks", "probe_mandate", "probe_unanswered", "write_protection", "write_protection_unresolved"];
253
+ export type PersistedRuleMandate = (typeof PERSISTED_RULE_MANDATES)[number];
254
+ /**
255
+ * May a LIVE automatic lane answer an ask carrying this word? (The persisted-rule lane is not among
256
+ * them — every word binds it.) TWO COLUMNS, because the two lanes are not asking one question: the
257
+ * sandbox-admission leg asks whether CONTAINMENT already holds what this call would do, while the
258
+ * classifier asks whether the question itself is one a model verdict may settle. Five of the six words
259
+ * happen to answer both the same way today; that is a coincidence of those words, not an invariant, and
260
+ * the columns exist so the sixth does not have to lie in one of them. Each row's reason:
261
+ * - `write_protection`: yes — upstream parity (that judge runs BEFORE the general allow rules and marks
262
+ * its ask classifier-approvable); what is refused is a standing rule answering "may this agent rewrite
263
+ * its own configuration?" once, for good.
264
+ * - `write_protection_unresolved`: CLASSIFIER no — the table's answer for this call is unknown, and a
265
+ * verdict clearing it would answer a question the engine could not put. SANDBOX yes — containment
266
+ * holds the effect whether or not the target is on the table, which is the leg's whole doctrine, and
267
+ * an isolated environment that cannot resolve a path is the ordinary reason this word appears.
268
+ */
269
+ declare const MANDATE_LANE_ANSWERS: {
270
+ readonly operator_always: {
271
+ readonly classifier: true;
272
+ readonly sandboxAdmission: true;
273
+ };
274
+ readonly tool_marks: {
275
+ readonly classifier: true;
276
+ readonly sandboxAdmission: true;
277
+ };
278
+ readonly probe_mandate: {
279
+ readonly classifier: false;
280
+ readonly sandboxAdmission: false;
281
+ };
282
+ readonly probe_unanswered: {
283
+ readonly classifier: false;
284
+ readonly sandboxAdmission: false;
285
+ };
286
+ readonly write_protection: {
287
+ readonly classifier: true;
288
+ readonly sandboxAdmission: true;
289
+ };
290
+ readonly write_protection_unresolved: {
291
+ readonly classifier: false;
292
+ readonly sandboxAdmission: true;
293
+ };
294
+ };
295
+ /** The fence over the mandate table: `never` while every word has a row. */
296
+ export type MandateTableCoversEveryMandate = AssertAllKeysHandled<Exclude<PersistedRuleMandate, keyof typeof MANDATE_LANE_ANSWERS>>;
297
+ /** Whether the auto-mode CLASSIFIER may answer an ask carrying this word; `undefined` (no mandate) admits it. */
298
+ export declare function classifierMayAnswerMandate(mandate: PersistedRuleMandate | undefined): boolean;
299
+ /** Whether the SANDBOX-ADMISSION leg may admit an ask carrying this word — the other column. */
300
+ export declare function sandboxMayAdmitMandate(mandate: PersistedRuleMandate | undefined): boolean;
301
+ /** WHY a matching persisted allow rule did not clear the ask, in the sentence a PERSON reads — one row per
302
+ * word (the ladder this replaces ended in a catch-all arm). Display prose; nothing reads it back to decide. */
303
+ declare const MANDATE_DISCLOSURE: {
304
+ readonly probe_mandate: "the reversibility check declared this call structurally gated (the built-in shell check raises this for a read outside the directories allowed for this session) — it is cleared by confirming this call, never by a standing allow rule";
305
+ readonly probe_unanswered: "the reversibility check did not answer for this call (it timed out or failed), so whether the call reads outside the directories allowed for this session is unknown — it is cleared by confirming this call, never by a standing allow rule";
306
+ readonly operator_always: "this deployment mandates per-call confirmation for shell commands (shellGate: always)";
307
+ readonly tool_marks: "this tool carries egress/irreversibility marks (a mandated confirmation a rule cannot clear)";
308
+ readonly write_protection: "this write's target is on the deployment's write-protection table (the agent's own configuration is guarded one look per write) — it is cleared by confirming this call, never by a standing allow rule";
309
+ readonly write_protection_unresolved: "the write-protection judge could not resolve this write's target, so whether it lands on the protected table is unknown — it is cleared by confirming this call: no standing rule and no automatic decider answers for it, though a wired approver seat can";
310
+ };
311
+ /** The fence over the disclosure table: `never` while every word has a row. */
312
+ export type DisclosureTableCoversEveryMandate = AssertAllKeysHandled<Exclude<PersistedRuleMandate, keyof typeof MANDATE_DISCLOSURE>>;
313
+ /** The disclosed reason for one word. `undefined` — no mandate — is the lane's OTHER refusal: a standing
314
+ * "ask me each time" rule matched, which outranks the remembered yes on its own. */
315
+ export declare function mandateDisclosureOf(mandate: PersistedRuleMandate | undefined): string;
238
316
  /**
239
317
  * The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
240
318
  * the single source for "could a persisted allow rule clear this ask?". Allow rules silence the
@@ -255,6 +333,11 @@ export declare function classifierMayAnswer(origin: AskOrigin): boolean;
255
333
  * above is deliberately the ONLY thing that carves a mandate out of it;
256
334
  * · a tool's OWN `"always"`/`"maybe"` irreversibility tier without the doctrine (`"tool_marks"`).
257
335
  *
336
+ * The two per-call write-protection seats sit BELOW the probe words and ABOVE the seat marks, on the
337
+ * ladder's rule: a word no lane may answer outranks the rest, and a fact about THIS call outranks a
338
+ * property of the tool. Judged HERE, not in {@link irreversibilityMandateOf} which {@link safetyAxisOf}
339
+ * shares: such a write is not an irreversible action, and its park stays budgetable.
340
+ *
258
341
  * Two consumers, one derivation: the gate's silencing arm (a matching rule is disclosed as shadowed
259
342
  * instead of clearing the ask) and the runner's suggestion factory (a mandated ask offers no
260
343
  * "stop asking me this" option — a rule minted from it would never clear it). A drift between the
@@ -262,7 +345,7 @@ export declare function classifierMayAnswer(origin: AskOrigin): boolean;
262
345
  * the same way every other per-call fact does: the gate stamps it on the surviving ask, the
263
346
  * synchronous sites read it off the decision they spread, and the park leg threads its own parameter.
264
347
  */
265
- export declare function persistedRuleMandateOf(marks: MandateMarks): "operator_always" | "tool_marks" | "probe_mandate" | "probe_unanswered" | undefined;
348
+ export declare function persistedRuleMandateOf(marks: MandateMarks): PersistedRuleMandate | undefined;
266
349
  /** The mark inputs of the two mandate derivations — properties of the tool SEAT (`egress`, `shellGated`,
267
350
  * `irreversibility`) beside the two per-call facts the gate stamps. */
268
351
  export interface MandateMarks {
@@ -272,6 +355,13 @@ export interface MandateMarks {
272
355
  /** The surviving ask's engine-stamped `probeMandated` — see
273
356
  * {@link import("./types.js").ReversibilityVerdict.mandated}. */
274
357
  probeMandated?: boolean;
358
+ /** The gate's own record that the write-protection judge covered this call and its table MATCHED
359
+ * (`GatePass.writeProtected`, written at the fourth post-fold tighten and there alone). A GATE fact
360
+ * like the org words: it rides the pass, not the decision, so no layer outside the engine declares it. */
361
+ writeProtected?: boolean;
362
+ /** The same record for the judge's other non-clear verdict — consulted, could not RESOLVE the target.
363
+ * The pair is the write face's twin of `probeMandated` / `probeUnanswered`, mutually exclusive. */
364
+ writeProtectionUnresolved?: boolean;
275
365
  /** The pass's own record that the probe was consulted and did not answer (`GatePass.probeUnanswered`):
276
366
  * the boundary is unknown, which no standing yes may stand in for. The runner's suggestion factory does
277
367
  * not see the pass and never passes it — a card may offer a rule the lane then discloses as shadowed
@@ -301,13 +391,10 @@ export declare function safetyAxisOf(marks: MandateMarks & {
301
391
  egress: boolean;
302
392
  irreversible: boolean;
303
393
  } | undefined;
304
- /** The PROBE half of the mandated family (the deployment's read boundary, judged per call): neither a standing rule
305
- * nor the classifier clears these two words; `tool_marks`/`operator_always` bind the rule lane only (`safety_tighten`). */
306
- export declare function probeBoundaryMandate(word: ReturnType<typeof persistedRuleMandateOf>): boolean;
307
394
  /** An ASK that carries its probe's mandate — the per-call member the gate stamped (`probeMandated`), read
308
- * through the same family word so every consumer (the inherited classifier station, the two ancestor
309
- * sandbox-admission arms) is one predicate, not three spellings. Adding a probe word to the family
310
- * changes all of them at once. */
395
+ * through the same family word and lane table, so its three consumers (the inherited classifier station,
396
+ * the two ancestor sandbox-admission arms) are one predicate. A word the table ADMITS reads here exactly
397
+ * as no mandate does, which is the answer those lanes already gave it. */
311
398
  export declare function probeMandatedAsk(ask: {
312
399
  probeMandated?: boolean;
313
400
  }): boolean;
@@ -75,9 +75,44 @@ const CLASSIFIER_MAY_ANSWER = {
75
75
  export function classifierMayAnswer(origin) {
76
76
  return CLASSIFIER_MAY_ANSWER[origin];
77
77
  }
78
+ export const PERSISTED_RULE_MANDATES = ["operator_always", "tool_marks", "probe_mandate", "probe_unanswered", "write_protection", "write_protection_unresolved"];
79
+ const MANDATE_LANE_ANSWERS = {
80
+ operator_always: { classifier: true, sandboxAdmission: true },
81
+ tool_marks: { classifier: true, sandboxAdmission: true },
82
+ probe_mandate: { classifier: false, sandboxAdmission: false },
83
+ probe_unanswered: { classifier: false, sandboxAdmission: false },
84
+ write_protection: { classifier: true, sandboxAdmission: true },
85
+ write_protection_unresolved: { classifier: false, sandboxAdmission: true },
86
+ };
87
+ export function classifierMayAnswerMandate(mandate) {
88
+ return mandate === undefined || MANDATE_LANE_ANSWERS[mandate].classifier;
89
+ }
90
+ export function sandboxMayAdmitMandate(mandate) {
91
+ return mandate === undefined || MANDATE_LANE_ANSWERS[mandate].sandboxAdmission;
92
+ }
93
+ function bindsEveryAutomaticLane(mandate) {
94
+ return mandate !== undefined && !classifierMayAnswerMandate(mandate) && !sandboxMayAdmitMandate(mandate);
95
+ }
96
+ const MANDATE_DISCLOSURE = {
97
+ probe_mandate: "the reversibility check declared this call structurally gated (the built-in shell check raises this for a read outside the directories allowed for this session) — it is cleared by confirming this call, never by a standing allow rule",
98
+ probe_unanswered: "the reversibility check did not answer for this call (it timed out or failed), so whether the call reads outside the directories allowed for this session is unknown — it is cleared by confirming this call, never by a standing allow rule",
99
+ operator_always: "this deployment mandates per-call confirmation for shell commands (shellGate: always)",
100
+ tool_marks: "this tool carries egress/irreversibility marks (a mandated confirmation a rule cannot clear)",
101
+ write_protection: "this write's target is on the deployment's write-protection table (the agent's own configuration is guarded one look per write) — it is cleared by confirming this call, never by a standing allow rule",
102
+ write_protection_unresolved: "the write-protection judge could not resolve this write's target, so whether it lands on the protected table is unknown — it is cleared by confirming this call: no standing rule and no automatic decider answers for it, though a wired approver seat can",
103
+ };
104
+ export function mandateDisclosureOf(mandate) {
105
+ return mandate === undefined ? "an explicit ask rule matched this call (a person's ask-me-each-time outranks a standing allow rule)" : MANDATE_DISCLOSURE[mandate];
106
+ }
78
107
  export function persistedRuleMandateOf(marks) {
79
108
  const word = irreversibilityMandateOf(marks);
80
- return probeBoundaryMandate(word) ? word : marks.egress === true ? "tool_marks" : word;
109
+ if (bindsEveryAutomaticLane(word))
110
+ return word;
111
+ if (marks.writeProtectionUnresolved === true)
112
+ return "write_protection_unresolved";
113
+ if (marks.writeProtected === true)
114
+ return "write_protection";
115
+ return marks.egress === true ? "tool_marks" : word;
81
116
  }
82
117
  function irreversibilityMandateOf(marks) {
83
118
  return marks.probeMandated === true
@@ -98,9 +133,6 @@ export function safetyAxisOf(marks) {
98
133
  (marks.shellGated === true && marks.irreversibility === "maybe" && marks.tightened === "shell_gate");
99
134
  return egress || irreversible ? { egress, irreversible } : undefined;
100
135
  }
101
- export function probeBoundaryMandate(word) {
102
- return word === "probe_mandate" || word === "probe_unanswered";
103
- }
104
136
  export function probeMandatedAsk(ask) {
105
- return probeBoundaryMandate(persistedRuleMandateOf({ probeMandated: ask.probeMandated === true }));
137
+ return bindsEveryAutomaticLane(persistedRuleMandateOf({ probeMandated: ask.probeMandated === true }));
106
138
  }
@@ -25,6 +25,15 @@ export interface EngineNotice {
25
25
  * — both spellings, so a consumer can diff them without guessing which one it holds. Once per
26
26
  * prepared leg, and never for a root that already is its own canonical form (its absence is the
27
27
  * claim that the two agree).
28
+ * - `"config.write_protection_target_view_absent"` — this leg's write-protection judge was compiled
29
+ * with NO execution environment while at least one path-confinable write tool is mounted, so it
30
+ * compares SPELLINGS against its table and nothing else: a write reaching a protected path only
31
+ * through an alias is cleared. Announce, never narrow — no verdict moves (an environment-less judge
32
+ * has no filesystem to resolve in). Audience `operator`. `detail: { tools, sessionId, runId }`,
33
+ * `tools` = the path-confinable write tools on the LEG-START roster, ascending. EXACTLY ONCE per
34
+ * prepared leg, never per judgment and never re-fired for a mid-run roster delta; absent ⇒ the judge
35
+ * has its target view, or no covered write tool was mounted AT PREPARE, or there is no judge —
36
+ * `wiringManifest.writeProtection` reports the judge's reading either way.
28
37
  * - `"config.tool_card_undeclared"` (design/388 L4) — a tool minted a `details.type` card its definition
29
38
  * (or its caller declaration's `renderHints.resultCards`) never declared; the card was dropped from
30
39
  * `tool_end.structured`; once per leg per type; `detail: { type, sessionId, runId }`.
@@ -10,6 +10,11 @@ import type { WriteProtectionVerdict } from "./write-protect.js";
10
10
  * one is its own fail-closed arm). `label` names the wait in the rejection, so the arm that catches it can
11
11
  * say which one elapsed.
12
12
  */
13
+ /** WHY a bounded wait ended with no answer, stated by the WAIT rather than read off an error's text:
14
+ * `timeout` — the bound elapsed; `aborted` — the task ended. Neither mark ⇒ the awaited work's own. */
15
+ export type GateWaitFailure = "timeout" | "aborted";
16
+ /** The mark above, read back off a rejection. `undefined` ⇒ the awaited work's own rejection. */
17
+ export declare function gateWaitFailureOf(err: unknown): GateWaitFailure | undefined;
13
18
  export declare function withGateTimeout<T>(p: Promise<T>, ms: number | undefined, signal?: AbortSignal, label?: string): Promise<T>;
14
19
  /**
15
20
  * The deadline used when the caller supplies no `approvalTimeoutMs`. The wait must be bounded EVERYWHERE:
@@ -35,4 +40,4 @@ export declare function createGateDeadline(input: {
35
40
  * judge's own `unresolvable` verdict — which the fold denies and the approved-edit arm denies — because a
36
41
  * wait that did not finish IS a target nobody could resolve, and an unjudged target is never vouched.
37
42
  */
38
- export declare function awaitWriteProtectionJudgment(judged: Promise<WriteProtectionVerdict>, ms: number, signal?: AbortSignal): Promise<WriteProtectionVerdict>;
43
+ export declare function awaitWriteProtectionJudgment(judged: Promise<WriteProtectionVerdict>, ms: number, signal?: AbortSignal, onFailure?: (err: unknown) => void): Promise<WriteProtectionVerdict>;
@@ -1,4 +1,13 @@
1
1
  import { traceHookCrash } from "./runner/gate-exit.js";
2
+ export function gateWaitFailureOf(err) {
3
+ const mark = err?.gateWait;
4
+ return mark === "timeout" || mark === "aborted" ? mark : undefined;
5
+ }
6
+ function gateWaitError(message, kind) {
7
+ const err = new Error(message);
8
+ err.gateWait = kind;
9
+ return err;
10
+ }
2
11
  export function withGateTimeout(p, ms, signal, label = "reversibilityProbe") {
3
12
  if (ms === undefined && signal === undefined)
4
13
  return p;
@@ -13,9 +22,9 @@ export function withGateTimeout(p, ms, signal, label = "reversibilityProbe") {
13
22
  signal?.removeEventListener("abort", onAbort);
14
23
  fn(v);
15
24
  };
16
- const onAbort = () => done(reject, new Error(`${label} aborted (task ended)`));
25
+ const onAbort = () => done(reject, gateWaitError(`${label} aborted (task ended)`, "aborted"));
17
26
  const timer = ms !== undefined
18
- ? setTimeout(() => done(reject, new Error(`${label} timed out after ${ms}ms`)), ms)
27
+ ? setTimeout(() => done(reject, gateWaitError(`${label} timed out after ${ms}ms`, "timeout")), ms)
19
28
  : undefined;
20
29
  p.then((v) => done(resolve, v), (err) => done(reject, err));
21
30
  if (signal?.aborted) {
@@ -43,11 +52,12 @@ export function createGateDeadline(input, notifier) {
43
52
  return resolved;
44
53
  };
45
54
  }
46
- export async function awaitWriteProtectionJudgment(judged, ms, signal) {
55
+ export async function awaitWriteProtectionJudgment(judged, ms, signal, onFailure) {
47
56
  try {
48
57
  return await withGateTimeout(judged, ms, signal, "writeProtectionJudge");
49
58
  }
50
59
  catch (err) {
60
+ onFailure?.(err);
51
61
  return { kind: "unresolvable", cause: err instanceof Error ? err.message : String(err), unresolvedSymlink: false };
52
62
  }
53
63
  }
@@ -2,8 +2,8 @@ import { inlineUntrusted } from "./untrusted-text.js";
2
2
  import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
3
3
  import { normalizeReadRootCandidate } from "./read-root-candidate.js";
4
4
  import { traceHookCrash } from "./runner/gate-exit.js";
5
- import { writeProtectionTightenDecision } from "./write-protect.js";
6
- import { createGateDeadline, awaitWriteProtectionJudgment, withGateTimeout } from "./gate-bounded-await.js";
5
+ import { writeProtectionMandated, writeProtectionTightenDecision, writeProtectionUnresolved } from "./write-protect.js";
6
+ import { createGateDeadline, awaitWriteProtectionJudgment, gateWaitFailureOf, withGateTimeout } from "./gate-bounded-await.js";
7
7
  export async function runGateFold(pass) {
8
8
  const { input, toolName, toolCallId, callSignal, ledger, notifier, adjudicate } = pass;
9
9
  const boundedAwaitDeadlineMs = createGateDeadline(input, notifier);
@@ -110,10 +110,23 @@ export async function runGateFold(pass) {
110
110
  };
111
111
  pass.tightenedBy = "safety";
112
112
  }
113
- if (pass.decision.action === "allow" && input.writeProtectionCheck !== undefined) {
113
+ const writeProtectionStandingAsk = pass.decision.action === "ask";
114
+ if ((pass.decision.action === "allow" || writeProtectionStandingAsk) && input.writeProtectionCheck !== undefined) {
114
115
  const judged = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, pass.req.face, input.trackedCwd?.(), pass.callSignal ?? input.abortSignal);
115
- const decision = judged === null ? undefined : writeProtectionTightenDecision(toolName, await awaitWriteProtectionJudgment(judged, boundedAwaitDeadlineMs(), pass.callSignal ?? input.abortSignal));
116
- if (decision !== undefined) {
116
+ const verdict = judged === null
117
+ ? undefined
118
+ : await awaitWriteProtectionJudgment(judged, boundedAwaitDeadlineMs(), pass.callSignal ?? input.abortSignal, (err) => {
119
+ const kind = gateWaitFailureOf(err);
120
+ if (kind === "aborted" || input.abortSignal?.aborted === true)
121
+ return;
122
+ traceHookCrash(input, new Error(`write-protection judge ${kind === "timeout" ? "timed out" : "threw"} for tool "${toolName}" (toolCallId ${toolCallId}) — this call's write target could not be judged: ${err instanceof Error ? err.message : String(err)}`), notifier);
123
+ });
124
+ if (verdict !== undefined && writeProtectionMandated(verdict))
125
+ pass.writeProtected = true;
126
+ if (verdict !== undefined && writeProtectionUnresolved(verdict))
127
+ pass.writeProtectionUnresolved = true;
128
+ const decision = verdict === undefined ? undefined : writeProtectionTightenDecision(toolName, verdict);
129
+ if (decision !== undefined && !writeProtectionStandingAsk) {
117
130
  pass.decision = decision;
118
131
  if (decision.action === "ask")
119
132
  pass.tightenedBy = "safety";
@@ -1,5 +1,5 @@
1
1
  import { attestClassifierDenyCause, classifierDenyCauseOf, decisionText, describeThrown } from "./tool-policy.js";
2
- import { classifierMayAnswer, persistedRuleMandateOf, probeBoundaryMandate, safetyAxisOf, stampAskOrigin } from "./ask-origin.js";
2
+ import { classifierMayAnswer, classifierMayAnswerMandate, mandateDisclosureOf, persistedRuleMandateOf, safetyAxisOf, sandboxMayAdmitMandate, stampAskOrigin } from "./ask-origin.js";
3
3
  import { classifierParseFailureDenyMessage, classifierUnavailableDenyMessage, denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
4
4
  import { inlineUntrusted } from "./untrusted-text.js";
5
5
  import { isRuleBehavior } from "./permission-rule-model.js";
@@ -278,6 +278,8 @@ export async function runGateLanes(pass) {
278
278
  irreversibility: input.irreversibility,
279
279
  probeMandated: pass.decision.action === "ask" && pass.decision.probeMandated === true,
280
280
  probeUnanswered: pass.probeUnanswered === true,
281
+ writeProtected: pass.writeProtected === true,
282
+ writeProtectionUnresolved: pass.writeProtectionUnresolved === true,
281
283
  });
282
284
  let personalEvidence = { dotsAbsent: input.persistedRules === undefined ? "not_wired" : "not_adjudicated" };
283
285
  if (laneAnswer?.unreadable === true)
@@ -326,15 +328,7 @@ export async function runGateLanes(pass) {
326
328
  else if (hitRules !== undefined) {
327
329
  personalEvidence = unionDotsOf(hitRules);
328
330
  const shownRule = disclosedRuleSet(hitRules);
329
- const mandateNoun = persistedRuleMandate === "probe_mandate"
330
- ? "the reversibility check declared this call structurally gated (the built-in shell check raises this for a read outside the directories allowed for this session) — it is cleared by confirming this call, never by a standing allow rule"
331
- : persistedRuleMandate === "probe_unanswered"
332
- ? "the reversibility check did not answer for this call (it timed out or failed), so whether the call reads outside the directories allowed for this session is unknown — it is cleared by confirming this call, never by a standing allow rule"
333
- : persistedRuleMandate === "operator_always"
334
- ? "this deployment mandates per-call confirmation for shell commands (shellGate: always)"
335
- : persistedRuleMandate !== undefined
336
- ? "this tool carries egress/irreversibility marks (a mandated confirmation a rule cannot clear)"
337
- : "an explicit ask rule matched this call (a person's ask-me-each-time outranks a standing allow rule)";
331
+ const mandateNoun = mandateDisclosureOf(persistedRuleMandate);
338
332
  pass.decision = {
339
333
  ...pass.decision,
340
334
  persistedRuleShadowed: shownRule,
@@ -367,7 +361,7 @@ export async function runGateLanes(pass) {
367
361
  }
368
362
  if (pass.decision.action === "ask")
369
363
  ledger.supersede();
370
- if (input.autoMode && pass.decision.action === "ask" && pass.decision.origin !== undefined && classifierMayAnswer(pass.decision.origin) && !probeBoundaryMandate(persistedRuleMandate)) {
364
+ if (input.autoMode && pass.decision.action === "ask" && pass.decision.origin !== undefined && classifierMayAnswer(pass.decision.origin) && classifierMayAnswerMandate(persistedRuleMandate)) {
371
365
  const verdict = await input.autoMode.decider
372
366
  .decide({ req: pass.req, askMessage: decisionText(pass.decision) }, input.abortSignal)
373
367
  .catch(() => ({ kind: "unavailable", cause: "error" }));
@@ -426,7 +420,7 @@ export async function runGateLanes(pass) {
426
420
  pass.hookAsk === undefined &&
427
421
  input.egress !== true &&
428
422
  input.peerMessage !== true &&
429
- !probeBoundaryMandate(persistedRuleMandate) &&
423
+ sandboxMayAdmitMandate(persistedRuleMandate) &&
430
424
  input.sandboxAdmission.boundaryCapable !== true) {
431
425
  const recorded = input.sandboxAdmission.askClassesOf(toolCallId);
432
426
  const admissible = recorded !== undefined && recorded.length > 0 && recorded.every((r) => r.cls === "sandbox_local");
@@ -119,7 +119,7 @@ export type NoticeAudience = "user" | "operator";
119
119
  * the union's catalog ({@link EngineNotice.code}). The groups below say only which family a row joins,
120
120
  * so the catalog is not written a second time here and the two cannot drift.
121
121
  */
122
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
122
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
123
123
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
124
124
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
125
125
  * sink is a supported shape, and narrowing that field would break it. */
@@ -111,6 +111,7 @@ export const ENGINE_NOTICE_CODES = [
111
111
  "config.tool_model_gate_env_invalid",
112
112
  "config.artifact_host_invalid",
113
113
  "config.task_root_not_canonical",
114
+ "config.write_protection_target_view_absent",
114
115
  "config.execution_env_capability_invalid",
115
116
  "config.tool_card_undeclared",
116
117
  "config.tool_face_undeclared",
@@ -199,6 +200,7 @@ const NOTICE_AUDIENCE_TABLE = {
199
200
  "config.tool_model_gate_env_invalid": "operator",
200
201
  "config.artifact_host_invalid": "operator",
201
202
  "config.task_root_not_canonical": "operator",
203
+ "config.write_protection_target_view_absent": "operator",
202
204
  "config.execution_env_capability_invalid": "operator",
203
205
  "config.tool_card_undeclared": "operator",
204
206
  "config.tool_face_undeclared": "operator",
@@ -175,6 +175,9 @@ export interface RunMemoryConsolidationOptions {
175
175
  /** Host seams for the throttle waits (tests and hosts with their own clocks). */
176
176
  now?: () => number;
177
177
  sleep?: (ms: number) => Promise<void>;
178
+ /** Delivery sink for the run's advisory notices, when the caller wires one: delivered at the mint, once,
179
+ * through the engine's notice throat, and still on the receipt. Absent ⇒ the receipt alone carries them. */
180
+ onNotice?: (notice: EngineNotice) => void;
178
181
  onProgress?: Parameters<typeof mintLlmConsolidationPlan>[0]["onProgress"];
179
182
  }
180
183
  export interface ConsolidationRunReceipt {