@sema-agent/core 7.30.0 → 7.31.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 (90) hide show
  1. package/CHANGELOG.md +274 -0
  2. package/dist/agents/cross-session-judge.d.ts +4 -12
  3. package/dist/agents/cross-session-judge.js +4 -11
  4. package/dist/agents/peer-session-drain.js +0 -5
  5. package/dist/agents/send-message-tool.js +1 -0
  6. package/dist/agents/subagent.d.ts +1 -0
  7. package/dist/agents/subagent.js +1 -0
  8. package/dist/core/ask-origin.d.ts +6 -0
  9. package/dist/core/ask-origin.js +10 -0
  10. package/dist/core/checkpoint-store.d.ts +14 -0
  11. package/dist/core/checkpoint-store.js +1 -0
  12. package/dist/core/engine-notice.d.ts +4 -0
  13. package/dist/core/governance-codes.d.ts +1 -1
  14. package/dist/core/governance-codes.js +3 -0
  15. package/dist/core/memory-engine/data-plane.d.ts +3 -1
  16. package/dist/core/memory-engine/data-plane.js +1 -1
  17. package/dist/core/memory-engine/delegation-settlement.d.ts +44 -3
  18. package/dist/core/memory-engine/delegation-settlement.js +38 -3
  19. package/dist/core/memory-engine/engine.d.ts +123 -26
  20. package/dist/core/memory-engine/engine.js +426 -149
  21. package/dist/core/memory-engine/entry-scan.d.ts +23 -0
  22. package/dist/core/memory-engine/entry-scan.js +55 -0
  23. package/dist/core/memory-engine/file-backend.d.ts +16 -23
  24. package/dist/core/memory-engine/file-backend.js +122 -99
  25. package/dist/core/memory-engine/layer-observations.d.ts +109 -0
  26. package/dist/core/memory-engine/layer-observations.js +148 -0
  27. package/dist/core/memory-engine/layout.d.ts +11 -0
  28. package/dist/core/memory-engine/layout.js +20 -4
  29. package/dist/core/memory-engine/types.d.ts +26 -2
  30. package/dist/core/parent-spec-seat.d.ts +4 -1
  31. package/dist/core/parent-spec-seat.js +2 -0
  32. package/dist/core/park-selfcheck.js +1 -0
  33. package/dist/core/runner/announce-once-ledger.d.ts +26 -0
  34. package/dist/core/runner/announce-once-ledger.js +37 -0
  35. package/dist/core/runner/contracts.d.ts +22 -5
  36. package/dist/core/runner/denial-limit-arms.d.ts +2 -2
  37. package/dist/core/runner/prepare-acquire-reconcile.d.ts +0 -4
  38. package/dist/core/runner/prepare-acquire-reconcile.js +1 -3
  39. package/dist/core/runner/prepare-file-history.d.ts +13 -15
  40. package/dist/core/runner/prepare-file-history.js +57 -43
  41. package/dist/core/runner/prepare-hands-readface.d.ts +2 -2
  42. package/dist/core/runner/prepare-listings.d.ts +3 -1
  43. package/dist/core/runner/prepare-listings.js +2 -2
  44. package/dist/core/runner/prepare-memory.js +16 -5
  45. package/dist/core/runner/prepare-park-ask.js +27 -16
  46. package/dist/core/runner/prepare-prompt-inputs.d.ts +8 -1
  47. package/dist/core/runner/prepare-prompt-inputs.js +5 -3
  48. package/dist/core/runner/prepare-task.js +7 -5
  49. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -0
  50. package/dist/core/runner/prepare-wiring-manifest.js +2 -1
  51. package/dist/core/runner/run-attachment-seats.js +1 -1
  52. package/dist/core/runner/run-leg.js +61 -52
  53. package/dist/core/runner/run-settle-and-teardown.js +4 -0
  54. package/dist/core/runner/run-turn-boundary.js +6 -6
  55. package/dist/core/runner/turn-attachments.d.ts +6 -6
  56. package/dist/core/runner-deps.d.ts +3 -1
  57. package/dist/core/secret-env.d.ts +11 -0
  58. package/dist/core/secret-env.js +22 -1
  59. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +2 -0
  60. package/dist/core/store-contracts/checkpoint-store-contract.js +22 -0
  61. package/dist/core/task-result.d.ts +1 -1
  62. package/dist/core/task-spec.d.ts +17 -8
  63. package/dist/core/tool-spec.d.ts +1 -1
  64. package/dist/core/wiring-manifest.d.ts +10 -0
  65. package/dist/core/wiring-manifest.js +7 -1
  66. package/dist/engine/execution-env/node-execution-env.d.ts +6 -0
  67. package/dist/engine/execution-env/node-execution-env.js +15 -17
  68. package/dist/engine/harness/agent-harness.js +7 -5
  69. package/dist/engine/lsp/node-lsp-manager.d.ts +7 -0
  70. package/dist/engine/lsp/node-lsp-manager.js +11 -3
  71. package/dist/index.d.ts +1 -1
  72. package/dist/index.js +1 -1
  73. package/dist/shell/lex.d.ts +8 -3
  74. package/dist/shell/lex.js +2 -2
  75. package/dist/tools/fs/bash-readonly-classifier.d.ts +7 -1
  76. package/dist/tools/fs/bash-readonly-classifier.js +7 -3
  77. package/dist/tools/fs/shell-read-establish.d.ts +1 -1
  78. package/dist/tools/fs/shell-read-establish.js +6 -34
  79. package/dist/tools/fs/shell-read-identity.d.ts +13 -0
  80. package/dist/tools/fs/shell-read-identity.js +39 -0
  81. package/dist/tools/fs/shell-read-judge.d.ts +8 -0
  82. package/dist/tools/fs/shell-read-judge.js +7 -0
  83. package/dist/tools/fs/shell-read-plan.d.ts +5 -1
  84. package/dist/tools/fs/shell-read-plan.js +13 -21
  85. package/dist/tools/fs/shell-read-word.d.ts +16 -2
  86. package/dist/tools/fs/shell-read-word.js +40 -5
  87. package/package.json +1 -1
  88. package/test/export-surface.snapshot.json +7 -1
  89. package/wire-changes/core@7.30.1.json +154 -0
  90. package/wire-changes/core@7.31.0.json +705 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,279 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.31.0 — 2026-09-25
4
+
5
+ ### The durable park row carries the mandate twin — `PendingAction.tool_approval.mandated` + `CheckpointSummary.mandated` (#1079; additive; +0 exports; +2 optional type keys; no checkpoint-version bump) — and the facts of the judgment that adjudicated the stored shape (#1088; behavior: tightening only) (@cli @client-core @server)
6
+
7
+ - **What was missing.** `AskRequest.mandated` (7.26.0) says no persisted allow rule and no standing answer may retire the question; a parked approval had no twin — only `requiresRealApproval` (#557) and `ruleOffersAbsence`. The latter is a different fact: the rule-offer lane speaks only for `Bash`, only where a rule lane is wired and only on a task that can hold a rule, and it words a mandated card `"shadowed"` whenever a rule the person wrote is also speaking. So a `Write`/`Edit` card whose target the write-protection judge matched, any mandated card on a deployment with no rule lane or on an anonymous task, an ancestor's ask floated back after its approver answered `"unavailable"` (on any tool but an armed-lane `Bash`), and an operator-written ask rule over a read outside the roots (`ruleOffersAbsence: "shadowed"`) all parked with no way to read the mandate off the row.
8
+ - **One rule.** The park mint copies the bit from the same clearance-facts object the row's rule-offer factory reads, through `askMandateMember` — the derivation the live card's bit comes from — so the parked and the live card of one ask cannot disagree. Present ⇔ mandated, `true` only, never `false`; `CheckpointSummary.mandated` echoes it screened to the literal `true`. Three independent facts now ride a parked card: `requiresRealApproval` (only a person may answer — implies the mandate), `mandated` (no rule may answer), `ruleOffersAbsence` (what the offer lane has to give).
9
+ - **Unchanged.** Display metadata: no gate, CAS or resume path reads it — the resume belts keep reading the gate's own bits. No checkpoint-version bump: an older reader ignores the member, and a row minted before it reads absent (unreported, not "ordinary"). The store-contract kit gains one case (true survives and projects / absent stays absent / `false` or a string reads absent) and its canonical `listByScope` fixture carries the member; the park wiring self-check's representative row carries it too.
10
+ - **#1088 — the row carries the facts of the judgments that adjudicated the value it files.** When a JSON-fidelity checkpoint backend's projection moves the parked value (a `Date`/`Map`/`NaN`… a deployment rewrite put there), the deployment's policy re-judges the stored shape; that re-judgment could only refuse the park (deny / rewrite) — a real-approval demand it raised was dropped, and the gate kind, the row's `requiresRealApproval` and its clearance facts read the first judgment alone, so a stored shape the policy wanted only a person to clear parked as the budget-approvable `human` kind. Now the re-judgment's ask joins the gate's facts, tightening only: its `requiresRealApproval` makes the park `irreversible_ask` with `gate.realApproval` (worded by the parked ask's own origin) and the row's `requiresRealApproval`; its mandate and an explicit ask-rule match join the clearance facts `mandated` and the rule-offer word are read from. A demand the first judgment already made is kept as it was; a re-judgment that allows or asks plainly changes nothing. The safety axis is derived from the tool's marks and the gate's own stamps, never from a policy member, so the re-judgment has none to add.
11
+ - **Pickup.** @cli L-652: `approvalIsMandated` on a parked card reads `mandated` (the offer word covers a subset). @client-core: the park projection keeps `mandated` beside `requiresRealApproval` — a projection that drops them records a mandated parked card as an ordinary one. @server S-663: a custom `CheckpointStore` backend must round-trip the member (the extended kit fails a whitelisting serializer); an inbox reads it from `listByScope` without a `get`. @client-core CC-180 @server S-700: #1088 changes which gate kind such a park mints (`human` → `irreversible_ask`) — a budget resolver that auto-approved it no longer may.
12
+
13
+ ### Fix — ten real budget names join the D1 secret-env scrub's exact-name allowlist, and the withheld-keys disclosure is now a structured notice (#1081; +1 `ENGINE_NOTICE_CODES` member; @server @cli @test)
14
+
15
+ - **Before.** The `TOKENS`-plural reverse ledger (kept: a model-token BUDGET; dropped: a real credential)
16
+ checked only the ONE word immediately touching `TOKENS`, not the whole name: `MAX_NEW_TOKENS`, `MAX_COMPLETION_TOKENS`,
17
+ `MAX_BATCH_PREFILL_TOKENS`, `MAX_WAITING_TOKENS`, `MAX_TOP_N_TOKENS`, `AIDER_MAX_CHAT_HISTORY_TOKENS`,
18
+ `AIDER_MAP_TOKENS`, `RATE_LIMIT_TOKENS`, `PROMPT_TOKENS` and `CONTEXT_TOKENS` — the ten names observed in the
19
+ 2026-09-24 acceptance corpus — were all dropped from a spawned child's environment though none is a credential. The `KEYS`-plural arm had NO reverse-exemption
20
+ list (RB-219's mirror existed only for the singular `KEY`), so a real boolean toggle shaped like a
21
+ credential plural (`DB_FOREIGN_KEYS`, Laravel's sqlite `foreign_key_constraints` switch) was silently
22
+ dropped too. Separately, whatever a scrub withheld was disclosed with a bare `console.warn` — invisible to
23
+ a host forwarding structured notices or reading the Runner's own event stream — and the LSP spawn site
24
+ (`defaultLspSpawn`) disclosed NOTHING at all: no console line, no collected finding, no seat.
25
+ - **An exact-name allowlist, not a wider rule.** An earlier shape of this fix WIDENED the count-word reverse
26
+ ledger to scan a name's whole prefix, then added a credential-word list to claw back the false negatives
27
+ that widening opened. Two review rounds each found a new real-shaped name neither list covered
28
+ (`TOTAL_ACCESS_TOKENS`, then `CONTEXT_GITHUB_TOKENS`/`MAX_OAUTH2_TOKENS`/fifteen more) — a word-flavor
29
+ blacklist over an open-ended prefix can never be complete. The shipped fix instead: (a) leaves the reverse
30
+ ledger exactly as it read before this ticket — `TOKEN_COUNT_WORDS`, unchanged, still adjacent-only; (b)
31
+ moves the ten real names above into `NON_SECRET_ENV_EXACT_NAMES` (RB-219's existing exact-name mirror,
32
+ already used for `KEY`/`KEYS`), passing the SAME three-part admission test the singular list always used —
33
+ an EXACT allowlist has no word-flavor gap for a fourth round to find; (c) extends the SAME mirror to the
34
+ `KEYS` plural — `DB_FOREIGN_KEYS` and `JSON_SORT_KEYS` (a JSON serializer's key-order toggle); `GPG_KEYS`
35
+ (a public key-fingerprint manifest) was considered and declined, since admitting it on "these particular
36
+ keys are public" is exactly the per-value judgment call this list's exactness exists to refuse; (d) the
37
+ withheld-keys disclosure is now `secret-env.ts`'s `announceSecretEnvScrub`, ONE function routed through
38
+ `deliverEngineNotice` (a new closed-catalog member, `config.secret_env_scrubbed`, audience `operator`,
39
+ `detail: { names }`) with ONE once-per-process latch SHARED by every spawn site — a tool's shell
40
+ (`NodeExecutionEnv`, which gains an optional `onNotice` constructor seat, independent of its existing
41
+ `onSecretEnvScrub` findings collector except that the collector wired ALONE keeps its pre-existing console
42
+ silence) and a language server (`NodeLspManager`, which gains the same `onNotice` option; `defaultLspSpawn`
43
+ used standalone still falls back to console, upgraded from total prior silence). A wired `onNotice` seat
44
+ replaces the console line exactly like every other `EngineNotice`; an unwired deployment sees the identical
45
+ console words this disclosure always printed. `NodeLspManagerOptions.env`'s null handling is restored to
46
+ the original nullish (`== null`) reach — a JS or config-driven caller handing `env: null` gets the scrub,
47
+ not `spawn`'s own null-means-inherit-everything reading.
48
+ - **Who is hurt.** Nobody by receiving more of their own real configuration: the ten exact-name additions only
49
+ ADD names that were wrongly dropped before, and — unlike the reverted widened-scan shape — cannot ALSO
50
+ re-admit a name that merely resembles one of them (an exact allowlist has no shape to resemble). A
51
+ deployment that read the literal `console.warn` text for the old scrub summary (rather than filtering on
52
+ the `secret_env_scrubbed:` prefix, which is unchanged) may see slightly different wording after the suffix.
53
+ - **Compensation.** `detail.names` gives a wired sink the exact withheld set per process, where before there
54
+ was nothing to read programmatically. `test/backlog1081c-secret-env-exact-allowlist.test.ts` carries the full evidenced-name table (the ten exempt names, the 24 adversarial names, the positive control).
55
+ @server @cli: pin `ENGINE_NOTICE_CODES` membership before this lands, if you total-switch over the set.
56
+ @test: the invariant that a real credential is dropped even beside an exact-allowlisted name — including
57
+ every adversarial combination of a count word with an unrelated word touching `TOKENS`
58
+ (`TOTAL_ACCESS_TOKENS`, `CONTEXT_GITHUB_TOKENS`, `MAX_OAUTH2_TOKENS`, and 21 more — 24 in all) — is pinned in one place.
59
+ - Pins: `test/backlog1081c-secret-env-exact-allowlist.test.ts` (the 24-name adversarial set — count-word-flavoured
60
+ prefixes paired with a credential-flavoured or unrelated word touching `TOKENS` — all still drop, exactly as v7.30.1 read, plus
61
+ the ten exact-allowlisted names and the `API_MAX_INPUT_TOKENS` positive control in one integration call);
62
+ `test/backlog1081-secret-env-scope-and-notice.test.ts` (per-name direction, the notice's shape/dedup/shared
63
+ latch across spawn sites, the both-seats-wired independence check); two cases in
64
+ `test/backlog1073-lsp-child-env.test.ts` (`env: null` treated as absent; an LSP spawn firing first still
65
+ reaches a wired `onNotice`); `test/backlog1072-secret-env-plurals.test.ts` / `test/rb219-secret-env-exemptions.test.ts`
66
+ / `test/governance-codes.test.ts` re-verified green.
67
+
68
+ ### `crossSessionInbound` retires the transitional `accept` reading (#1078; BREAKING; +0 exports; `ResolvedCrossSessionInboundSetting.deprecatedLayers` removed (optional); @server @cli)
69
+
70
+ - **What was wrong.** 7.30.0 renamed `crossSessionInbound`'s deliver-at-next-turn value from `accept` to
71
+ `next-turn`, but `resolveCrossSessionInboundSetting`'s `readLayer` kept a migration arm: a layer still
72
+ spelling `accept` was READ as `next-turn` (never held, never invalid) and the layer was reported on
73
+ `ResolvedCrossSessionInboundSetting.deprecatedLayers` so `peer-session-drain.ts` could announce the
74
+ spelling once per leg. settings-schema 6.0.0 already dropped `accept` from its own vocabulary — this
75
+ read-side arm was the one window the retired word still had into the engine, a compatibility layer this
76
+ project does not carry through the test phase.
77
+ - **One rule.** `accept` now reads exactly like any other unrecognized `crossSessionInbound` value: the
78
+ layer is `invalid`, `resolveCrossSessionInboundSetting` reports it in `invalidLayers` with
79
+ `decidedBy: "invalid"`, and the EXISTING fail-closed rule forces `hold` whenever the standing value is
80
+ looser than `hold` — `judgeCrossSessionInbound`'s `invalid-setting` hold cause and its warning
81
+ (`unrecognized "crossSessionInbound" value in: <layers>`) are the same loud arm a typo or a boolean
82
+ already used; no new announcement was added. `CROSS_SESSION_INBOUND_DEPRECATED_SPELLINGS` (never on the
83
+ package export surface) and `ResolvedCrossSessionInboundSetting.deprecatedLayers` are removed, along
84
+ with `peer-session-drain.ts`'s per-leg "spelling" announcement that read the latter.
85
+ - **Who is hurt.** A settings file (managed / user / repo layer) still spelling `accept` used to be
86
+ silently migrated to `next-turn` with one warning per leg; it is now `invalid` and forces `hold` on
87
+ every cross-session message this session would otherwise have delivered or parked under mode parity —
88
+ same as any other garbage value.
89
+ - **Compensation.** settings-schema 6.0.0 already rejects `accept` at the schema door, so no currently
90
+ valid settings file can carry it; this closes the one place left inside the engine that still read it.
91
+ @server @cli: this engine's own consumers carry no reference to `deprecatedLayers` /
92
+ `CROSS_SESSION_INBOUND_DEPRECATED_SPELLINGS` (grepped clean in this tree) — check your own trees for a
93
+ read of `ResolvedCrossSessionInboundSetting.deprecatedLayers` and drop it.
94
+
95
+ ### Three memory project-key names reach the package root — `MEMORY_PROJECT_KEY_ABSENT_REASONS`, `MemoryProjectKey`, `MemoryProjectKeyAbsentReason` (#1049; additive; +3 exports; +0 wire keys; @server)
96
+
97
+ - **What was missing.** `MEMORY_PROJECT_KEY_ABSENT_REASONS` (`["not_git", "bare", "unreadable"]`) is the run-time closed set a host enumerates when it keys memory by project, and the two types name the key and its absence reason — all three were declared in `src/core/memory.ts` but not re-exported from the package root, so a host `import` resolved to `undefined` and had to derive the type from `TaskSpec["memory"]["projectKey"]`.
98
+ - **One rule.** The package root exports what a host is expected to enumerate; the export-surface snapshot and the consumer smoke probe carry the three names.
99
+ - **Who is hurt.** Nobody: additive. **Pickup.** @server: import the closed set instead of deriving it.
100
+
101
+ ### Test isolation — every vitest worker gets its own `AGENT_DATA_DIR`, and a run that touches the real `~/.ai-agent` fails (#1067; repo-internal; +0 exports; +0 wire keys)
102
+
103
+ - Every worker's data root lives under a run-scoped parent the main process owns; a global setup gate fails the run when the real `~/.ai-agent` gains an entry, and the teardown sets `process.exitCode = 1` before it throws. Repo-internal: nothing ships in the package; disclosed here because the suite's behaviour changed.
104
+
105
+ ### Read-only memory layers are a session's policy, not file-system state — the engine changes no permission bits (#1076; BREAKING; +0 exports; +0 wire keys; @server @test)
106
+
107
+ - **What was wrong.** `materialize` set every read-only inherited layer to directories 0o555 / files 0o444, and undid it only at the top of the NEXT materialize (the memory dir itself was re-set to 0o755 each mount, and the layout adoption re-set a home to 0o755 before moving into it). A session's view of which layer it may write was written onto disk that every session and process on the store shares: the bits outlived the session, two concurrent sessions with different write scopes re-moded each other's write homes (a write or a commit then failed EACCES), and a control-plane delete in a locked directory failed EACCES — its kept journal then failed the next construction, so the host could not start until someone ran `chmod` by hand.
108
+ - **One rule.** The engine changes no permission bits anywhere (`chmodScopeTree`, `chmodNoFollow`, `openNoFollowForMode`, the restore's `fchmod` pair and the adoption's `chmodSync` are gone). A read-only layer stays read-only as policy. The file-tool write gate's domain is now the whole memory dir: a write into anything outside the session's write home — an inherited layer, the memory root — is refused at the write (`read_only_layering`, with its own reason); it used to pass the gate and fail on the permission bits. Harvest detects a changed read-only layer file on every path (`readonly_layer`); a writing session restores the committed copy once its fail-closed preflights pass, and bytes equal to the committed copy (a concurrent session that may write that layer committed them) are not a violation. The two mode passes per materialize become one read-only layer census that still states a link or an unreadable path in a layer, once per materialize (`layer census skipped the symlink …` / `layer census could not read … — nothing under it was examined`, replacing the `mode sweep …` lines).
109
+ - **Who is hurt.** A deployment that relied on 0o444 / 0o555 as a boundary against a same-user process writing an inherited layer (a shell under the default `shellGate: "off"` asks nothing): the write now lands on disk. In a writing session harvest rejects it and restores the committed copy; in a session that admits nothing (`writeScope: null`, capture opt-out, persistence declared unavailable) it is a `readonly_layer` rejection plus a `restricted_divergence` finding and is not restored; either way the bytes are recorded as an unexpected write and never adopted (next section). A reader that took EACCES on a layer file as "this layer is read-only" no longer gets one.
110
+ - **Compensation.** The file-tool refusal above, and the harvest `readonly_layer` rejection + restore (now detected on every harvest path). A memory write the file system refuses on permissions (EACCES / EPERM) at a path inside the store is now loud at every engine write point, with the failing path and one remedy: a thrown `materialize` / `harvest` / `eraseMemoryEntries` error and the harvest incident detail carry it, the File backend's `applyPatches` / `eraseWithEvidence` throws and its journal replay carry it (a replay refused on permissions inside the store names the remedy, not the rebuild path), a refused derived-index write is announced (it was silent), and the restores that used to be best-effort — a read-only layer file, a missing write-home file, a CAS loser's projection, a projection write-back, a debt seat repair — each add a report warning instead of skipping silently.
111
+ - **Upgrading from core ≤ 7.30.x:** a store those versions wrote may still carry the read-only bits they set; run `chmod -R u+w <memoryDir>` once — **after the last ≤ 7.30.x session on that store has ended**: a 7.30.x session re-sets the bits on every mount and 7.31 never clears them, so a store shared by both versions keeps refusing its 7.31 writers until the old sessions are gone (KNOWN-LIMITS KL-1076-a).
112
+ - A fresh memory directory is created 0o700 (`ensureDirExists`), as the other engine directories are; it is no longer re-set to 0o755 on every mount.
113
+ - **Directory modes.** With no mode writes left, a memory dir the engine CREATES keeps its creation mode (`0o700`, `ensureDirExists`) instead of being re-set to `0o755` on every mount; a deployment whose sandbox traverses the memory dir under another uid sets the mode it needs on its own volume (host-FS mechanics belong to the deployment). @server
114
+
115
+ ### Bytes that appear in a read-only memory layer during a session are never adopted — the session-observation table (#1076b, #1080; behaviour NARROWING; +0 exports; +2 optional `MemorySessionHandle` keys (`layerBaseline`, `accountLeg`); +0 wire keys; +1 incident code `memory.observation_settlement_unavailable`; @server @cli @test)
116
+
117
+ - **What was wrong (after #1076).** With no permission bits, a shell write into an inherited layer lands on disk. A session that admits nothing reported it but did not restore it, and the next unrestricted session's read-side sync adopted the bytes as an out-of-session edit — into the committed account, served to every later session. A new id-bearing file written into an inherited layer was adopted the same way (by the writing session's own harvest too), and a new id-less note there was committed by the next harvest of a session that writes that layer, as its own.
118
+ - **One table, every door.** A session's harvest compares each layer it holds read-only with a census taken when it materialized; every file whose bytes changed in that window and that no commit backs is recorded in the control plane (`layer-observations.json`: entry id, the committed rev at observation and the observed content's rev — or, for an id-less note, its layer, path and bytes' digest — where, which session). Every door through which uncommitted disk state enters the committed account consults it — the adopting read's edit, new-file and move arms (an id the table names is judged against the current account, never a stale cache), the lock-free retrieval face (which withholds such an entry while the account cannot be read), and a writer's harvest. A recorded divergence is not adopted: the committed copy is served where it stands at the entry's committed projection (otherwise nothing is), the file is left as found, and the adopting read tells every session that meets it (`change NOT adopted: "<path>" was written while a session held its layer read-only — …` / `file NOT adopted: …`). A new file a session was not served (with or without an entry id) is its `readonly_layer` rejection.
119
+ - **Out-of-session edits are adopted as before.** A divergence nobody recorded — a git pull, a hand edit, a file dropped between sessions — takes the ordinary adopt-on-read path, announced as before. A commit that moves an entry's rev retires every record made against the old rev.
120
+ - **What moved with it.** (1) A zero-copy mount writes no projection over its own store, for any session (it did for unrestricted ones): a divergence the read did not adopt — a recorded one, or a concurrent writer's uncommitted edit — is no longer overwritten by another session's materialize. (2) A writer's harvest does not commit, as its own update, a committed entry's bytes that already stood when it materialized, nor, as its own add, a recorded file it was not served that already stood when it materialized (a file that appears during its own window is its write, whoever else recorded it); an id-bearing file a harvest leaves uncommitted with no baseline of its own stays out of the handle's diff baseline, so its next edit is judged as a new file (it used to be baselined and then read as an update of an entry that does not exist). (3) A writing session does not restore a read-only layer file while a session that writes that layer is live (#1080): the bytes may be that writer's uncommitted edit; it warns (`… was not restored: a session that writes this layer is open …`) instead. Session-account rows now carry the write scope and `terminatedAt` — the moment the session's leg ended: a harvest the caller names `"terminal"` (`MemoryEngine.harvest` `opts.phase`; the Runner's settle leg, which a committed park reaches too) returning or throwing, whatever it admitted, or the host valve (`resolveSessionAccount`); a resume re-opens it; absent `opts.phase`, the writer stays live (the engine does not guess). Each row carries the LEG that holds it (minted by the materialize that opened or re-opened it, carried on `MemorySessionHandle.accountLeg`): a harvest that finishes after a resume re-opened the row — a settle-leg harvest that outlived the teardown budget — neither closes nor ends the later leg, and says so on its report; a prepare that fails after the memory mount ends the leg it opened (`MemoryEngine.endSessionLeg`). Liveness is read off that, never off `closedAt`: every full harvest closes the account — the checkpoint harvest at a park's commit door too — so a run whose park failed to persist and carries on writing would otherwise count as gone. `closedAt` and its design/336 readings are unchanged. A row written before 7.31.0 (no write scope) reads as before: live while never closed. (4) A `readonly_layer` rejection for a divergence that already stood when the session started says so (and claims no restore — `restored` and the warnings say what happened). Whether a commit backs a changed read-only-layer file is judged only on a committed account read whole: while a transaction journal is pending (any writer mid-commit, or one that died there) the question is undecidable, and the change is recorded UNDECIDED — never adopted meanwhile, announced as `change NOT adopted yet … re-judged once a writing session can read the committed account`; the first session that may write settles every undecided row once its preflight has recovered the journal (a commit backing the bytes at their seat drops that one observation — the bytes are committed content; a readable committed face that does not back them pins the row to the rev it read, after which the ordinary rule applies (a commit that moves the rev retires it); a committed face the settlement cannot read keeps the row undecided. Each observation is its own row — a settlement touches only the evidence it judged). A zero-copy backend without the committed-snapshot face (`committedSnapshotOf` — e.g. a wrapper that does not pass it through) cannot answer the question at all: every such change is recorded undecided and never settles there, which the engine states once per engine at materialize (`memory.observation_settlement_unavailable` on `onIncident`) — a wrapper passes the face through to settle. A mount that throws after opening its account ends the leg it began before rethrowing. A scope registry the observation cannot read is said on the report and leaves the refusal to the preflight (`sidecar_corrupt`, as in 7.30.x) instead of throwing out of the harvest. (5) A write home the file system refuses to write (the bits an older engine left) is announced once per materialize, with the remedy when the refusal is the permission bits (EACCES / EPERM) and as itself otherwise, before the first write fails. (6) The two harvest warnings for a plane file that could not be written back (a projection write-back, a lost CAS) no longer say the next materialize re-projects it where the plane is the store itself — there the next read adopts it as an out-of-session edit.
121
+ - **Who is hurt.** A legitimate write that lands inside a session's window in a layer that session holds read-only (a git pull during the session) is recorded like any other: an edited committed entry is not adopted until a commit moves its rev, a new file until its bytes change or it is removed (KNOWN-LIMITS KL-1076b-a). A host that relied on a zero-copy mount rewriting a hand-edited entry file into canonical bytes no longer gets that rewrite (content and rev are unchanged).
122
+ - **Compensation.** The refusal is announced on every adopting read (`NOT adopted`, naming the file), so an operator sees exactly what waits; memory keeps serving the committed face. Bytes recorded in a layer a **write** session holds read-only are rolled back on disk by that session's harvest (KL-1076b-a), and re-landing the same bytes stays unadopted — the ways in are a commit from a writer session of that layer, or a change that moves the entry's rev, which retires the record on its own.
123
+ - **Residuals** (KNOWN-LIMITS KL-1076b-b – KL-1076b-o): the record lands at the observing session's harvest, so an adopting read in between, or a session that dies before its harvest, leaves the earlier behaviour; writer presence is what the session account says — a row whose leg has not ended (`terminatedAt` absent) is a live writer.
124
+ - **Pickup for a host that wraps the file backend (@server).** The engine reaches three faces of the file backend by presence, not by interface: `committedSnapshotOf` (whether a commit backs an observed change — absent, every read-only-layer change is recorded undecided, none settles, and the engine states `memory.observation_settlement_unavailable` once through `onIncident`), `directoryRoot` (the control dir it pins) and `checkControlPlane`. A hand-built decorator (a zero-copy wrapper) must forward all three, or it silently loses what each carries.
125
+
126
+ ### A stop ends the leg: no model turn after the durable denial-limit stop, or after a cancel that lands before the prompt (#1074; behaviour NARROWING; +0 exports; +0 wire keys; @server @cli @test)
127
+
128
+ - **What was wrong.** Since 7.27.0 the durable station of the classifier denial-limit stop runs inside the resume leg's decision application, BEFORE the leg's continuation prompt. It minted the notice ("The run was stopped"), set the typed terminal and fired the run's abort — and then the leg sent its continuation anyway. `harness.prompt` starts the loop on a controller of its own, so the fired abort never reached it: the model was called turn after turn, every tool call it made was refused with `policy check aborted (task timed out or cancelled)`, and the loop ran until the model gave up (a model that always wants the tool: 41 calls and 80 tool frames after the notice) or a declared limit tripped (`limits.maxTurns: 10` ⇒ 10 calls, and a terminal whose `code` was `limits.max_turns_exceeded` beside the stop's sentence). The same held for any abort that fired between a leg's entry and its prompt: a cancel landing while the approved call ran on a resume (40 calls afterwards), while a parked steer was screened (40), or while the objective's image was fetched (41).
129
+ - **One rule, read at the door.** A leg whose abort has fired issues no prompt — the reading the leg's entry already made of an abort that fired before it began, now made at each leg's prompt door (`run-leg.ts`), after everything that can fire it. The harness closes the last gap behind the door: `AgentHarness.prompt` mints its run controller before its own startup awaits (the held-write flush, the turn state, the before-start hook) instead of after them, so a cancel landing while the prompt starts up reaches the loop (it used to be lost the same way: 41 calls after a cancel on the objective's `human_input` frame), and a run whose abort fired before the first turn ends `run aborted` instead of `invalid_state` / "prompt completed without an assistant message". The stop and the cancel are one abort and end the leg the same way; no second stop mechanism. After the stop: 0 model calls, 0 tool frames, one `done` carrying `classifier.denial_limit` and the notice's sentence, whatever limits are declared. A cancel before the prompt ends `failed` / `run aborted` with no model call.
130
+ - **What the closed door leaves.** A continuation that was not sent stays owed: a steer parked on the consumed row is announced (`task.user_steer_undrained`) instead of riding a continuation into the stopped leg. A review gate's decision is delivered by the continuation, so a closed door leaves it undelivered and its row reopens, as the walltime-exhausted arm does. The objective leg publishes no `human_input` "applied" account for an objective it never sent. The suspended batch's unanswered siblings are closed by the interrupt reconcile ("never executed") instead of being run through the gate and refused.
131
+ - **Who is hurt.** A host or test that counted the post-stop turns, or read a `limits.*` code off a stopped durable leg, now sees none. A person's refusal of a parked fallback still stops nothing, and an abort that lands inside a running loop (cancel, interrupt, walltime, turn limit) ends it exactly as before.
132
+ - **Compensation.** The stopped leg's terminal carries the notice's own code and sentence (`classifier.denial_limit`), a steer parked on the consumed row is announced as undelivered (`task.user_steer_undrained`), and a review decision the closed door did not deliver reopens its row — nothing the stopped leg owed is dropped silently.
133
+
134
+ ### A run can turn its own file history off: `TaskSpec.fileHistory: "disabled"` (#1048; minor; +1 TaskSpec word, +1 manifest key, +1 rewind code; +0 exports; @server @cli)
135
+
136
+ - **`TaskSpec.fileHistory?: "disabled"`** — the request-level twin of `RunnerDeps.fileHistoryStore: "disabled"`,
137
+ spelled like `TaskSpec.checkpointStore`'s off switch. A run that sets it makes no history-store call at all:
138
+ no first-touch record before an Edit/Write/NotebookEdit (the edit itself proceeds), no turn boundary, no fork
139
+ carry, and no "file history is not live" advisory (the request decided; nothing is missing, and the Runner's
140
+ once-per-arm memo is left for the runs that did not). Omitted ⇒ the deployment's seat decides, byte-for-byte as
141
+ before. The two layers are read at their own doors and folded into one projection; either word turns the run's
142
+ machinery off.
143
+ - **`rewind.capture_disabled`** (the `rewind.*` prepare-throw family +1). A `restoreFiles` / `rewindFilesTo`
144
+ request on a run that set the word refuses with this code, even when the store is wired and holds the boundary.
145
+ It is not `rewind.store_unconfigured`, which keeps meaning "the deployment wired no store" (including a
146
+ deployment `"disabled"`). When both layers are off, the request's code wins.
147
+ - **`wiring_manifest.fileHistory: "on" | "disabled"`** (effective half, present on every engine-minted leg). A leg
148
+ fact: `"on"` = this leg records; `"disabled"` = it does not, whichever layer or missing half stopped it (the
149
+ request word, a deployment `"disabled"`, no store, no filesystem env). Absence only means an older engine or an
150
+ external derivation. Not part of `configFingerprint`, so no existing fingerprint moves.
151
+ - **Inherited.** The word rides the parent spec seat (`ParentSpecSeatFields` / `ParentSpecSeat` +`fileHistory`,
152
+ fill law): the Task lane's sync / background / fork children, the observer sidecar, workflow agents and a durable
153
+ revival all get it. A retained child woken by a run that carries the word (the `createSubagentResume` fold, and
154
+ SendMessage reading the sender's seat) takes it for that cycle. No word turns capture back on below a parent that
155
+ turned it off (`KL-1048a`).
156
+ - **Bad values refuse.** `"off"`, `true`, `""`, `null`, `"Disabled"` and every other value but `"disabled"` refuse
157
+ prepare with `rewind.invalid_spec` before any store call.
158
+ - **Who is hurt:** a consumer that pins the `rewind.*` code set, or pins a whole effective manifest (every leg now
159
+ carries `fileHistory`). A caller that sets nothing sees no change except that manifest
160
+ key. **Order:** core first; a pre-7.31 core ignores the unknown spec key and records, so a host must not
161
+ advertise a per-session switch before it runs this core. Pickup: `docs/wire-consumption/unreleased-1048.md`;
162
+ residuals `KL-1048a` / `KL-1048b` / `KL-1048c`. Pins: `test/backlog1048-run-file-history-off.test.ts` (the five cells and
163
+ their control, bad values, the layer matrix, the memo, the manifest cell, the seat, a delegated child's write,
164
+ the retained wake from both sides, and the package-root `FileStorageBackend` + `Runner` path).
165
+ ### The system-prefix date is frozen per session, not per leg (#1045; prompt-face change; +0 exports; +0 wire keys; @server @test)
166
+
167
+ - **What changed.** The environment block's `Today's date:` line was rendered at every leg's prepare. A host that
168
+ starts one leg per user message therefore re-rendered the prefix on the first message of each new day, and the
169
+ provider prompt cache missed from the environment block onward (a host measured 0.3–3.5 s on that message).
170
+ The date is now anchored per session: a session's first leg renders today's date and records it, and every
171
+ later leg of that session renders the recorded date. The system prompt stays byte-identical across days.
172
+ - **How the model learns the date.** The existing `date_change` frame ("The date has changed. Today's date is now
173
+ …") now also rides a leg's **first request** when today differs from the last date the model was told. Before,
174
+ it rode only a boundary after a tool call. A text-only answer on a new day still gets the date. Once a
175
+ date is told it is recorded, and no later boundary or leg repeats it.
176
+ `steering_injected{source:"date_change"}` echoes the first-request frame the same way it echoes the boundary one.
177
+ Both positions record the date as told only when the message carrying the frame reaches the transcript (the
178
+ listings' receipt rule). A boundary steer that never drains, for example on a run cancelled before its next
179
+ request, leaves the date unannounced, and the next leg announces it.
180
+ - **Re-anchoring happens at two points only:** a new session, and a landed compaction (boundary,
181
+ prompt-too-long recovery, manual `/compact` and end-of-task alike). The leg after a compaction renders
182
+ today's date. There is no switch. `clientContext.timeZone` still decides the zone.
183
+ - **Where the anchor lives.** One engine-owned `custom` session entry (`customType: "engine:date-anchor"`,
184
+ `data: { prefixDate, announcedDate, segment }`), appended as the leg settles (after its `done` frame, before
185
+ `result()` settles) only when the anchor is new or the announced date moved. That is at most once per new session, per compaction segment and per day change. It
186
+ survives a process restart with a durable session repo (pinned with `FileStorageBackend`). The model never
187
+ sees it, and `SessionTreeEntry` does not change (`custom` already exists).
188
+ - **Who is hurt.** A consumer or test that pins "a new leg of an existing session renders today's date" must
189
+ re-pin. That leg keeps the session's date, and the new date arrives as the `date_change` frame at the head of
190
+ the first user message. A consumer that counts or orders raw session entries sees the extra `custom` entry
191
+ in the cases above. It sits between two turns: the next user message's `parentId` can be the anchor rather
192
+ than the previous assistant reply, so `resumeAt` with `resumeAtMode: "before"` on such a message leaves the
193
+ branch leaf on the anchor. The replayed context is unchanged. `docs/wire-consumption/unreleased-1045.md` lists the observable readings.
194
+ - **Not covered.** `docs/KNOWN-LIMITS.md` KL-1045a – KL-1045d (KL-1045d: the anchor write now lands after `done`, so a process that exits between the two leaves the leg without an anchor — a re-render or a repeated reminder next leg, never a wrong date).
195
+
196
+ ### A deployment can stop recording file history and still restore what it holds: `RunnerDeps.fileHistoryCapture: "off"` (#1050; minor; +1 RunnerDeps word, +1 config code; two refusal texts reworded, codes unchanged; "before" restore NARROWING; +0 exports; @server @cli)
197
+
198
+ - **`RunnerDeps.fileHistoryCapture?: "off"`** — the deployment's capture switch, apart from the store seat. With a
199
+ store wired and `"off"`, no run records: no first-touch record before an Edit/Write/NotebookEdit (the edit
200
+ proceeds), no turn boundary, no fork carry, no "file history is not live" advisory, and the leg's
201
+ `wiring_manifest.fileHistory` reads `"disabled"`. The store stays the restore seat: `restoreFiles` /
202
+ `rewindFilesTo` converge to boundaries already in it, as before. Before, the only way to stop capture was to
203
+ withhold the store, which made every earlier boundary unrestorable (`rewind.store_unconfigured`);
204
+ `fileHistoryStore: "disabled"` declares no store at all and still refuses restores. Omitted ⇒ unchanged.
205
+ - **One fold, two seats.** The phase folds the three switches once: either `"disabled"` (deployment store seat or
206
+ `TaskSpec.fileHistory`) still means "no store for this run"; `"off"` drops only the recording seat. When the
207
+ request's `TaskSpec.fileHistory: "disabled"` is also set, a restore still refuses `rewind.capture_disabled`
208
+ (the request layer wins, as before).
209
+ - **Bad values refuse.** `"on"`, `false`, `""`, `null`, `"Off"` and every other value but `"off"` refuse prepare
210
+ with the new `config.invalid_file_history_capture`, before any store call. So does `"off"` with no real store
211
+ beside it (key omitted, or `fileHistoryStore: "disabled"`): there is nothing to switch off, and the sentence points
212
+ at `"disabled"` for dropping history entirely. A host must replace `"disabled"` with the store plus `"off"`, not
213
+ stack `"off"` on top of it.
214
+ - **`rewind.store_unconfigured` names its arm.** Same code; the message now differs by arm: an omitted store key
215
+ keeps the old sentence ("… this deployment wired no RunnerDeps.fileHistoryStore — no file history was ever
216
+ recorded …"), a `"disabled"` seat says `… this deployment declared file history disabled
217
+ (RunnerDeps.fileHistoryStore: "disabled"), so there is no history to restore …`. `"off"` beside a store does not
218
+ refuse. Closes `KL-1047a`.
219
+ - **`rewind_snapshot.unresolvable` names what makes a boundary.** Same code; the second sentence said boundaries
220
+ exist only for turns that "ran with a RunnerDeps.fileHistoryStore wired", which a host with a store wired and
221
+ capture off (or a run-level `"disabled"`) was told falsely. Both arms now say only turns that recorded history
222
+ (store wired, capture on, the run's file history not disabled) have one; the whole-tree-era clause is unchanged.
223
+ The retired `rewindFiles` texts name the capture switch among what turns tracking off.
224
+ - **A `"before"` restore no longer substitutes an earlier boundary; a target without its own boundary refuses**
225
+ (behaviour NARROWING). `resumeAt` + `resumeAtMode: "before"` + `restoreFiles` converges to the target entry's own
226
+ boundary ("file state when that turn began") or refuses `rewind_snapshot.unresolvable` with the files untouched
227
+ (message: `entry "<id>" has no file-history boundary … a before-restore needs the target's own boundary and never
228
+ crosses into an earlier turn`). Before, a target with no boundary fell back to the nearest boundary-carrying
229
+ ancestor, which also reverted edits the kept conversation still shows: a whole earlier turn when the target's turn
230
+ recorded nothing (capture `"off"`, a run-level `"disabled"`, a mixed-epoch session), the turn's pre-steer edits
231
+ when the target was a steer / follow-up entry. The session holds no turn structure to bound such a walk, so it is
232
+ removed. `rewindFilesTo` and `"at"` mode never walked and are unchanged.
233
+ - **Who is hurt:** a consumer that matched the `store_unconfigured` or `rewind_snapshot.unresolvable` message text
234
+ (switch on the code instead); a deployment that writes `"off"` with no store (now refused at prepare); a consumer
235
+ that pins the `config.*` code set; a caller that sends `resumeAtMode: "before"` + `restoreFiles` at a steer /
236
+ follow-up entry or at a turn that recorded nothing (now refused instead of an over-reaching restore). **Order:** core first; a pre-7.31 core ignores the unknown deps key and records, so a host must not write `"off"` (and keep the store) before it
237
+ runs this core. Pickup: `docs/wire-consumption/unreleased-1050.md`; residual `KL-1050a`. Pins:
238
+ `test/backlog1050-file-history-capture-off.test.ts` (the five cells and their control, both refusal sentences,
239
+ bad values and the no-store contradiction, the layer matrix, the memo, the coordinates, the fork carry, a
240
+ delegated child's write, the unrecorded-turn sentences on both arms, the retired `rewindFiles` texts, the
241
+ "before" restore over unrecorded turns (both layers) and at a steer entry, and the package-root
242
+ `FileStorageBackend` + `Runner` path).
243
+
244
+ ## 7.30.1 — 2026-09-24
245
+
246
+ ### An unquoted extended-glob opener makes a shell text unestablishable (#1064 ⑨; patch; read boundary NARROWING; +0 exports; +0 wire keys; @cli @test)
247
+
248
+ - **Compensation:** the refusal names the target and says a before-restore needs the target's own boundary; `resumeAtMode: "at"` and `rewindFilesTo` still restore exactly what the store holds; a target that has its own boundary behaves as before.
249
+ - **What was wrong.** After `shopt -s extglob`, bash reads `pack@(age.json)` as one pattern and `<<@(x)` as the here-document delimiter `@(x)`; the lexer reads a split word (`pack@`, `age.json`) and the delimiter `@`. The read station judged the split words, found nothing, and vouched: `shopt -s extglob` on one line and `head -c 64 < pack@(age.json)` on the next read a deny-row file at zero cards on the real lane (v7.29.0 and v7.30.0 alike). The only extglob reading lived inside the HOME-rebinding check.
250
+ - **One rule.** An unquoted `?(` `*(` `+(` `@(` `!(` in an argv word, a redirection target or a here-document delimiter joins the plan's `unreadable` floor (`unreadableSentenceOf`, beside the unresolved-word sentence): the whole text is unestablishable — mandated on `bash_off` and `bash_classify`, refused on the read-only face, denied headless — and a deny hit anywhere in it still refuses. A quoted opener, a comment and a here-document body are the text they always were; an `$((…))` arithmetic body is never globbed and keeps its reading.
251
+ - **Review round (before release).** Two bypasses of the first cut are closed: an executable substitution whose text equals an arithmetic body (the exemption is granted only to a body that is one parenthesised group spanning the whole body — `$((cat x); :)` is a command substitution — and only where every substitution spelled so is such a body), and a here-document delimiter quoted in part (`<<'E'@(X)` — a delimiter quoted anywhere counts).
252
+ - **Pre-release batch 2.** The arithmetic test reads the lexer's structure (a quoted, escaped or commented parenthesis is text: `$((echo '('; …); (echo ')'))` is a command substitution and keeps the floor), and a here-document body keeps each substitution's kind (`cat <<EOF\n$((2*(3+4)))\nEOF` stays vouched on every seat, as on 7.30.0).
253
+ - **Who is hurt.** A text with an unquoted opener costs a card even when extglob is off (`ls !(x)` — a syntax error there — and the arithmetic command `(( 2*(3+4) ))`, whose `2*(` reads as an opener), and so do pattern positions bash never globs: `[[ $v =~ ^[0-9]+(\.[0-9]+)?$ ]]`, `[[ $f == *.@(js|ts) ]]`, `case $x in @(a|b))` (both full-shell seats ask, the read-only face refuses; ≈0 in the corpus). KL-994k.
254
+
255
+ ### The declared-home deny floor is established as a read — pattern, tree, canonical target (#1064 ⑬; patch; read boundary NARROWING; `ReadCandidate.denyOnly?` added (optional); +0 exports; +0 wire keys; @cli @test)
256
+
257
+ - **What was wrong.** 7.30.0 gave an unplaced home word (a text that may rebind `HOME`, a home word inside a value) its declared-home spelling as one more LITERAL spelling for the deny judge. A pattern (`~/k*.txt`), a traversal (`grep -r KEY ~`) or a symlink under the home never matched it, so those asked — and, approved, read the deny row — where v7.29.0 refused them.
258
+ - **One rule.** Each such word's `floorSpelled` becomes a SHADOW candidate (`denyFloorShadowsOf`, `shell-read-judge.ts`) established exactly as a read is — canonical target, a pattern's matches, a traversal root's tree — and judged for deny rows only (`ReadCandidate.denyOnly`): it never adds a containment finding and never vouches, so a read that is not a deny row still asks through its own unplaced candidate. The literal spelling the unresolvable arm used to carry is gone (one mechanism, not two).
259
+ - **Also on the floor.** A bare `cd` / `cd --` puts the declared home on the deny floor (`cd && cat <deny row under home>` refuses); a `[k]=` compound element is read as the value bash assigns, so its `~` expands and its `$HOME` is a home word (`A=([0]="$HOME/<deny>")`, `A=([0]=~/<deny>)` refuse).
260
+ - **Review round (before release).** A shadow is never an identity in the pre-spawn re-check (a real read retargeted onto a shadow's target is still refused), and a `[k]=` element is re-read as an assignment VALUE (a leading `#` stays literal — `A=([0]=#dir/".env")` refuses as on 7.30.0; a quoted `"[k]=…"` element keeps its tail's spelling too, as on 7.30.0).
261
+ - **Pre-release batch 2.** A `[k]=` element keeps its word's own reading (its folded tail, globbed as the word is) as a deny-only candidate beside the assignment value: an unquoted `${A[k]}` expands a stored `pack*`, and `A=([0]="pack*"); head ${A[0]}` refuses on disk as on 7.30.0.
262
+ - **Admission.** A deny-only read (a shadow, an element word's own reading) is its own identity class in the pre-spawn re-check: a real read retargeted onto a shadow's target is still caught, and a deny-only read retargeted onto a deny row between approval and spawn is re-judged and refused (`read_path_denied`), as a real read is.
263
+ - **Pre-release batch 4.** Candidate de-duplication keys on `denyOnly` too: a deny-only read never absorbs an ordinary read of the same spelling (a real match retargeted after judgment is refused at admission in either assignment order, as on 7.30.0); and the arithmetic test follows its group across the segments the lexer cuts at `&&` `||` `&` `|` and line breaks (`$((2*(3+4) && 1))`, a multi-line body stay vouched).
264
+ - **Pre-release batch 5.** The pre-spawn identity comparison is PER CANDIDATE (`shell-read-identity.ts`): each candidate's established targets are compared with its own re-establishment, keyed by where it stands in the plan and what it is. A pooled set let another word's pattern (`A=([0]="pair*") B=pair*; head linkx`) or a home shadow already reaching the new target hide a retargeted read, which was admitted; it is refused now (the `pair*` form was admitted on 7.30.0 too).
265
+ - **Consumer face.** `judgeShellRead().establishment` now lists the shadow reads too (`candidate.denyOnly: true`); a consumer that counts established reads sees them.
266
+ - **Not covered** (KL-994l): an option payload's home word (`--file="$HOME/…"`), `pushd ~`, parameter modifiers (`${HOME:-…}`), and a `~` assignment value no program opens (`A=~/<deny>; head "$A"` still asks — the #1004 ruling keeps `A=~/.env; true` vouched).
267
+
268
+ ### A language server's child process gets the tool child's environment scrub (#1073; patch; env face NARROWING; `NodeLspManagerOptions.env?` added (optional); +0 exports; @server)
269
+
270
+ - `defaultLspSpawn` passed no `env` to `child_process.spawn`, so a language server inherited the whole host `process.env` — credentials included — while a tool's child process got `scrubSecretEnv`. The default spawn now passes the same scrub (the one function, applied at each spawn). A host that wants another environment passes `NodeLspManagerOptions.env`, used verbatim; a host-supplied `spawn` owns its child's environment as before. The LSP scrub has no findings collector: what it removed is not reported (the tool child's `onScrub` channel has no LSP twin). **Who is hurt:** a language server that read a credential-shaped variable from the host environment no longer sees it; pass it through `env`.
271
+
272
+ ### Plural credential names are scrubbed from a spawned environment (#1072; patch; env face NARROWING; +0 exports; @server @web-admin)
273
+
274
+ - `SECRET_ENV_RE` reads `KEYS`, `TOKENS` and `PASSWORDS` as it reads their singulars (`SECRETS` / `CREDENTIALS` already were): `SERVICE_AUTH_TOKENS`, `API_KEYS`, `DB_PASSWORDS` no longer reach a child shell. The exact-name exemptions (`PARTITION_KEY`, …) still win.
275
+ - **A token COUNT is not a credential.** `TOKENS` is also a count word: right after `MAX` / `OUTPUT` / `INPUT` / `THINKING` / `NUM` / `TOTAL` (`TOKEN_COUNT_WORDS`, the reverse ledger in the same place) it is the count and is kept — `MODEL_MAX_TOKENS`, `MAX_THINKING_TOKENS`, `CLAUDE_CODE_MAX_OUTPUT_TOKENS`, `MAX_TOKENS` still reach an agent CLI run in the child shell. The singular `TOKEN` keeps its 7.30.0 reading.
276
+
3
277
  ## 7.30.0 — 2026-09-24
4
278
  ### A resumed leg with no shell is no longer told to run `git status` (#1046; prompt-face NARROWING; +0 exports; +0 wire keys; @server @test)
5
279
 
@@ -3,9 +3,8 @@ import { type PermissionModeClass } from "./cross-session-envelope.js";
3
3
  /**
4
4
  * The explicit values of `crossSessionInbound`, loosest first: `wake` (deliver, and the HOST may start
5
5
  * a turn on this session when its box grows), `next-turn` (deliver at the session's next turn
6
- * boundary — the value the pre-7.30 spelling `accept` named), `hold` (park for a human), `refuse`.
7
- * The UI's spelling `"default"` means UNSET (mode parity) and is folded to `undefined` by the resolver
8
- * — it is not a distinct policy.
6
+ * boundary), `hold` (park for a human), `refuse`. The UI's spelling `"default"` means UNSET (mode
7
+ * parity) and is folded to `undefined` by the resolver — it is not a distinct policy.
9
8
  *
10
9
  * @contract cross_session.wake_is_the_hosts_leg — `wake` is judged exactly as `next-turn` at the drain
11
10
  * (deliver); what it adds is the HOST's leg: a host that observes a `peer_message` or `engine_frame`
@@ -19,16 +18,13 @@ import { type PermissionModeClass } from "./cross-session-envelope.js";
19
18
  export type CrossSessionInboundSetting = "wake" | "next-turn" | "hold" | "refuse";
20
19
  export declare const CROSS_SESSION_INBOUND_SETTINGS: readonly CrossSessionInboundSetting[];
21
20
  export declare function isCrossSessionInboundSetting(value: unknown): value is CrossSessionInboundSetting;
22
- /** The pre-7.30 spelling and the word it now means. A layer carrying the old spelling is READ as the
23
- * new word (never held, never invalid) and reported in `deprecatedLayers` so a host can say so once. */
24
- export declare const CROSS_SESSION_INBOUND_DEPRECATED_SPELLINGS: Readonly<Record<string, CrossSessionInboundSetting>>;
25
21
  /** Which layer decided the effective value. `"invalid"` = an unrecognized value was found in some
26
22
  * layer and the fail-closed rule forced `hold` (the loud arm). */
27
23
  export type CrossSessionSettingSource = "managed" | "user" | "repo" | "invalid";
28
24
  export interface CrossSessionInboundSettingLayers {
29
25
  /** Organization-managed layer (CC `policySettings`): beats the user layer when set. It is NOT
30
26
  * immune to repository TIGHTENING (CC form: the repo loop runs over whatever the first two layers
31
- * resolved to) — a repo may make a managed `accept` stricter, never looser. */
27
+ * resolved to) — a repo may make a managed `next-turn` stricter, never looser. */
32
28
  managed?: unknown;
33
29
  /** The user's own layer (CC `userSettings`/`flagSettings`): consulted after managed. */
34
30
  user?: unknown;
@@ -46,17 +42,13 @@ export interface ResolvedCrossSessionInboundSetting {
46
42
  * ⇒ the resolver applied the fail-closed rule (see `decidedBy: "invalid"`) unless the effective
47
43
  * value was already at least as tight as `hold`. Reported so a host can warn — never swallowed. */
48
44
  invalidLayers: Array<"managed" | "user" | "repo">;
49
- /** Layers that spelled a value the pre-7.30 way (`accept`): read as its current word, and reported
50
- * here so the drain announces the spelling ONCE per leg — the value in force is never silently
51
- * different from the value written. Absent from the object when empty. */
52
- deprecatedLayers?: Array<"managed" | "user" | "repo">;
53
45
  }
54
46
  /**
55
47
  * CC `I` transcribed: managed beats user; repo layers may only tighten the standing value — including a
56
48
  * managed one (tightening is the safe direction; the loosening direction is what policy forbids) — and
57
49
  * take attribution on a tie unless managed decided; an unrecognized value anywhere forces `hold` when
58
50
  * the standing value is looser than `hold` (bad-value loudness: a garbage setting never silently reads
59
- * as `accept`).
51
+ * as `next-turn`).
60
52
  */
61
53
  export declare function resolveCrossSessionInboundSetting(layers: CrossSessionInboundSettingLayers): ResolvedCrossSessionInboundSetting;
62
54
  /**
@@ -3,7 +3,6 @@ export const CROSS_SESSION_INBOUND_SETTINGS = Object.freeze(["wake", "next-turn"
3
3
  export function isCrossSessionInboundSetting(value) {
4
4
  return typeof value === "string" && CROSS_SESSION_INBOUND_SETTINGS.includes(value);
5
5
  }
6
- export const CROSS_SESSION_INBOUND_DEPRECATED_SPELLINGS = Object.freeze({ accept: "next-turn" });
7
6
  const RANK = { wake: 0, "next-turn": 1, hold: 2, refuse: 3 };
8
7
  const UNSET_BASELINE = "next-turn";
9
8
  function readLayer(v) {
@@ -11,38 +10,32 @@ function readLayer(v) {
11
10
  return { value: undefined };
12
11
  if (isCrossSessionInboundSetting(v))
13
12
  return { value: v };
14
- const migrated = typeof v === "string" && Object.hasOwn(CROSS_SESSION_INBOUND_DEPRECATED_SPELLINGS, v) ? CROSS_SESSION_INBOUND_DEPRECATED_SPELLINGS[v] : undefined;
15
- return migrated !== undefined ? { value: migrated, deprecated: true } : { value: "invalid" };
13
+ return { value: "invalid" };
16
14
  }
17
15
  export function resolveCrossSessionInboundSetting(layers) {
18
16
  const invalidLayers = [];
19
- const deprecatedLayers = [];
20
17
  let value;
21
18
  let decidedBy;
22
19
  for (const [name, raw] of [
23
20
  ["managed", layers.managed],
24
21
  ["user", layers.user],
25
22
  ]) {
26
- const { value: v, deprecated } = readLayer(raw);
23
+ const { value: v } = readLayer(raw);
27
24
  if (v === "invalid") {
28
25
  invalidLayers.push(name);
29
26
  continue;
30
27
  }
31
- if (deprecated === true)
32
- deprecatedLayers.push(name);
33
28
  if (v !== undefined && value === undefined) {
34
29
  value = v;
35
30
  decidedBy = name;
36
31
  }
37
32
  }
38
33
  for (const raw of layers.repo ?? []) {
39
- const { value: v, deprecated } = readLayer(raw);
34
+ const { value: v } = readLayer(raw);
40
35
  if (v === "invalid") {
41
36
  invalidLayers.push("repo");
42
37
  continue;
43
38
  }
44
- if (deprecated === true)
45
- deprecatedLayers.push("repo");
46
39
  if (v === undefined)
47
40
  continue;
48
41
  if (RANK[v] > RANK[value ?? UNSET_BASELINE]) {
@@ -57,7 +50,7 @@ export function resolveCrossSessionInboundSetting(layers) {
57
50
  value = "hold";
58
51
  decidedBy = "invalid";
59
52
  }
60
- return { ...(value !== undefined ? { value } : {}), ...(decidedBy !== undefined ? { decidedBy } : {}), invalidLayers, ...(deprecatedLayers.length > 0 ? { deprecatedLayers } : {}) };
53
+ return { ...(value !== undefined ? { value } : {}), ...(decidedBy !== undefined ? { decidedBy } : {}), invalidLayers };
61
54
  }
62
55
  export const CROSS_SESSION_HOLD_CAUSES = Object.freeze([
63
56
  "mode-mismatch",
@@ -65,7 +65,6 @@ export function createPeerSessionDrain(opts) {
65
65
  const disclosedHeld = new Set();
66
66
  let inFlight = Promise.resolve();
67
67
  let noticeRound = 0;
68
- let deprecatedSpellingAnnounced = false;
69
68
  let currentRound;
70
69
  const held = peerHeldQueueFor(scope, sessionId, opts.clock !== undefined ? { clock: opts.clock } : {});
71
70
  const idleMachine = peerIdleMachineFor(scope, sessionId, opts.clock !== undefined ? { clock: opts.clock } : {});
@@ -86,10 +85,6 @@ export function createPeerSessionDrain(opts) {
86
85
  });
87
86
  const readFacts = () => {
88
87
  const setting = resolveCrossSessionInboundSetting(opts.settingLayers() ?? {});
89
- if (setting.deprecatedLayers !== undefined && !deprecatedSpellingAnnounced) {
90
- deprecatedSpellingAnnounced = true;
91
- opts.onError?.(new Error(`crossSessionInbound is spelled "accept" in the ${setting.deprecatedLayers.join(", ")} layer(s); that spelling is read as "next-turn" (deliver at the next turn boundary) — update the settings file`), { phase: "degraded", sessionId, classification: "peer-inbound-setting-spelling" });
92
- }
93
88
  return { setting, selfModeClass: opts.selfModeClass() };
94
89
  };
95
90
  const judgeMeta = (facts, meta) => judgeCrossSessionInbound({ setting: facts.setting, selfModeClass: facts.selfModeClass, sender: { ...(meta?.fromMode !== undefined ? { fromMode: meta.fromMode } : {}), selfSent: meta?.fromSession !== undefined && meta.fromSession.toLowerCase() === sessionId.toLowerCase() } });
@@ -1036,6 +1036,7 @@ export function createSendMessageTool(opts) {
1036
1036
  ...(ctx.oneShot === true ? { oneShot: true } : {}),
1037
1037
  ...(ctx.memoryPersistenceCapable === false ? { memoryPersistenceCapable: false } : {}),
1038
1038
  ...(ctx.parentSpecSeat?.memoryOff === true ? { memoryOff: true } : {}),
1039
+ ...(ctx.parentSpecSeat?.fileHistory === "disabled" ? { fileHistory: "disabled" } : {}),
1039
1040
  },
1040
1041
  ...(ctx.autoModeReview !== undefined ? { currentAutoModeReview: ctx.autoModeReview } : {}),
1041
1042
  });
@@ -460,6 +460,7 @@ export declare function createSubagentResume(deps: {
460
460
  oneShot?: true;
461
461
  memoryPersistenceCapable?: false;
462
462
  memoryOff?: true;
463
+ fileHistory?: "disabled";
463
464
  readFaceRoots?: true;
464
465
  writeFaceRoots?: true;
465
466
  readDenyPatterns?: readonly import("../tools/fs/read-deny.js").ReadDenyEntry[];
@@ -575,6 +575,7 @@ export function createSubagentResume(deps) {
575
575
  ? { memoryPersistenceCapable: false }
576
576
  : {}),
577
577
  ...(deps.currentClamps?.memoryOff === true ? { memory: memoryTurnedOff(entry.specSnapshot.memory) } : {}),
578
+ ...(deps.currentClamps?.fileHistory === "disabled" ? { fileHistory: "disabled" } : {}),
578
579
  signal: abort.signal,
579
580
  };
580
581
  if (deps.registry !== undefined && deps.taskId !== undefined && deps.taskAccess !== undefined) {
@@ -233,6 +233,12 @@ export interface RuleClearanceFacts {
233
233
  export declare function askMandateMember(ask: RuleClearanceFacts | undefined): {
234
234
  mandated?: true;
235
235
  };
236
+ /** The clearance facts of an ask the engine did not construct (a policy's verdict), each member read ONCE into
237
+ * an engine-owned value — for a station that keeps the answer past its own side effects, where the verdict's
238
+ * accessors must not run again. The members a verdict may carry that narrow what may clear it. */
239
+ export declare function ownedClearanceFacts(d: Extract<PermissionResult, {
240
+ action: "ask";
241
+ }>): RuleClearanceFacts;
236
242
  /**
237
243
  * Which origins IMPLY the `requiresRealApproval` bit, and WHERE that implication is verified — the
238
244
  * consistency table {@link askOriginOf} reads. An ask of a must-carry origin exists so that no automatic
@@ -51,6 +51,16 @@ export function ruleClearanceOf(ask, marks) {
51
51
  export function askMandateMember(ask) {
52
52
  return ruleClearanceOf(ask, {}).mandated ? { mandated: true } : {};
53
53
  }
54
+ export function ownedClearanceFacts(d) {
55
+ const { mandated, requiresRealApproval, decisionReason, matchedAskRule, probeMandated } = d;
56
+ return {
57
+ ...(mandated === true ? { mandated } : {}),
58
+ ...(requiresRealApproval === true ? { requiresRealApproval } : {}),
59
+ ...(decisionReason !== undefined ? { decisionReason } : {}),
60
+ ...(typeof matchedAskRule === "string" ? { matchedAskRule } : {}),
61
+ ...(probeMandated === true ? { probeMandated } : {}),
62
+ };
63
+ }
54
64
  function originLadder(decision, facts) {
55
65
  if (facts.contentQuestion)
56
66
  return "content_question";
@@ -806,6 +806,16 @@ export type PendingAction = {
806
806
  * this"). Absent otherwise (never `false`). Display metadata: the resume belts keep reading the
807
807
  * gate's own bit. */
808
808
  requiresRealApproval?: true;
809
+ /** #1079 (additive, no checkpoint-version bump — the {@link requiresRealApproval} precedent) — the PARK
810
+ * twin of `AskRequest.mandated`: present (`true`) ⇔ no persisted allow rule and no standing answer may
811
+ * retire this parked question; absent otherwise (never `false`; a pre-#1079 row reads absent = unreported).
812
+ * Minted from the SAME facts object the row's rule-offer factory reads, through
813
+ * {@link import("./ask-origin.js").askMandateMember} (the live card's derivation). Three independent
814
+ * facts: {@link requiresRealApproval} (only a person — implies this one), this one (no rule), and
815
+ * {@link ruleOffersAbsence} (what the offer lane has to give: silent off its tool and wiring, `"shadowed"`
816
+ * when a person's rule also speaks — never a substitute for this one). Display metadata: no gate, CAS or
817
+ * resume path reads it. */
818
+ mandated?: true;
809
819
  /** The PARK twin of `AskRequest.denialLimitFallback`: present
810
820
  * ⇔ this parked ask is the classifier DENIAL-LIMIT fallback, carrying the counts that tripped
811
821
  * the bound. Its `autoDenyAfterMs` is ALWAYS `0` on a row: the window is a fact of the ask's
@@ -1933,6 +1943,10 @@ export interface CheckpointSummary {
1933
1943
  * {@link toolCallId} absence-shape contract); pure echo of the persisted bit — a pre-#557 row reads
1934
1944
  * absent, which is honest (unreported), never a claim that budget may clear it. */
1935
1945
  requiresRealApproval?: true;
1946
+ /** The row's `PendingAction.tool_approval.mandated` (#1079), for the same one-call reason: `true` or
1947
+ * OMITTED (the {@link toolCallId} absence-shape contract), screened to the literal `true` — a pre-#1079 row
1948
+ * reads absent (unreported). ECHO-ONLY; nothing gates on it. */
1949
+ mandated?: true;
1936
1950
  /** The row's `PendingAction.tool_approval.denialLimitFallback`, projected BESIDE the preview
1937
1951
  * for the same one-call reason: a card that shows the fallback's counts and limit must not re-open
1938
1952
  * the row to learn them (`autoDenyAfterMs` is `0` on every row — see the row member). Pure echo;
@@ -297,6 +297,7 @@ export function summarizeCheckpoint(cp) {
297
297
  ...(tool?.preview !== undefined ? { preview: tool.preview } : {}),
298
298
  ...(tool?.previewWithheld === "oversize" || tool?.previewWithheld === "unavailable" ? { previewWithheld: tool.previewWithheld } : {}),
299
299
  ...(tool?.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
300
+ ...(tool?.mandated === true ? { mandated: true } : {}),
300
301
  ...(() => {
301
302
  const fb = readDenialLimitFallback(tool?.denialLimitFallback);
302
303
  return fb !== undefined ? { denialLimitFallback: fb } : {};
@@ -277,6 +277,10 @@ export interface EngineNotice {
277
277
  * - `"config.execution_env_capability_invalid"` — a remote env DECLARES a boolean capability key
278
278
  * (`isolation`, `suspendable`, `canonicalPathAuthoritative`) with a non-boolean value; ABSENCE is a
279
279
  * supported reading of each and stays silent. Once per key per run; `detail: { key, valueType }`.
280
+ * - `"config.secret_env_scrubbed"` (#1081) — the D1 fail-closed `scrub` policy withheld one or more
281
+ * secret-shaped keys from a spawned child's environment (a tool's shell, or a language server).
282
+ * Once per PROCESS, shared by every scrub site ({@link import("./secret-env.js").announceSecretEnvScrub});
283
+ * `detail: { names }` — every withheld key's name, never a value.
280
284
  * - `"config.durable_gate_unavailable"` — a durable-approval MANDATE is in force on a leg with NO
281
285
  * checkpoint store: the per-principal `forceDurableGate` entitlement, or the task's own
282
286
  * `durableApproval` opt-in with no live approver seat (`durableMandateSourceOf`, the predicate the
@@ -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.context_settings_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.write_protection_unresolved", "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", "mcp.server_redialed", "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", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "route.response_model_mismatch", "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.context_settings_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.write_protection_unresolved", "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", "config.secret_env_scrubbed", "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", "mcp.server_redialed", "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", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "route.response_model_mismatch", "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. */