@sema-agent/core 7.53.0 → 7.54.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.
- package/CHANGELOG.md +277 -1
- package/LICENSE +3 -1
- package/dist/agents/peer-session-drain.d.ts +0 -1
- package/dist/agents/peer-session-drain.js +0 -2
- package/dist/agents/send-message-tool.js +3 -4
- package/dist/agents/subagent.d.ts +3 -5
- package/dist/agents/subagent.js +18 -11
- package/dist/agents/tool-filter.d.ts +7 -4
- package/dist/agents/tool-filter.js +7 -3
- package/dist/core/a2a.js +4 -2
- package/dist/core/allow-entry.d.ts +1 -0
- package/dist/core/allow-entry.js +1 -0
- package/dist/core/approval-object.d.ts +87 -0
- package/dist/core/approval-object.js +85 -0
- package/dist/core/arg-summary.d.ts +3 -2
- package/dist/core/arg-summary.js +17 -11
- package/dist/core/ask-origin.d.ts +43 -27
- package/dist/core/ask-origin.js +20 -11
- package/dist/core/ask-question.d.ts +0 -1
- package/dist/core/ask-question.js +4 -67
- package/dist/core/ask-unresolvable-notice.d.ts +3 -2
- package/dist/core/auto-compaction.d.ts +49 -50
- package/dist/core/auto-compaction.js +41 -51
- package/dist/core/auto-mode-prompt.d.ts +4 -2
- package/dist/core/auto-mode-prompt.js +4 -3
- package/dist/core/canonical-json.d.ts +69 -0
- package/dist/core/canonical-json.js +130 -8
- package/dist/core/checkpoint-requirements.d.ts +13 -7
- package/dist/core/checkpoint-requirements.js +4 -1
- package/dist/core/checkpoint-store.d.ts +33 -18
- package/dist/core/checkpoint-store.js +5 -4
- package/dist/core/child-clamp-seats.d.ts +45 -6
- package/dist/core/child-clamp-seats.js +56 -4
- package/dist/core/consolidate-scope.d.ts +1 -1
- package/dist/core/cross-boundary-taint.d.ts +213 -0
- package/dist/core/cross-boundary-taint.js +196 -0
- package/dist/core/decision-receipt.d.ts +44 -1
- package/dist/core/decision-receipt.js +16 -0
- package/dist/core/effective-path-target.d.ts +3 -0
- package/dist/core/effective-path-target.js +4 -0
- package/dist/core/engine-notice.d.ts +34 -2
- package/dist/core/engine-notice.js +17 -1
- package/dist/core/engine-storage-backend.d.ts +73 -0
- package/dist/core/engine-storage-backend.js +1 -0
- package/dist/core/file-history-store.d.ts +6 -2
- package/dist/core/file-history-store.js +1 -0
- package/dist/core/gate-fold.js +10 -2
- package/dist/core/gate-lanes.js +30 -21
- package/dist/core/gate-outcome.d.ts +7 -3
- package/dist/core/governance-codes.d.ts +7 -2
- package/dist/core/governance-codes.js +6 -0
- package/dist/core/hooks.d.ts +14 -14
- package/dist/core/hooks.js +1 -2
- package/dist/core/locked-config.d.ts +28 -1
- package/dist/core/locked-config.js +30 -3
- package/dist/core/lsp.d.ts +1 -1
- package/dist/core/mcp-server-spec.d.ts +66 -9
- package/dist/core/mcp-server-spec.js +52 -19
- package/dist/core/mcp.js +5 -4
- package/dist/core/memory-engine/engine.js +2 -1
- package/dist/core/memory-engine/export-bundle.d.ts +1 -1
- package/dist/core/memory-engine/export-bundle.js +1 -1
- package/dist/core/memory-engine/file-backend.d.ts +2 -6
- package/dist/core/memory-engine/file-backend.js +1 -12
- package/dist/core/memory.d.ts +3 -3
- package/dist/core/mode-policy.d.ts +13 -8
- package/dist/core/mode-policy.js +3 -2
- package/dist/core/mods/kernel/receive.d.ts +2 -1
- package/dist/core/mods/kernel/receive.js +7 -2
- package/dist/core/parent-spec-seat.d.ts +10 -13
- package/dist/core/parent-spec-seat.js +14 -6
- package/dist/core/pause-registry.d.ts +35 -1
- package/dist/core/pause-registry.js +10 -0
- package/dist/core/permission-rule-consent.d.ts +18 -5
- package/dist/core/permission-rule-consent.js +13 -8
- package/dist/core/permission-rule-model.d.ts +29 -6
- package/dist/core/permission-rule-model.js +6 -1
- package/dist/core/permission-rule-org.d.ts +37 -0
- package/dist/core/permission-rule-org.js +34 -42
- package/dist/core/permission-rule-session.js +2 -2
- package/dist/core/permission-rule-store.d.ts +8 -0
- package/dist/core/permission-rule-store.js +1 -0
- package/dist/core/permission-rule-sync.js +3 -2
- package/dist/core/read-only-shell.d.ts +1 -1
- package/dist/core/read-only-shell.js +30 -9
- package/dist/core/remote-env.d.ts +41 -1
- package/dist/core/remote-env.js +18 -0
- package/dist/core/resume-startup-cause.d.ts +18 -0
- package/dist/core/resume-startup-cause.js +12 -0
- package/dist/core/runner/approval-feedback.d.ts +46 -0
- package/dist/core/runner/approval-feedback.js +41 -0
- package/dist/core/runner/compaction-call-options.d.ts +3 -3
- package/dist/core/runner/compaction-call-options.js +2 -2
- package/dist/core/runner/contracts.d.ts +33 -41
- package/dist/core/runner/cross-boundary-lane.d.ts +87 -0
- package/dist/core/runner/cross-boundary-lane.js +190 -0
- package/dist/core/runner/decide-continuation.d.ts +4 -4
- package/dist/core/runner/decide-continuation.js +3 -1
- package/dist/core/runner/gate-exit.d.ts +32 -12
- package/dist/core/runner/gate-exit.js +75 -33
- package/dist/core/runner/inherited-ask-grants.js +2 -2
- package/dist/core/runner/person-path-rows.d.ts +2 -2
- package/dist/core/runner/prepare-acquire-reconcile.d.ts +3 -4
- package/dist/core/runner/prepare-acquire-reconcile.js +3 -3
- package/dist/core/runner/prepare-ask-lane.js +3 -3
- package/dist/core/runner/prepare-caps-and-workflow.d.ts +6 -5
- package/dist/core/runner/prepare-caps-and-workflow.js +3 -3
- package/dist/core/runner/prepare-config-doors.d.ts +12 -15
- package/dist/core/runner/prepare-config-doors.js +8 -3
- package/dist/core/runner/prepare-context-lane.js +6 -3
- package/dist/core/runner/prepare-delegation-surface.d.ts +3 -2
- package/dist/core/runner/prepare-delegation-surface.js +2 -3
- package/dist/core/runner/prepare-execution-env.d.ts +1 -2
- package/dist/core/runner/prepare-execution-env.js +2 -1
- package/dist/core/runner/prepare-gate-stations.d.ts +2 -0
- package/dist/core/runner/prepare-gate-stations.js +4 -3
- package/dist/core/runner/prepare-hands-readface.d.ts +2 -2
- package/dist/core/runner/prepare-hands-readface.js +1 -1
- package/dist/core/runner/prepare-inherited-gate.d.ts +2 -0
- package/dist/core/runner/prepare-inherited-gate.js +1 -0
- package/dist/core/runner/prepare-park-ask.d.ts +1 -1
- package/dist/core/runner/prepare-park-ask.js +9 -3
- package/dist/core/runner/prepare-policy-chain.js +2 -7
- package/dist/core/runner/prepare-safety-scan.d.ts +7 -2
- package/dist/core/runner/prepare-safety-scan.js +34 -27
- package/dist/core/runner/prepare-suspend-saga.d.ts +2 -2
- package/dist/core/runner/prepare-suspend-saga.js +6 -2
- package/dist/core/runner/prepare-task.js +13 -10
- package/dist/core/runner/prepare-turn-wiring.d.ts +10 -5
- package/dist/core/runner/prepare-turn-wiring.js +14 -49
- package/dist/core/runner/prepare-wiring-manifest.d.ts +2 -2
- package/dist/core/runner/prepare-wiring-manifest.js +2 -2
- package/dist/core/runner/prepare-workspace-restore.js +2 -2
- package/dist/core/runner/resume-admission.js +9 -9
- package/dist/core/runner/resume-apply.js +4 -4
- package/dist/core/runner/resume-policy-outcome.d.ts +2 -2
- package/dist/core/runner/resume-policy-outcome.js +18 -15
- package/dist/core/runner/resume-preflight.js +2 -2
- package/dist/core/runner/resume-review-outcome.js +3 -3
- package/dist/core/runner/run-leg.js +57 -33
- package/dist/core/runner/run-settle-and-teardown.js +2 -0
- package/dist/core/runner/run-terminal-adoption.js +15 -4
- package/dist/core/runner/session-file-state-replay.d.ts +5 -2
- package/dist/core/runner/session-file-state-replay.js +3 -2
- package/dist/core/runner/stream-steer-verb.js +2 -2
- package/dist/core/runner/tool-face-overlay.d.ts +24 -6
- package/dist/core/runner/tool-face-overlay.js +24 -15
- package/dist/core/runner-deps.d.ts +9 -9
- package/dist/core/session-store.d.ts +35 -16
- package/dist/core/session-store.js +20 -12
- package/dist/core/{shell-lexer.d.ts → shell-run-candidates.d.ts} +36 -12
- package/dist/core/{shell-lexer.js → shell-run-candidates.js} +145 -225
- package/dist/core/shell-wrapper-table.d.ts +5 -3
- package/dist/core/shell-wrapper-table.js +1 -1
- package/dist/core/store-contracts/checkpoint-store-contract.js +2 -2
- package/dist/core/store-contracts/session-store-contract.d.ts +49 -0
- package/dist/core/store-contracts/session-store-contract.js +639 -0
- package/dist/core/store-contracts/session-store-incarnation-contract.d.ts +12 -0
- package/dist/core/store-contracts/session-store-incarnation-contract.js +5 -2
- package/dist/core/task-event.d.ts +6 -4
- package/dist/core/task-registry-agent.js +1 -0
- package/dist/core/task-registry-shared.d.ts +5 -0
- package/dist/core/task-registry.d.ts +7 -0
- package/dist/core/task-registry.js +17 -0
- package/dist/core/task-result.d.ts +1 -1
- package/dist/core/task-spec.d.ts +6 -6
- package/dist/core/tighten-task-spec.d.ts +6 -4
- package/dist/core/tighten-task-spec.js +2 -2
- package/dist/core/tool-catalog-entries.d.ts +1 -1
- package/dist/core/tool-catalog-entries.js +52 -52
- package/dist/core/tool-face-reader.d.ts +49 -0
- package/dist/core/tool-face-reader.js +94 -0
- package/dist/core/tool-face.d.ts +17 -8
- package/dist/core/tool-face.js +1 -0
- package/dist/core/tool-policy.d.ts +64 -23
- package/dist/core/tool-policy.js +62 -56
- package/dist/core/tool-roster.d.ts +46 -28
- package/dist/core/tool-roster.js +91 -14
- package/dist/core/tool-spec-members.js +2 -2
- package/dist/core/tool-spec.d.ts +20 -4
- package/dist/core/tools.js +2 -0
- package/dist/core/trace.d.ts +24 -2
- package/dist/core/types.d.ts +2 -2
- package/dist/core/types.js +1 -1
- package/dist/core/untrusted-text.d.ts +7 -0
- package/dist/core/untrusted-text.js +3 -8
- package/dist/core/user-attachment.d.ts +43 -0
- package/dist/core/user-attachment.js +92 -15
- package/dist/engine/compaction/compaction.d.ts +2 -2
- package/dist/engine/compaction/utils.d.ts +2 -2
- package/dist/engine/harness/types.d.ts +7 -4
- package/dist/engine/llm/types.d.ts +7 -2
- package/dist/engine/llm/types.js +2 -1
- package/dist/engine/session/repo-utils.d.ts +5 -0
- package/dist/engine/session/repo-utils.js +9 -0
- package/dist/engine/session/storage-base.js +2 -2
- package/dist/index.d.ts +23 -9
- package/dist/index.js +19 -8
- package/dist/internal/llm.d.ts +1 -1
- package/dist/internal/llm.js +1 -1
- package/dist/orchestration/run-workflow-tool.d.ts +3 -0
- package/dist/orchestration/run-workflow-tool.js +1 -0
- package/dist/orchestration/workflow-governance.d.ts +1 -1
- package/dist/orchestration/workflow.d.ts +3 -0
- package/dist/orchestration/workflow.js +2 -1
- package/dist/prompt-assembly/catalog/schema.d.ts +3 -0
- package/dist/prompt-assembly/catalog/schema.js +3 -0
- package/dist/prompt-assembly/overlay.js +2 -2
- package/dist/prompt-assembly/select.js +2 -2
- package/dist/prompts/default.d.ts +1 -1
- package/dist/shell/lex.d.ts +87 -8
- package/dist/shell/lex.js +283 -49
- package/dist/stores/file/index.d.ts +8 -6
- package/dist/tools/artifact/artifact-tool.js +10 -0
- package/dist/tools/fs/bash-program-position.d.ts +3 -15
- package/dist/tools/fs/bash-program-position.js +3 -10
- package/dist/tools/fs/bash-readonly-classifier.js +2 -0
- package/dist/tools/fs/fs-bash.d.ts +3 -3
- package/dist/tools/fs/fs-bash.js +72 -101
- package/dist/tools/fs/fs-read.d.ts +12 -0
- package/dist/tools/fs/fs-read.js +10 -9
- package/dist/tools/fs/fs-shared.d.ts +27 -31
- package/dist/tools/fs/fs-shared.js +12 -15
- package/dist/tools/fs/fs-write.js +1 -1
- package/dist/tools/fs/index.js +2 -2
- package/dist/tools/fs/read-deny-home.d.ts +1 -1
- package/dist/tools/fs/read-deny-home.js +5 -4
- package/dist/tools/fs/read-deny.d.ts +16 -3
- package/dist/tools/fs/read-deny.js +12 -9
- package/dist/tools/fs/safety.d.ts +7 -6
- package/dist/tools/fs/shell-read-bindings.d.ts +5 -3
- package/dist/tools/fs/shell-read-bindings.js +15 -16
- package/dist/tools/fs/shell-read-device.js +1 -1
- package/dist/tools/fs/shell-read-plan.js +36 -47
- package/dist/tools/fs/shell-read-word.d.ts +1 -6
- package/dist/tools/fs/shell-read-word.js +2 -20
- package/dist/tools/fs/shell-reentry-text.js +1 -1
- package/dist/tools/web.d.ts +2 -2
- package/package.json +4 -1
- package/test/export-surface.snapshot.json +5654 -5520
- package/wire-changes/core@7.53.0.json +30 -8
- package/wire-changes/core@7.54.0.json +2870 -0
- package/dist/core/shell-scan.d.ts +0 -60
- package/dist/core/shell-scan.js +0 -183
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,284 @@
|
|
|
1
|
+
## 7.54.0 — 2026-10-19
|
|
2
|
+
|
|
3
|
+
### A table of the faces kept alive for compatibility, a gate that keeps it honest, and one ruler per word (#1336, #2100; release tooling and src comments only; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
4
|
+
|
|
5
|
+
- **What changed.** (1) `docs/RETIREMENT-LINES.json` lists every face that lives on only for compatibility, one row per face: what it is (`target`, `kind`), where it is still declared (`presence`: a file and a declaration-shaped pattern, read after comments are stripped), the one current reading, the last version allowed to ship it (`retireBy`) and the ticket. 30 rows today: the TaskOutput tool (export, env factory, two catalog rows, five retired-name hints), the old `MemoryStore` family (`MemoryStore`, `consolidateScope`, `runMemoryConsolidation`, the memory ledger v1 bare-map reader), the memory-root anchor backfill arm, the v1–v9 checkpoint number ladder, and fifteen smaller members and exports (`rewindFiles`, `ImportPreview.translated`, `ARTIFACT_CARD`, the `details.matched` list, the `finalVerifyInjected` boolean, `MEMORY_GUIDANCE`, `CODE_SYSTEM_PROMPT`, `memoryConsolidationMicroUsd`, the never-emitted `post-clear` word in two unions, the ignored `mark` parameter of `stripGitStatusUnits`, the journal cap re-export, `CheckpointStore.listByScope?`, `createWebFetchTool`, the bare `string | null` return of `loadProjectMemory`). The 65 names of the teacher / team / stores-cc / scenarios / built-in-workflow families that the table's design listed are all gone from `src/` (v7.49.0) and have no row. (2) `gate:retirement-lines` (static tier of `gate:all`) judges the table: a row whose face is gone is red (stale: the commit that deletes the face deletes its row); a face still there although a release at or past its line has shipped is red in every mode (broken: re-window with a reason, or delete); a face whose line is the version being prepared is a warning, and red under `gate:retirement-lines:rc` (rc tier). The version being prepared is `--version X.Y.Z`, else the first heading of `CHANGELOG.md` when it is a release heading — never `package.json`, which on a release candidate still holds the previous version; `--rc` with neither COULD NOT JUDGE: exit 2 with the refusal marker, so `gate:all` prints it on its COULD NOT JUDGE line (it names `--version X.Y.Z` and a numeric `## X.Y.Z` heading as the two ways out); exit 1 is kept for a judged finding such as a due row that is present. (3) The same gate scans `src/` comments for a closed vocabulary of survival claims (`back-compat`, `backward-compat(ible)`, `for compatibility`, `one minor`, `compat shim`, `compat seat` / `arm` / `window`): each must name its row (`RL-<id>`) in the same comment or sit in an exemption of the table, keyed by file and a verbatim fragment of the comment (never a line number), of class `false-positive` (with the reason) or `cc-declared` (with the parity-register entry, checked wherever the register is visible). A comment citing a row that does not exist is red; an exemption that matches no comment is red. 45 claims today: 20 governed by a row, 25 exempted. (4) One ruler per word: the count ratchet in `test/legacy-residue-gate.test.ts` keeps `legacy`, `pre-rename` and the Chinese word for compatibility, and no longer counts `backward-compat`; its regex now comes from the gate script (`RATCHET_NARRATIVE_RE`), and the gate re-proves on every run that no survival claim is visible to it. Baselines re-frozen to the measurement without the word (of the 11 lines that left, 10 are governed by the scan — a row or an exemption — and the eleventh, `prepare-memory.ts:295`, was reworded: it called a one-object type a union): `src/agents` 16→15, `src/core` 105→100, `src/core/runner` 59→53 (merged tree; 55 before the #1391 note removals), `src/engine/harness` 5→4. `legacy arm` is not in the scan's vocabulary because it contains `legacy`, which stays with the ratchet.
|
|
6
|
+
- **Comments made true (no behavior).** `LspServerManager.sessionFor`'s `env` and `WebSearchConfig.search`'s third parameter are optional by contract, not for an older implementation, and now say so; the memory-root backfill arm's comment no longer states a deletion version of its own (the row does); `prepareMemory` no longer calls the memory spec a union. Twenty comments gained an `RL-<id>`.
|
|
7
|
+
- **Who is hurt.** Nobody at runtime: `src/` changes are comments. A change that adds a survival-claim sentence without a row or an exemption, or deletes a listed face without deleting its row, now fails `gate:all`; a release candidate fails `gate:retirement-lines:rc` while a face is still listed at or before the version being cut.
|
|
8
|
+
- **Not in this release.** The deletions the rows promise (`#2100` carries the memory-root arm, the member rows, `listByScope?`, `createWebFetchTool`, the bare `loadProjectMemory` return and the `rewindFiles` merge into the retired-key refusal; `#1608`, `#1654`, `#1333` carry theirs). The full enumeration of the old `MemoryStore` family beyond the three exported names above belongs to `#1654`'s design.
|
|
9
|
+
- **Pins.** `test/retirement-lines-gate.test.ts` (the shipped table green on the shipped tree; an earliest-line probe red under `--rc`; due, stale, comment-only presence, broken, half-deleted family, unreadable file, bad values; survival claims governed, dangling, exempted, stale; the word division and its before-the-division negative control; the CLI reads the CHANGELOG heading, never `package.json`); `test/gate-all-roster.test.ts` (both gates seated); `test/legacy-residue-gate.test.ts` (re-frozen).
|
|
10
|
+
|
|
11
|
+
### (#469 r3 residuals, #2061) A resume leg tells the execution-env factory which workspace it is rebuilding (`ExecutionEnvFactoryContext.resumeWorkspace`), and a file op a target refused before commit has its own word (`FileErrorCode` `target_unavailable`) (#469; additive; behavior change, open to veto: a file write refused with `target_unavailable` is read as provably unwritten by the Write / Edit / NotebookEdit lanes; behavior narrowing: none at runtime, the contract text for a target-bound factory is tightened; hard-breaking: none; closed sets: `FileErrorCode` +`target_unavailable` (a type-only union; types: `FileErrorCode` +1); exports: none; members: +1 optional `ExecutionEnvFactoryContext.resumeWorkspace`) (@server @client-core)
|
|
12
|
+
|
|
13
|
+
- **Before.** A device-bound factory learned of a resume only by `sessionId` / `placementRootSessionId`, so it looked the placement's CURRENT binding up and connected whatever device that named; the engine compared the minted env's handle against the checkpointed one afterwards and refused a different `provider` or `deviceId` (`resume.env_failed`) — after the wrong device was already connected. The file face had no never-started word: an adapter whose target refused a file op before commit had to land it on `transport_lost` ("the connection dropped mid-session"), which a retry whitelist reads as an op that may have run.
|
|
14
|
+
- **Now.** (1) On a durable resume leg the factory's ctx carries `resumeWorkspace`: a frozen copy holding exactly the members a handle declares (`WORKSPACE_HANDLE_MEMBERS`, one list keyed by the interface: `sandboxId`, `provider`, `mountPath`, `snapshotId`, `sessionToken`, `restoreMode`, `deviceId`) that the checkpoint row carries as strings. Every member is a string, so the freeze is deep; nothing else on the stored object is copied (a decoration an adapter put on its own handle), and there is no structured-clone step: a handle a custom store hands back as a Proxy no longer fails the resume with a code-less `DataCloneError`. The key is absent, not an `undefined` value, on a fresh run and on a resume whose row recorded no handle. A factory bound to a target MUST mint on `resumeWorkspace.deviceId` and THROW when it cannot; it must not connect another device or re-read the binding. **The minted env's `workspaceHandle()` MUST report the device its connection actually reached and is never copied from `resumeWorkspace`.** The engine's comparison (`provider` equal; `deviceId` equal whenever the row carries one) is unchanged and is the receipt check, and it trusts that report: a factory that connects elsewhere and reports so is refused with `resume.env_failed`, the approved action does not run, and the row is reopened when the store can; a factory that echoes the fence into its env's handle while connecting elsewhere defeats its own protection and passes (the contract's stated limit, pinned). A factory's own typed refusal surfaces as the failed terminal's code unchanged (e.g. `device.rebind_mismatch`), the row reopened the same way. (2) `FileErrorCode` gains `target_unavailable`: the op was refused before commit (offline / busy / not bound), never started, no effect landed, a retry is safe once the target is back — the exec face's word of the same name, on the file face. The Read chain and the Bash dead-cwd probe treat it as every non-death code: the adapter's sentence rides unchanged and the probe reads it as unknown, never as a gone directory. (3) **Behavior change (open to veto).** The write lane's list of codes whose contract says nothing was written (`NOTHING_WAS_WRITTEN`, `fs-write.ts`: `already_exists`, `precondition_failed`) gains `target_unavailable`, so a file write the target refused before commit retracts its first-touch history record on the code's own guarantee and counts no edited notice. Before, a code outside the list took the evidence arm, which re-reads the path through the same unreachable target: the read failed, the record was kept, and a rewind then deleted a file someone else had since created at that path. Who is affected: an adapter that returns `FileError("target_unavailable")` from a write (none did before: the word was not a `FileErrorCode` member and the device adapter mapped it to `transport_lost`); its records and `editedFiles` entries for a refused write are no longer kept.
|
|
15
|
+
- **Behavior narrowing: none at runtime.** The fence moves no refusal earlier than it was: it is read after the claim, so the only pre-claim defence stays `RunnerDeps.resumePreflight`. A window remains between a passing preflight and the factory's mint in which the device can be re-bound; a conforming factory refuses there, the refusal lands in the post-claim compensation (the row reopens on a store with `reopen`, stays consumed on one without) and its code is the factory's own, not necessarily `resume.env_failed`. The engine writes no file-face prose per code, so `target_unavailable` and `transport_lost` read alike in the Read / Bash answers; the difference is the code a retry decision reads.
|
|
16
|
+
- **Who breaks.** (a) Type only: code that exhausts `FileErrorCode` alone — a `Record<FileErrorCode, …>` or a `switch` ending in a `never` check — stops compiling until the new word has a row (a table over `FileErrorCode ∪ ExecutionErrorCode` does not move: the exec face already carried the word; `sema-server` `device-ws-protocol.ts` is such a table). Runtime values are unchanged for every existing adapter. (b) A factory or test that deep-equals or enumerates the ctx on a RESUME leg sees one more key. (c) A factory that logs its ctx wholesale writes the checkpointed handle on a resume leg, `sessionToken` included.
|
|
17
|
+
- **How to migrate.** *server*: the device factory reads `ctx.resumeWorkspace?.deviceId`, mints on that device, and throws a typed `device.rebind_*` refusal when the bound device differs or is unreachable; `DEVICE_OUTCOME_TO_FILE_ERROR.target_unavailable` (`remote-env-device.ts`) and the transient rows of `DEVICE_REJECT_TO_FILE_ERROR` move from `transport_lost` to `target_unavailable`; redact `sessionToken` where ctx is logged. *client-core*: add `target_unavailable` to the mirrored `FileErrorCode`. *cli*: nothing.
|
|
18
|
+
- **Pins.** `test/design469-r3-fence.test.ts` (F1–F7: the fence's members, a Proxy row, the echoing factory's stated limit); `test/design469-r3-file-target-unavailable.test.ts` (T0–T3); `test/failopen-governance-core.test.ts` (the exhaustive table gains the row); `test/hands-track-edit.test.ts` (#469: a `target_unavailable` create keeps no record, reads no proof, counts no edit, and a rewind spares a later file; control `transport_lost`).
|
|
19
|
+
|
|
20
|
+
### `readToolFaces(declared)` is the one reader of a `toolFaces` declaration and `readDenyRowAnchor(pattern)` the one judge of where a read-deny row is anchored, both exported from the package root (#1973, #2042; additive exports; hard-breaking: none; closed sets: none; exports: +2 functions; members: none) (@server @cli @client-core @settings-schema)
|
|
21
|
+
|
|
22
|
+
- **Before.** The engine judged a `toolFaces` declaration (`McpServerSpec.toolFaces`) only at the mount, against the tool's materialized schema (`toolFaceProblem`), and that judge assumes a well-typed face; the structure of a declaration a host assembles from JSON — the members, the kind of each, the five closed words (`family`, `pathTarget.access` / `base` / `absent`, `renderHints.approvalCard`) — was read by each host for itself (server `mcp-tool-faces.ts`, beside a mirror of the engine's tables). The engine's own door did not ask any reader of it either: it judged `toolAxes` through `readToolAxes` and left `toolFaces` to the mount, so the engine read the declaration a second way, at the listing, off the caller's raw object — a mistyped member (`pathTarge`) or a safety axis written as a face member (`effect`) was dropped in silence and the tool mounted without the protection the operator declared; a flag of the wrong kind was stamped as written; `aliases: "file"` threw `values.find is not a function` out of prepare (#2079); an empty tool name was announced inert (`config.tool_face_undeclared`). Where a read-deny row is anchored was answered by two predicates read together (`isRootedReadDenyPattern` and `readDenyPatternRun(p).homeAnchored`), written out at each of four readers, neither exported (and the auto-mode classifier's deny lines asked the home word alone, so a rooted row `/etc/shadow` was stated to the model as `Read(//**/etc/shadow)`, matched anywhere): a host judging a served or hot-issued row could only look at its first character.
|
|
23
|
+
- **Now.** (1) `readToolFaces(declared)` answers `{ ok: true, faces? } | { ok: false, problem }`, the face-side sibling of `readToolAxes`, and the two share their receipt step and their refusal sentences (`receivePerToolDeclaration`). The declaration is received as plain data by the engine's one receipt (a Proxy, a map, a class instance, a record of another realm, a getter that throws, a cycle, a nesting past 1 000 levels, a tool named `__proto__` are refusals that say where they sit — the same words `readToolAxes` uses), each face is typed whole (the members the types have, each of its kind; a typo is a refusal, never a dropped member; `pathTarget.skillScopeEligible: false` is the absence its type means), then judged by the mount's own judge with the checks that need the schema left out — the five closed words, the bounds, the cross-member rules. The checks that read the schema (a `param` that is a top-level property, the keys an approval card needs, the names a summary or a rule face lists) stay with the listing. One face that does not fit refuses the whole declaration; an empty record declares nothing. The answer is a plain copy that keeps the order the members were written in. The engine's door asks it exactly as it asks `readToolAxes` (`judgeEntryToolFaces`, beside `judgeEntryToolAxes`): for every MCP server of a task (a declaration that does not fit refuses the whole prepare, `config.tool_face_invalid` — see the #2093 section below — subject `MCP server "<name>"`, before any server is dialed) and per server at the library entrance (`materializeMcpTools` and the no-run probe: that server's dial fails, `invalid_config`, its siblings unaffected); the entry then carries the reader's frozen copy, and the mount (`applyMcpToolFaces`) consumes that copy, never the caller's object. The card-namespace rule (a tool's `resultCards` live under its own `<cardId>/…`) is now the face judge's own rule, for an MCP server's face (`mcp:<server>/<tool>/…`, asked at the listing, where the minted tool is) as for a caller's (`toolFaceProblem` takes the tool's `cardId`): one function judges both. The refusal sentences are the mount judge's own words and may quote only the structure of the declaration — a tool or member name, the word a closed table refuses, a key over its bound (its first 40 characters), a count and its bound — never display text or a list that fits (the `ToolFacesRead` doc). (2) `readDenyRowAnchor(pattern)` answers `"root"` / `"home"` / `"none"`; it states the rooted rule once (a leading `/` that is no home row), the canonical spelling asks it, and so do the compiler (placement and linked placements), the toolkit's "has anything to place" test, the mount's row-head reading and the classifier's deny lines (a rooted row is stated at the root, `Read(//etc/shadow)`, not as matched anywhere) — `isRootedReadDenyPattern` (a module function, never a root export) is retired. The two write tables that refuse the home word because they place no row (`write-protect.ts`, `sensitive-path-policy.ts`) ask whether a name opens with the home word itself, `readDenyPatternRun`, and stay. (3) `readToolFaces` is declared in `src/core/tool-face-reader.ts`, beside the declaration vocabulary rather than in the runner subtree where the mount lives; the public surface tiers a symbol by its declaring file, so it is `advanced` like `readToolAxes` (it was `internal` in the file where the mount is). A spelling and its canonical form give one answer: `//etc` and `/etc` are `"root"`, `/~/x` is `~/x`, `"home"`.
|
|
24
|
+
- **Not folded in.** `effect` / `egress` / `irreversibility` are `toolAxes`' members, read once, by `readToolAxes`; a face has no such member and `readToolFaces` refuses one as an unknown member. The ticket's "five vocabularies" are the face's five closed tables, not those axes.
|
|
25
|
+
- **Behavior narrowing (open to veto).** The row-anchor judge moves nothing (every row the engine mints compiles, places and mints the same text: `test/backlog2042-read-deny-row-anchor.test.ts` A2); the face reading does. (1) **What changed:** the engine refuses at its door what it used to take in silence, announce at the listing, or choke on — for a task's `TaskSpec.mcp` (the whole prepare, `config.tool_face_invalid`) and for `materializeMcpTools` / the no-run probe (that server's dial, `invalid_config`): (a) a mistyped member, a safety axis written as a face member, a flag that is not a boolean (base: the tool mounted without the declared member, or with the flag stamped as written); (b) a member of the wrong kind — `aliases: "file"`, a string `summaryParams`, a null face (base: a TypeError out of prepare, #2079); (c) an empty tool name (base: `config.tool_face_undeclared` announced, every tool mounted); (d) every fault the mount's judge refused per tool at the listing — a word outside a closed table, a write path target on `base: "root"` or `absent: "base"`, an inert `patternParam`, a member over its bound, a reserved approval card (base: that tool is not mounted and `config.tool_face_invalid` is announced, the server's other tools mount; head: the whole prepare is refused). At the listing, an MCP face whose `resultCards` name another namespace (`builtin:Edit/…`, another server's, a bare name) is refused as a caller's is — the tool is not mounted, `config.tool_face_invalid` (base: stamped as written, and its cards joined the leg's projector allow set). A written `skillScopeEligible: false` is the absence its type means, and an empty `toolFaces: {}` leaves no key on the entry. A valid declaration mounts exactly as before, byte for byte in the face columns (full, members in another order, partly empty, none: `.gate-logs/car-1973-2042/cx/door-*.txt` in the receipt). The auto-mode classifier states a rooted read-deny row at the root (`Read(//etc/shadow)`; before, `Read(//**/etc/shadow)`, wider than the row). (2) **Who is hurt:** a deployment whose `toolFaces` carries any of the above — it was half-declared before and mounted without (part of) its protection, or lost tools at the listing; its tasks are refused until the declaration is fixed, and nothing mounts without the protection it declared. A host that keeps its own intake reader meets the same refusals one step later, at the engine's door. A classifier prompt that listed a rooted row gains a narrower line: it names the declared location only, as a home-anchored row's always did — where the row's head is a link the compiler also denies the resolved location, which the line does not state (the old `Read(//**/…)` line reached it only when the link target happened to end in the same segments, and over-stated the row everywhere else; the engine enforces the compiled row either way). (3) **What compensates:** the sentence names the server, the tool and the member (and the word a closed table refuses), in the words the mount and the host reader use; a host that asks `readToolFaces` at its own door refuses before a task is built.
|
|
26
|
+
- **Who is hurt (otherwise).** Nobody else. `isRootedReadDenyPattern` was reachable from no published subpath. The type of the answer is not a second export (`ReturnType<typeof readToolFaces>`).
|
|
27
|
+
- **How to migrate.** *server*: `readMcpToolFaces` (`mcp-tool-faces.ts`) becomes `readToolFaces` (same answer shape, `faces`; the sentence is the engine's), and the file and the five table imports only it used are deleted; S-1743 asks `readDenyRowAnchor(row)` for a served or hot-issued row instead of a first-character test. *settings-schema* (arrival version **7.54.0**): import `readToolFaces` instead of mirroring five tables; no drift gate to carry. *cli* / *client-core*: import where a `toolFaces` declaration or a deny row is judged. *Any host that mounts MCP servers*: a `toolFaces` declaration that mounted with a member dropped (or threw) is fixed at its source; the refusal sentence names the member.
|
|
28
|
+
- **Pins.** `test/backlog1973-read-tool-faces.test.ts` (T1–T4, S: the one reader, one bad word per closed table in the mount's sentence, the receipt's words shared with `readToolAxes`, the host-side reader's shapes judged the same, no vocabulary of its own); `test/backlog1973-fix1-faces-door.test.ts` (D the door and the task lane, L the library entrance, M the mount and the namespace rule, A the classifier's statement of an anchor, T the tier); `test/backlog2042-read-deny-row-anchor.test.ts` (A1–A3, G: the three forms and their spellings, every reader byte-identical to the retired pair, the pair read nowhere else); `test/export-surface.snapshot.json` (+2, `readToolFaces` tiered `advanced`).
|
|
29
|
+
|
|
30
|
+
### An MCP server's `toolFaces` declaration that does not fit is refused under `config.tool_face_invalid`, the code a caller tool's bad face already has, at the preparation door and at `materializeMcpTools` (#2093; behavior narrowing, open to veto; hard-breaking: none for a well-formed deployment, see Who is hurt; closed sets: `CHECKPOINT_CONFIG_REFUSAL_CODES` +1 `config.tool_face_invalid` (40 → 41); exports: none; members: none) (@server @client-core @cli @settings-schema @test)
|
|
31
|
+
|
|
32
|
+
- **Before (v7.53.0).** A vocabulary problem in an MCP server's `toolFaces` — a word outside a closed table, a reserved approval card (`question` / `plan`) — reached the mount, which announced it once per tool after the listing (`config.tool_face_invalid`) and did not mount that tool; the server's other tools mounted. A caller tool declaring the same face was refused at its own door, at prepare, under the same code.
|
|
33
|
+
- **Behavior narrowing (open to veto).** The entry is refused whole, before any dial: the preparation door refuses the whole prepare (`runTask` returns a failed terminal, `terminal.code` `config.tool_face_invalid`, the reader's sentence in `terminal.message`; nothing is announced) and `materializeMcpTools` fails that server's dial (status `failed` / `invalid_config`, the warning's `cause.code` `config.tool_face_invalid`, its siblings unaffected) — the same all-or-nothing rule a bad `toolAxes` already had. This also gives the face refusals the #1973 section above describes (a mistyped member, a member of the wrong kind, an empty tool name) their code: `config.tool_face_invalid`, not the generic `config.invalid_argument`, so one bad face has one code at the caller door and at the MCP door, in the one judge's sentence. What stays `config.invalid_argument`: the entry's own plain-data receipt (a Proxy, a cycle, a member named `__proto__`, a list element that is not a string) and the `toolAxes` leg. A face the reader accepts but the tool's schema cannot carry (a `pathTarget.param` that is not a top-level property, the keys an approval card needs) is unchanged: judged at each listing, that tool not mounted and `config.tool_face_invalid` announced once.
|
|
34
|
+
- **On a resume.** The word is a prepare code the resume's pre-claim screen can now produce, so it joins the set the screen wraps: an MCP entry whose `toolFaces` the face reader refuses rejects the resume as `checkpoint.config_refused` (`detail.code` `config.tool_face_invalid`, `detail.field` `"taskConfig"`) with the row `pending` and byte-identical on any store, as a bad `toolAxes` does. A CALLER tool (`TaskSpec.tools`) declaring the same bad face is not on the screen: its refusal is the roster safety scan, prepare's own stretch after the config-door phase, so on a resume it is a `failed` terminal after the claim (`KL-1950-a`; the pin is `backlog1950` D) — it was never a raw error, only later.
|
|
35
|
+
- **Closed sets.** `CHECKPOINT_CONFIG_REFUSAL_CODES` / `CheckpointConfigRefusalCode` 40 → 41: +`config.tool_face_invalid` (each word of the set has a recipe the screen produces: `test/backlog1950-resume-config-doors-preclaim.test.ts` I).
|
|
36
|
+
- **Who is hurt.** (a) A deployment that points one bad card at one tool of a server: the whole server does not mount (before: that tool alone), and a task naming it is refused at prepare until the declaration is fixed. The code does not change from v7.53.0 (`config.tool_face_invalid`), only where it is met and what it takes with it. A host that branched on `config.invalid_argument` for the #1973 refusals above is not hurt: they never shipped under that code. (b) A downstream table that is exhaustive over `CheckpointConfigRefusalCode` / `CHECKPOINT_CONFIG_REFUSAL_CODES` (a `satisfies Record<CheckpointConfigRefusalCode, …>` status or disposition table) stops compiling, or misses the word at run time, until it has a row for it (@server @client-core).
|
|
37
|
+
- **How to migrate.** Fix the declaration the refusal names (the server, the tool, the member), or ask `readToolFaces(declared)` at the host's own door and refuse before a task is built. *server* / *client-core*: add one row to a table over `CheckpointConfigRefusalCode` for `config.tool_face_invalid` (`detail.field` `"taskConfig"`: correct the config and resume the same token, as for the other protocol declarations). *settings-schema* / *cli*: a branch on the declaration code now sees `config.tool_face_invalid` for an MCP face too.
|
|
38
|
+
- **Pins.** `test/backlog2093-toolfaces-door-code.test.ts` (D the door and the shared judge, W the two doors one code, R the task lane, L the library entrance, S the schema-dependent half unchanged); `test/backlog1414-approval-card.test.ts` (A10 protocol arm: the run fails at prepare, nothing asked); `test/backlog1973-fix1-faces-door.test.ts` (D1–D3 code); `test/backlog1950-resume-config-doors-preclaim.test.ts` (A / B the resume refuses the face pre-claim, G the nested seat sweep, I the recipe for the new word, D the caller door's post-claim `failed`).
|
|
39
|
+
|
|
40
|
+
### What the approving person writes in the card's "and tell the agent what to do next" input reaches the model: `ResumeOutcome` (`policy_ask`) `allow` gains `feedback?: string` (#1581; additive; behavior change, open to veto: the words are delivered to the model as the user's own, unfenced and unbounded, like the reference's accepted-prompt feedback; behavior narrowing: a WRITTEN `feedback` on a `deny` or on a decide whose `hostDecision.decidedBy` is not `"person"` is refused pre-CAS; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `ResumeOutcome.feedback` on the `policy_ask` arm, +1 optional `ResolvedOutcome.feedback`, +2 optional `detail.kind` / `detail.carry` on `task.user_steer_undrained`, +1 optional `OwedUserInputAccount.continuation.approval`, +1 optional `approvalFeedback` in `undrainedUserInputNotices`' counts) (@server @client-core @cli)
|
|
41
|
+
|
|
42
|
+
- **Before.** The approval card's Yes row had an input ("and tell Sema what to do next") whose text had no seat on the decide body: the shell collected it and dropped it, so the model never read what the person wrote. The `deny` arm already carried the person's words as `reason`; the `allow` arm had nothing.
|
|
43
|
+
- **Now.** (1) The `allow` arm of the `policy_ask` decide takes `feedback?: string`. It is present only when the person wrote something; a host whose card has no input omits the key; **blank (absent, `""`, whitespace-only) is "wrote nothing" and delivers nothing** — the continuation is byte-for-byte what a plain allow produced. (2) The resumed run's continuation user message (the first message after the batch's tool results) carries the words once, verbatim after the outer whitespace is dropped, as a user-lane range outside every `engineSegments` range — no fence, no wrapper sentence, no length bound; a derived view (auto-mode window, compaction serializer) reads them as user speech. A user row directly after the approved call's own result is not used: a batch's siblings settle after it and a call's results must precede any later message. (3) The winner on the row records the words exactly as supplied (`resolvedOutcome.feedback`, additive: a row that never recorded them leaves the dimension unbound) and the `env_failed` replay must repeat them. (4) **Core has no `allow_session` word** — `decision` is exactly `"allow"` | `"deny"`; "allow for this session" is a host's session-rule write beside a plain `allow`, which carries `feedback` like any other allow. (5) Anchored on the reference (2.1.296): the accepted feedback is trimmed with empty meaning none (`acceptFeedback` (cli296.js:274533)), and it rides as one plain text block beside the tool result with no prefix and no bound (`acceptFeedback` (cli296.js:249220)).
|
|
44
|
+
- **Behavior narrowing (open to veto).** A WRITTEN `feedback` is refused pre-CAS (`checkpoint.invalid_outcome`, the row stays pending and redeemable) when the decision is `deny` (that arm's words are `reason`), when `hostDecision.decidedBy` is not `"person"` (a machine's text is never delivered as the user's), when it is not a string, or when it carries a `<system-reminder>` tag, opening or closing (the one rule a deny's `reason` is held to — the sentinel is the engine's own, so the reference's raw delivery of such text is a stated divergence, `KL-1581-c`). A host that sent a stray `feedback` key on such a decide before this version had it silently ignored.
|
|
45
|
+
- **Who is hurt.** (a) A host that already puts a WRITTEN `feedback` (a non-blank string) on a deny or on a policy-made decide (ignored until now) is refused at the verb — a blank one on a deny is accepted as before; and a host that sends `feedback: null` (or any other non-string) on any decide of an approval row — a person's allow included, where it was accepted before — is now refused (`checkpoint.invalid_outcome`, the row stays pending): omit the key instead. (b) A deployment whose `userPromptSubmit` screen is a prompt FILTER does not see these words (`KL-1581-b`): filter at the approval channel, as for `reason`. (c) A consumer that assumed the resume continuation holds only engine frames and fenced data now meets a user-lane range there when the person wrote one. (d) Mixed fleets: a worker on an older core ignores `feedback` silently — pin the core version that serves the decide verb. (e) If the call's result row or the message carrying the words fails to append, or the leg ends before that message commits (a spent token / $ ceiling), the words are announced — one more row of `task.user_steer_undrained` with `detail.kind: "approval_feedback"` (`kind` is absent on every other row; no second code) — unless the checkpoint row came back; they are not retained across the failure, and a returned row does not carry them either: on a row reopened for `env_failed` the replay must repeat the recorded words (blank and trimmed forms read alike), on one reopened for another reason the next decide is fresh and, without `feedback` on it, the first words are gone with no announcement — the host may re-supply them (`KL-1581-a`). (f) If a sibling of the approved call gates again before the prompt lands, the words ride the new row as its last parked steer and reach the model under that carrier's framing on the next resume — unless they exceed that carrier's bounds (16,000 characters; a queue already holding three steers), in which case they are not carried and `task.user_steer_undrained` (`cause: "leg_end"`, `detail.carry` = the store's refusal code `steering.invalid_content` or `steering.queue_full`, and a sentence that says the pause's carrier refused them) counts them; the same account counts them when the leg ends before its prompt lands (a spent token / $ ceiling), after the decide was applied (`KL-1581-a`). (g) The refusal sentences of a deny / reject `reason`, of `editedPlan` and of a trusted live steer (same rule — they already refused the opening tag too) are now produced once (`sentinelTagRefusal`, beside `sanitizeUntrustedText`) and say "… must not contain a <system-reminder> tag (opening or closing)" where they named only the close tag: dispatch on `code`, never on the sentence. The in-process approver face (`AskOutcome`) has no `feedback` seat in this version.
|
|
46
|
+
- **How to migrate.** *server*: pass the card's input through on the allow decide — `feedback` beside `decision: "allow"` when `hostDecision.decidedBy` is `"person"` and the person wrote something (omit the key otherwise; never on a deny, where the words are `reason`). *client-core*: the `decide` input gains `feedback?: string` for the allow arm and projects it onto the decision body. *cli*: the card's existing input feeds the allow decision (`buildAllowDecision(updatedInput, permissionUpdates, feedback)`), on the plain Yes row and on the rows that also write a session rule alike.
|
|
47
|
+
- **Pins.** `test/backlog1581-approval-feedback.test.ts` (F1 delivery: role / position / once / verbatim and the stored user-lane range; F2 blank; F3 composition and the absent `allow_session` word; F4 deny unchanged, written feedback refused on a deny and on a policy decide; F5 the winner, the replay, the owed carry on a re-park; F6 bad values; F7 event order and the committed user entry).
|
|
48
|
+
|
|
49
|
+
### The protocol tools a teammate leg shares with its spawner (`SendMessage`, `CronCreate` / `CronDelete` / `CronList`, the task-list family) are held exactly when the layers above the leg admit them — one table replaces the `protocolUnderCeiling` flag (#2070; behavior narrowing and behavior change, ruled: clay C-R646, 2026-10-19; hard-breaking: type only — `ToolCatalogEntry` loses `protocolUnderCeiling`; a host compiling against that member stops compiling, runtime unchanged; closed sets: none; exports: none; members: −1 optional `ToolCatalogEntry.protocolUnderCeiling`) (@cli @server @client-core)
|
|
50
|
+
|
|
51
|
+
- **Before.** On a named child (a teammate leg) four of the eight team tools — `SendMessage` and the scheduler trio — were admitted whatever the spawner's lists said; only the task-list family (`TaskCreate` / `TaskGet` / `TaskList` / `TaskUpdate`) was bounded by the spawner, through a flag on those four catalog rows. So a spawner with `allowTools: ["Read", "Agent"]` handed its teammate `SendMessage` and `CronCreate` / `CronDelete` / `CronList` anyway, and a spawner that denied `SendMessage` (or any of the four) got its named child REFUSED before it ran (`config.tool_face_control`, `detail.reason: "protocol_denied"`, "an inherited list … denies it"). The cli's `--tools` hid the four with a deny-complement second line for exactly this reason.
|
|
52
|
+
- **Now.** One table, `SPAWNER_SURFACE_LEG_KINDS` (the leg kinds that share their spawner's tool surface; today `teammate`), read by the selector's protocol arm: a protocol row some kind of which is in the table is decided by the layers ABOVE the leg — the leg's own lists still do not decide it (`allowTools` need not name it, a definition's own allowlist never takes it). So a spawner's deny, or an allowlist (its own, or one of a run between the spawner and the leg) that does not name a team tool, takes that tool from the named child: its roster does not carry it and a call of it comes back `tool.not_found`. An inherited deny of such a tool is a bound, no longer a contradiction: the child starts without the tool. The flag is gone (`protocolUnderCeiling` on four catalog rows, its field in the filter's row table, its contract sentence). A row no kind of which is in the table is the leg's own channel — `ReportBlocked` (delegated), `StructuredOutput` (structured), `DeclareDone` (goal), `ObserverReport` (observer): admitted past every layer exactly as before, and a deny that reaches one, the run's own or an inherited one, is still refused before the run in the same sentence. A row naming several kinds is shared when any of them is in the table. Anchored on the reference (2.1.296): an in-process teammate's available tools ARE its leader's root tool surface (`cli296.js:278271`, `{tools: J} = y.rootToolSurface`), the task-list family is requested for it only when that surface holds `TaskUpdate` (`cli296.js:278288`), and its own `tools:` / `disallowedTools` cannot remove `SendMessage` or the task-list family (`cli296.js:278274`).
|
|
53
|
+
- **Behavior narrowing (ruled: clay C-R646, 2026-10-19).** A named child under a spawner whose allowlist does not name `SendMessage` / `CronCreate` / `CronDelete` / `CronList` no longer holds them (the task-list family already followed the allowlist). The leg's own deny of a team tool is refused before the run as before, byte for byte; the table's other kinds, an unnamed (delegated) child, and the whole-face switch (`excludeAllTools`) are unchanged.
|
|
54
|
+
- **Behavior change (ruled: clay C-R646, 2026-10-19).** A spawner that denies a team tool no longer gets its named child refused: the child starts without the tool (a team whose lead denied `SendMessage` has teammates that cannot message — the configurer's choice, now carried out as written). The reference silently drops a teammate's OWN deny of these tools from its definition (`cli296.js:278274`, the filter on `p.disallowedTools`); core keeps refusing it loudly (the existing divergence, unchanged).
|
|
55
|
+
- **Who is hurt.** (a) A deployment that relied on "the team tools are always there" under a limited spawner: `SendMessage` and the scheduler trio vanish from the named child unless the spawner's allowlist names them (`--tools Read,Agent` shape). (b) A host or test that pinned the refusal of an inherited deny of a team tool (`protocol_denied` naming "an inherited list") for these rows: that sentence now arises only for the other kinds' rows. (c) A parked named child resumed after the upgrade is judged by the ceiling its row recorded, so it too meets the new roster (not pinned). (d) Type only: a literal typed `ToolCatalogEntry` that sets `protocolUnderCeiling` stops compiling (the catalog is core's own static table; no host is known to author rows).
|
|
56
|
+
- **How to migrate.** Name the team tools the teammates should keep in the spawner's allowlist (`allowTools: ["Read", "Agent", "SendMessage", "TaskList"]`), or deny the ones they must not have; there is nothing to set on the leg. *cli*: retire the deny-complement second line of `--tools` (the `tools-flag-complement-deny-teammate-protocol` transitional arm: its registry entry in `scripts/uplift-retirement-registry.json`, the comment block at `src/main.tsx:1329`, and the complement `src/utils/permissions/permissionSetup.ts` computes for it) — the request's top-level `allowTools` already bounds the teammate. *server*: no change (the tool table projection reads the roster). *client-core*: one doc sentence — a named child's team tools follow the spawner's allowlist and denylist.
|
|
57
|
+
- **Divergences preview.** Entry 280 (`protocol-column-leg-tools-outside-lists`) narrows to the leg's own lists; the layers above now decide the team rows as the reference's root-surface inheritance does. What stays a divergence: a teammate's own deny of a team tool is refused (reference: silent drop), and the scheduler trio is kept past a definition's own allowlist here although the reference forces only `SendMessage` and the task-list family into a teammate's own `tools:` (`cli296.js:278274`; the trio is in the reference's teammate allowance set, `cli296.js:245703`, applied at `cli296.js:254651`).
|
|
58
|
+
- **Pins.** `test/backlog2070-protocol-rows-under-ceiling.test.ts` (T1–T2 the table and the multi-kind rule; U per catalog row P1–P4, P6, three-layer nesting, the covering-spelling matcher, P7, the other kinds' rows unchanged; R a real Runner, roster AND call, own deny sentence byte-same; S the flag gone and no leg-kind literal in the filter); `test/backlog2070-multi-kind-protocol-row.test.ts` (a synthetic catalog row naming two kinds, asked through the admission and through the contradiction judge, per asking leg); `test/backlog1623-tool-selector.test.ts` ②b ① and the row-under-the-ceiling cell re-anchored.
|
|
59
|
+
|
|
60
|
+
### Five judges the engine already asks are now package-root exports, so a host stops writing a second copy: `sameOrgPolicyContent`, `receiveDeclaration` + `DECLARATION_PATH_RULES`, `mcpStartupTimeoutMs`, `isHostRole` (#2060, #2076, #2084, #2085; additive exports; hard-breaking: none; closed sets: none; exports: +4 functions, +1 constant, +3 types; members: none) (@server @client-core)
|
|
61
|
+
|
|
62
|
+
- **Before.** Each of these was a function or table the engine's own door, partition or backend already asks, held inside its module. A host that needed the same question answered at its own door had to re-write it: the org partition's "same revision, same content" comparison (a persistence backend of the host's own), the plain-data reading of an MCP server's `transport.env` / `transport.headers` or an A2A peer's `headers`, the startup bound's range for `startupTimeoutMs`, and the membership of `TaskSpec.promptRole`. `TaskSpec.promptRole` also spelled the four role words a second time, as an inline union beside the exported `HOST_ROLES`.
|
|
63
|
+
- **Now.** (1) `sameOrgPolicyContent(a, b)` (#2060) is the one comparator the partition's equal-revision guard and the file backend's install of org state both ask: row for row the same policy members (`rule`, `behavior`), never observation metadata (`fetchedAtMs`, `signature`) and never a member no judgment reads. (2) `receiveDeclaration(declared, rules)` and `DECLARATION_PATH_RULES` (#2076; types `DeclarationRead`, `DeclarationFault`, `DeclarationPathRule`) are the engine's receipt of a caller's declaration and the per-surface path rules the preparation door asks it with (`receiveDeclaration(entry, DECLARATION_PATH_RULES[at])`, `at` a `ToolAxesSurface`): `ok: true` carries the engine's frozen plain-data copy (the caller's object is never handed on), `ok: false` the path and the kind of fault, never a value. `rules` is a required argument and must be a list: a surface's row, or `[]` for a declaration that fixes no path; an absent value (what an `at` outside the two surfaces indexes to), `null`, a string or an inherited member of the table is refused with `config.invalid_argument`, the code `readToolAxes` gives an unknown `at`, rather than read as "no path rule". The table is now frozen down to each row and its path (it was frozen only at the table and the lists, which a package-root export would have left rewritable by any consumer, changing what every door judges; nothing in the engine writes to it). A declaration that is not a record (`null`, a string, a list) is answered as it is, ok, for the caller that expected a record to word — the door's own refusal of those is its next step, not this function's. (3) `mcpStartupTimeoutMs(server?)` (#2084) is the one reader of an MCP server's startup bound: the server's own `startupTimeoutMs`, else env `MCP_TIMEOUT`, else `undefined` (the SDK's default); a declared value that is not a whole number of milliseconds in range is refused with `config.mcp_startup_timeout_invalid`, naming the server (`(unnamed)` for an entry with no usable `name`, which the sentence used to read for its text and fail on with a bare `TypeError`). The `brain.call` trace row's `turn` note now says what the implementation does: `turn` is present on the main loop's own provider call and absent on every call fired beside the loop (the compaction summarizer, the classifier gate, a mod's call), whose rows also carry `latencyMs: 0`. (4) `isHostRole(value)` (#2085) is the membership test of `HOST_ROLES` (`"general"`, `"code"`, `"coordinator"`, `"cowork"`; a root export since 7.52.0): `resolveHost` (the task door for `TaskSpec.promptRole`) and the overlay grammar's `when.host.role` check now call it instead of each writing the membership, and `TaskSpec.promptRole` is typed `HostRole`, the one statement of the set. The ticket named the set `PROMPT_ROLES`; it already exists as `HOST_ROLES`, so there is no second name for it. A host that carried a copy of an engine role's text (the code role's prompt, sent as `systemPrompt`) retires it by selecting the role — `TaskSpec.promptRole: "code"` renders the engine's own code variant — and must also stop sending `systemPrompt` beside it: `systemPrompt` replaces the selected role base, so given together the host's text wins over the engine's.
|
|
64
|
+
- **Behavior narrowing: none.** Every export is the value the engine's own module holds (pinned by identity), the two readers of the role set ask the same predicate they wrote inline before, and `promptRole`'s accepted values are unchanged. The changes to existing values: the path-rules table's rows are frozen (nothing writes to them), and `receiveDeclaration` loses its `rules = []` default (it was not exported; the engine's two callers that fix no path now pass one empty table).
|
|
65
|
+
- **Who is hurt.** Nobody: additive exports plus a type spelled through its alias (the same four members; `test/backlog2060-exports-batch.test.ts` E-2085.c checks it at compile time).
|
|
66
|
+
- **How to migrate.** *server*: the MCP request door's `transport.env` / `transport.headers` and the A2A `headers` reading (`plain-string-record.ts`) can ask `receiveDeclaration(entry, DECLARATION_PATH_RULES[at])` and word a refusal from `fault.path` / `fault.problem` (measured against `plain-string-record.ts` over eighteen `transport.env` values, the two answer alike on sixteen; they differ on a member holding `undefined`, which the engine reads as absent and `plain-string-record.ts` refuses, and on an own `__proto__` member holding a string, which the engine refuses and `plain-string-record.ts` strips from its copy); the `mcpServers[i].startupTimeoutMs` intake asks `mcpStartupTimeoutMs({ name, startupTimeoutMs })` and answers a `config.mcp_startup_timeout_invalid` throw as a 400 naming the member; a persistence backend of its own for the org state asks `sameOrgPolicyContent` for "same revision, same content". *any host*: read the role set from `HOST_ROLES` / `isHostRole`, not from a copy.
|
|
67
|
+
- **Pins.** `test/backlog2060-exports-batch.test.ts` (E-2060.a–b, E-2076.a–h, E-2084.a–c, E-2085.a–e: each root export is identical to the engine's own value, plus one behaviour per judge; E-2076.e compares the root receipt with the preparation door's own refusal over twelve declarations on both surfaces); `test/export-surface.snapshot.json` (+8 names).
|
|
68
|
+
|
|
69
|
+
### Small fixes: a checkpointed workspace handle is delivered as one copy; the org partition's refused snapshot copy has its own type; a pre-claim parked-resume refusal names its code, field and reason; a session branch is read in linear time (#2058, #2066, #2083, #2092; behavior change — open to veto; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `details.detail` on the parked-resume startup receipt) (@server)
|
|
70
|
+
|
|
71
|
+
- **#2058 (behavior change, open to veto; type-level: two `Readonly`).** On a durable resume the env factory was handed a frozen copy of the checkpointed workspace handle (`ExecutionEnvFactoryContext.resumeWorkspace`), the adapter's `resumeVM` was handed the stored row's OWN object as `VmLifecycleOptions.priorHandle`, and the deployment's `resumePreflight` a structured clone of it (`ResumePreflightInfo.workspaceHandle`; a handle a custom store returns as a Proxy made that clone throw before the preflight ran). `frozenHandleCopy(handle)` now answers one copy per live handle object, and all three deliveries are that copy: the preflight's facts, the factory's `resumeWorkspace` and the restore's `priorHandle` are the same frozen object holding the declared handle members (`WORKSPACE_HANDLE_MEMBERS`) that the row carries as strings. `priorHandle` and `ResumePreflightInfo.workspaceHandle` are typed `Readonly<WorkspaceHandle>`. The engine's own comparison of the row against the restored handle is unchanged and still reads the row's own object.
|
|
72
|
+
- **Who is hurt (#2058).** An adapter that writes to `priorHandle` (the write now throws in a module, where it used to edit the stored row's object for the next attempt of the restore), one that reads a member the interface does not declare off `priorHandle` (a decoration a store kept on the row; it is no longer delivered), and a preflight hook that mutates `info.workspaceHandle` or reads such a member. Nothing else: a conforming adapter reads `mountPath` / `snapshotId` / `provider` / `deviceId`, which are all delivered unchanged. *server*: no migration; a deployment adapter that rebinds the restore on `priorHandle.mountPath` is unaffected. Pins: `test/backlog2058-prior-handle-same-copy.test.ts` (P1 the three deliveries are one object, frozen and equal to the row; P2 a write to `priorHandle` throws and the row is untouched, a decoration is not delivered; P3 / P3b a Proxy handle resumes through a wired preflight and the preflight's copy is the factory's and the restore's; P4 the copy function; P5 readonly at type level); `test/design469-r3-fence.test.ts` F1 re-anchored (its comment and assertions had stated the restore received "the engine's own handle", the one fact this fix changes).
|
|
73
|
+
|
|
74
|
+
- **#2066 (type, no behavior change).** The org partition's copy of a snapshot whose rules list the list judgment refused (not a list, a Proxy, past 256 places) had no type of its own: it was held as an `OrgRuleSnapshot` whose `rules` was a frozen refusal stand-in, behind an `as unknown as` that the type-hygiene ratchet counted (`src/core` 43 → 44 in 7.53.0). The held copy is now `HeldOrgSnapshot = OrgRuleSnapshot | RefusedOrgSnapshot`: the refused arm carries the snapshot's envelope and the words it was refused in (`refused`) and no `rules`; `validateOrgSnapshot` is the one way to an installable snapshot (`{ admitted }` or `{ refused }`), and install and serve read the admitted arm, so a refused copy cannot be read as a table of rows (a zero-row policy). The stand-in object and its WeakMap are gone, and the `src/core` hygiene stock is 43 again. The partition's verdicts are unchanged: 23 refused and control inputs, fetched and persisted, resolve to the same status, revision, rules served, installs and disclosure sentences as before (recorded from the tree this change started from). The new types and `orgSnapshotCopyOf` / `validateOrgSnapshot` are module exports for the pins; the package root does not carry them. Pin: `test/backlog2066-org-snapshot-copy-union.test.ts` (U1 shapes, U2 verdicts, U3 `@ts-expect-error` negatives, G the 23-scenario table, H the retired assertion); `test/type-hygiene-gate.test.ts` baseline `src/core` 44 → 43.
|
|
75
|
+
- **#2083 (additive; a new `details` member on an existing code; closed sets: none — no count changes) (@server S-1708).** The parked-resume receipt of a background sub-agent (`details.error: "parked_resume.startup_failed"`, `details.cause`) named only the cause class when the resume was refused before the claim: the refusal's own `CheckpointError.detail` (`code`, `field`, `reason` — which input and which arm refused) was dropped on the way, so a consumer gating the claim could read the sentence and nothing else. The receipt gains `details.detail = { code?, field?, reason? }`: each a string, the words of the thrown error's own families (`checkpoint.config_refused`'s prepare code `detail.code` and its input `detail.field`; the arm of one code, `detail.reason`), read from the failure's coded account by the one picker `resumeRefusalDetailOf` (`src/core/resume-startup-cause.ts`). **In-presence:** only when the resume was refused by a thrown failure whose coded `detail` states at least one of the three as a string; a member not stated is absent, the whole `detail` is absent when none is, and a returned failed result (a leg that ran past the claim) carries none. The sentence, `cause`, `error` and `task_id` are unchanged; no word is added to any closed set. *server*: `parked-decide.ts`'s 422 body can pass `detail.code` / `detail.field` / `detail.reason` through as `blockedBy` / `field` / `reason`, the keys the top-level and synchronous legs already carry. Pin: `test/backlog2083-park-claim-refusal-code.test.ts` (D1–D2 the members on a config refusal, the arm of one code, a lone member; D3 sentence / cause / error byte-same; D4 absence — no detail, other members only, non-strings, a throwing getter; D5 the picker, own members only).
|
|
76
|
+
- **#2092 (performance, no behavior change).** `getPathToRoot` (the one root-bound path read behind `getBranch`, `buildContext` and every engine read of a local-backend session) built its answer with an `unshift` per entry, so the cost of reading a branch was quadratic in its length (20,000 entries ≈ 0.5 s per read, measured by the server). It now collects leaf → root and reverses once. The returned path, its order and every stopping rule are unchanged: the floor stops the walk, an entry with no parent stops it, a parent missing above the floor is `invalid_session`, an unknown leaf is `not_found`. Pin: `test/backlog2092-path-to-root-linear.test.ts` (B1 the order in three shapes, byte-for-byte; B2 a 20k chain walked with zero front-insertions — a count, not a wall-clock budget, which the hard gate forbids).
|
|
77
|
+
|
|
78
|
+
### The store vocabularies a backend checks rows against are package-root values, each spelled once in the engine: `PERSISTED_RULE_MATCHES`, `RULE_OFFER_BATCH_MEMBER_KINDS`, `UNCOVERED_SEGMENT_REASONS`, `EDITED_RULE_BREADTH_WARNING_CODES`, `RULE_APPROVAL_RECORD_SCHEMA`, `FILE_HISTORY_ERROR_CODES` (#1468 stage one, K1 — the 7.55.0 theme; additive exports; hard-breaking: none; closed sets: none changed — six existing vocabularies gain their value, members and order unchanged; exports: +6 constants; members: none) (@server)
|
|
79
|
+
|
|
80
|
+
- **Before.** The engine published the TYPES of these six lists only. The server kept a runtime copy of each — five in `src/permission-rule-vocab.ts`, one in `src/file-history-disclosure.ts` — bound to the engine's type by a compile-time equality pin, and read the copies in its stores and doors (`z.enum(...)`, the approval-record stale gate, a text code reader). Inside the engine, the consent record's read-back spelled the three reason words out again in two clauses (the record's damage check and the card projection) and the two warning codes in one, and the record stamp `4` was written four times (three mints, one stale gate).
|
|
81
|
+
- **Now.** Each list is a frozen tuple (the record stamp a constant) in the module that owns it, the public type is derived from it — the unions are literally the same — and the package root exports that value. Engine constant → export → the server copy it replaces (sema-server origin/main `5df75153`): `PersistedRuleMatch` (`src/core/permission-rule-model.ts`) → `PERSISTED_RULE_MATCHES` → `src/permission-rule-vocab.ts:45`; `RuleOfferBatchMember["kind"]` → `RULE_OFFER_BATCH_MEMBER_KINDS` (the union keeps its literal arms, two compile fences bind them to the tuple both ways) → `:79`; `UncoveredSegmentDetail["reason"]` → `UNCOVERED_SEGMENT_REASONS` (`UNCOVERED_SEGMENT_REASON_BASELINE` is keyed by it) → `:84`; `EditedRuleBreadthWarning["code"]` → `EDITED_RULE_BREADTH_WARNING_CODES` → `:89`; `RuleApprovalRecord["schema"]` (`src/core/permission-rule-consent.ts`) → `RULE_APPROVAL_RECORD_SCHEMA` (`4`) → `:99`; `FileHistoryError["code"]` (`src/core/file-history-store.ts`) → `FILE_HISTORY_ERROR_CODES` → `src/file-history-disclosure.ts:193`. The consent read-back clauses and the three mints read the values instead of spelling them. The session-rule bucket names are not exported: the server retired its copy of them itself and reads the engine's session-rules judges (`readSessionRulesShape`, `judgeSessionRulesWrite`), so no copy is left for a value to replace.
|
|
82
|
+
- **Behavior narrowing: none.** The same members in the same order as the server's copies; the read-back clauses accept and refuse exactly the values the spelled-out comparisons did — the record's two clauses compare word by word in the tuple's order and stop at the first match, so even a value a host's approval-record store hands back behind an accessor is read as often, and in the same order, as before; the card projection keeps its one read of each value.
|
|
83
|
+
- **Who is hurt.** Nobody: additive exports and types re-spelled through their own derivation.
|
|
84
|
+
- **How to migrate.** *server* (stage two): import the six names from the package root and delete the six copies with their pins (`permission-rule-vocab.ts:45` / `:79` / `:84` / `:89` / `:99`; `file-history-disclosure.ts:193` and its two difference fences); `z.enum(PERSISTED_RULE_MATCHES)` and the other readers take the frozen tuples as they took the local ones. `RULE_OFFER_BATCH_MEMBER_KINDS` has no reader in the server today (only its pin).
|
|
85
|
+
- **Pins.** `test/backlog1468-k1-vocabulary-twins.test.ts` (per vocabulary: ① the root export is the module's own constant, ② frozen, ③ the same set as the server copy, ④ at compile time the derived union equals the union the type spelled before; ⑤ the collapsed read-back clauses accept every member and refuse thirteen foreign values, a live store's accessor rows get the verdict the spelled-out comparisons gave, the stale gate reads only the stamp as live, and the card, import and starter mints write it); `test/export-surface.snapshot.json` (+6 names).
|
|
86
|
+
|
|
87
|
+
### Five more store vocabularies are package-root values, each spelled once in the engine: `RULE_APPROVAL_KINDS`, `RULE_APPROVAL_STATES` (+ type `RuleApprovalState`), `IMPORTED_SETTINGS_LAYERS`, `RULE_ADD_ORIGINS`, `RULE_OWNER_KINDS` (#2097, #1468 stage one — the 7.55.0 theme; additive exports; hard-breaking: none; behavior narrowing: none; closed sets: none changed — five existing vocabularies gain their value, members and order unchanged; exports: +5 constants, +1 type; members: none) (@server)
|
|
88
|
+
|
|
89
|
+
- **Before.** The engine published only the TYPES of these lists (the approval record's `state` was an inline union with no name). The server's stores and doors spelled each list out again inline — `z.enum([...])` or per-arm `z.literal(...)` — bound to the engine's type one way at most (a list that is a subset of the engine's still compiles where it is assigned into the engine's type), so a word the engine adds would be refused there with no compile error to say so. One inline copy is held both ways: the local-import door's add `origin` (`src/http/routes/rules.ts:126`), through that door's row-shape equality pin (`:164`). Inside the engine, the sync wire's add reader and the session overlay's add check each spelled the three provenance words out as three comparisons.
|
|
90
|
+
- **Now.** Each list is a frozen tuple in the module that owns it and the public type is derived from it — the unions are literally the same; `RuleOwner` keeps its literal arms (its `principal` arm carries the name), and two compile fences bind the arms to `RULE_OWNER_KINDS` both ways. The two add checks read `RULE_ADD_ORIGINS`. Engine constant → the server's inline copy it replaces (sema-server origin/main `5df75153`): `RULE_APPROVAL_KINDS` → `src/plugins/permission-rule-store-file.ts:114`, `src/plugins/permission-rule-store-sql.ts:1138`; `RULE_APPROVAL_STATES` → `permission-rule-store-file.ts:115`, `permission-rule-store-sql.ts:1139`; `IMPORTED_SETTINGS_LAYERS` → `src/http/routes/rules.ts:80`; `RULE_ADD_ORIGINS` → `rules.ts:126`, `permission-rule-store-sql.ts:157`; `RULE_OWNER_KINDS` → the owner arms at `permission-rule-store-file.ts:109` / `:190`. The durable scope gets no list: its rule is a judgment (two members, and a project root must be a complete absolute path), and the existing package-root judge `isValidDurableScope` is what `permission-rule-store-sql.ts:146-149` (`DurableRuleScopeSchema`) restates.
|
|
91
|
+
- **Behavior narrowing: none.** The same members in the same order as each type spelled them; the two collapsed add checks compare word by word in the tuple's order and stop at the first match, so they accept and refuse exactly the values the spelled-out comparisons did.
|
|
92
|
+
- **Who is hurt.** Nobody: additive exports and types re-spelled through their own derivation.
|
|
93
|
+
- **How to migrate.** *server* (stage two): import the names from the package root and read them where the inline lists stand (`z.enum(RULE_APPROVAL_KINDS)` and the others take the frozen tuples as they take literal arrays); the scope shape asks `isValidDurableScope`.
|
|
94
|
+
- **Pins.** `test/backlog2097-rule-vocab-twins.test.ts` (per vocabulary: frozen, the root export is the module's own constant, the words in order are the type's words as it spelled them; at compile time each derived union equals the union it spelled before; the sync wire's reader and the session overlay's door judge 24 values — the three members and 21 foreign ones — exactly as the spelled-out comparison does); `test/export-surface.snapshot.json` (+6 names); `docs/CLOSED-SETS.md` (+5 rows).
|
|
95
|
+
|
|
96
|
+
### Release-process docs and gates: the live ruling vocabulary and serial lane, the pre-tag wire-manifest check, the 7.53.0 criteria erratum (#2067, #2068, #2072; release tooling, tests and docs only; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
97
|
+
|
|
98
|
+
- **#2067 (live gate; no `src/` change).** The `#842` live cell (`test/live-write-protect-ask.test.ts` ①) read the classifier's ruling through a pattern naming a word the verdict type does not have (`approve`), so a model answering "no" — `allow`, a legitimate ruling — read as "no ruling". The vocabulary is now a table keyed on `AutoModeVerdict["kind"]` (`allow` and `block` are rulings, `unavailable` and `parse_error` are not), pinned against the real `parseAutoModeResponse` in the same file; that block runs without the live environment. `scripts/run-live-gate.mjs` gains a serial lane: the files named in `SERIAL_LIVE_FILES` (that one) run alone in their own vitest invocation after the shared batch, each batch is judged on its own targets, and every batch runs even when an earlier one is red (`test/backlog2067-live-serial-lane.test.ts`, a fake `npx` on `PATH`). Not established: that the gate's concurrency caused the 7.53.0 RC2 red — the lane removes it as a variable rather than claiming it. The question wording is unchanged; a stable `spelling = allow` / others = `block` disagreement would be #842's own disclosure and is not this ticket.
|
|
99
|
+
- **#2068 (release gate; no `src/` change).** The wire manifest the tag carries is computed from `src/`, the export snapshot and the version's criteria file; a fix merged after it was generated left the package's copy describing an older parent (7.53.0: computed at `3c775d132`, five input files moved afterwards, and only the post lint — after the tag — said STALE). `npm run release:preflight` (`scripts/release-preflight.mjs`) runs that same staleness judgment before the tag: it reads the manifest from the RC tree rather than the working tree, takes the version from the RC tree's `package.json` (the manifest must carry the same one), reads a two-stage manifest's `v<version>` as pointing at the RC, checks the base is the previous tag and that no input moved since it was computed, and exits 1 naming each moved file. The judgment is one function now (`manifestRangeProblems` in `scripts/ship-post-lint.mjs`, which calls `staleManifestProblems`; the post lint's WIRE leg uses it unchanged). `docs/RELEASE-TIERS.md` adds it to the R checklist as item 6: regenerate the manifest as the last step before the tag, once more after every car merged since (`test/backlog2068-release-preflight.test.ts`, real tags as fixtures: 7.53.0 reds, 7.52.0 and 7.51.2 pass). It is a one-off pre-tag check outside the `gate:*` family, so it is not in the `gate-all` roster.
|
|
100
|
+
- **#2072 (docs only; `docs/` is not in the package; no `src/` change).** The 7.53.0 criteria document's `v7.52.0:` front states for G10.d, G10.e, G19.a and G19.b (and the F15 / F23 `before` texts that repeated them) do not reproduce on the real v7.52.0 tag (they described an intermediate state of the 7.53.0 stage); each is rewritten to what the tag does, measured, and marked `erratum #2072` in the text (the criteria schema carries no separate erratum member, so the mark rides the field it corrects): the throwing-`behavior` row made the lane throw on `Bash` calls and the partition refuse the snapshot; class instances, `Date` / bigint / typed-array rows and another realm's plain object were read as published; a list-shaped row was held in memory with its members and persisted as `[]`; the start block already taught `s` before any tool was allowed and the disconnect line already went out in (b). G1's setup spells `createRoutingBrain(routes, { fallback })`; G15.e's 8 000-level claim is limited to the screening seat (the engine's one remaining `structuredClone` of a screening's answer is #2048); G15.f is marked owner-pin-only (#2071). The 7.53.0 section's #1042 line names `TaskSpec.clientContext.timeZone`. `docs/INTEGRATION-CORE.md` 12.13 gains the `contentHash` reading rule: `salted:` values are keyed with a per-process random salt (`randomBytes(16)`, never persisted) and compare only within one engine process; `sha256:` values compare anywhere; a host dispatches on the prefix (`test/backlog2072-docs-errata.test.ts` ties the `contentHash` statement, the G1 call form, the erratum marks and the zone path to the code or text they concern; the measured v7.52.0 readings are history and are not pinned).
|
|
101
|
+
|
|
102
|
+
### `EngineStorageBackend` is the engine's storage seats as one contract — ten seat stores, `dispose()`, and an optional backend-level `retention` executor — exported from the package root, and `FileStorageBackend` implements it (7.55.0 theme version, #1468 stage one K3; additive export; behavior change: none; hard-breaking: none; closed sets: none; exports: +1 type; members: none on any existing type) (@server)
|
|
103
|
+
|
|
104
|
+
- **Before.** What "a storage backend" is existed twice and was a contract nowhere: core's `FileStorageBackend` class (at the base, `src/stores/file/index.ts:179-394`: the ten seat stores below and the file-only `root` / `memoryStore` / `consolidationLock` at `:179-225`, `dispose()` at `:355`), and the core-seat half of the server's `StoreBackend` interface (server `src/plugins/store-backend.ts:194-417`: `session()`, `checkpoint?()`, `toolResult?()`, `sessionPolicy()`, `workflowJournal()`, `fileHistory()`, `fileUsageWindowStore?()`, `retention?()`, `close()`, interleaved with the server's own product stores). A second backend had no type to implement. The class's doc example still passed `memoryStore` to `new Runner`, a seat removed earlier.
|
|
105
|
+
- **Now.** `EngineStorageBackend` (`src/core/engine-storage-backend.ts`) has ten members, each named for its `RunnerDeps` seat and typed as that seat's store: `sessionStore`, `checkpointStore`, `toolResultStore`, `sessionPolicyStore`, `workflowJournalStore`, `usageWindowStore`, `backgroundAgentStore`, `mailboxStore`, `rosterStore`, `fileHistoryStore` (the store only: the seat's `"disabled"` word is the deployment's). It also has `dispose()` and `readonly retention?: ManagedRetentionCapability`. The type is the existing contract in `src/core/retention.ts:64-79`, not a new one. It is the backend-level executor, because the capability's session deletion re-checks references held by several stores inside one transaction. It is an object, not a store's `"managed"` / `"none"` declaration word. `FileStorageBackend` gains `implements EngineStorageBackend` and nothing else: no member is added or changed, and it does not carry `retention`. Non-members, each with its reason, are classified in the pin: `workflowRunStore` and `workflowScriptStore` (the file backend does not construct them), `permissionRuleStore` (the seat takes the composed provider), `memoryBackend`, `memoryCaptureRecordStore` and `sharedMemoryStores` (not keyed by the storage root), `peerDirectory` and `promptSource` (host-fed; no bundled backend constructs them). A new `RunnerDeps` seat stays red in that pin until it is classified if it is named like a store (`…Store` / `…Stores` / `…Backend`) or typed as one of the contract's store types. A seat with neither a store's name nor a contract store type is outside that guard, and the pin says so. Server parity, member by member (server `5df75153`): `session()` → `sessionStore`, `checkpoint?()` → `checkpointStore`, `toolResult?()` → `toolResultStore`, `sessionPolicy()` → `sessionPolicyStore`, `workflowJournal()` → `workflowJournalStore`, `fileHistory()` → `fileHistoryStore`, `fileUsageWindowStore?()` (local) → `usageWindowStore`, `retention?(opts)` → `retention`, `close()` → `dispose()`. `backgroundAgentStore`, `mailboxStore`, `rosterStore` and the SQL `usageWindowStore` are built in server `src/boot/stores.ts:398-561`, outside `StoreBackend`. The class's doc example now names seats that exist.
|
|
106
|
+
- **Behavior narrowing: none.** The built JS is byte-identical to the base except for one new type-only module (`export {};`). The declarations add the `implements` clause, the import and the root export.
|
|
107
|
+
- **Who is hurt.** Nobody: one added type and a structural clause on a class whose members did not move.
|
|
108
|
+
- **How to migrate.** *server* (stage two): type the core half of `StoreBackend` as one `EngineStorageBackend` composed with the product stores. That means `close()` → `dispose()`, the four seats built in `boot/stores.ts` move under the engine backend, and the `retention?(opts)` factory becomes the `retention` object. Its `taskAttachmentTable` option names a deployment table; in the SQL package's design, deleting deployment rows is the deployment's cascade hook, not an executor option. *Any host with its own backend*: implement `EngineStorageBackend` and wire it by naming the seats, not by spreading a class instance (an instance carries members that are not seats).
|
|
109
|
+
- **Pins.** `test/backlog1468-k3-engine-backend.test.ts` (compile time: every member names a seat, member and seat types match both ways, every seat named or typed like a store is classified, no seat is both, the file backend satisfies the contract; run time: a real Runner wired through the ten members runs a task into the backend's data root, the file backend carries no `retention`, the store-level retention judgment reads as before, a backend carrying the executor is refused when it is handed over as a store, and `dispose()` releases the root); `test/export-surface.snapshot.json` (+1 name).
|
|
110
|
+
|
|
111
|
+
### 7.55.0 theme release, #1468 stage one S1: `sessionStoreContract(make, hooks)` — the `SessionStore` interface's obligations as one cross-backend case set, exported from the package root (#1468; additive export; behavior change: none; hard-breaking: none; closed sets: none; exports: +1 function, +1 type `SessionStoreContractHooks`; members: none) (@server)
|
|
112
|
+
|
|
113
|
+
- **Before.** The obligations a session store owes the engine were written as sentences on `SessionStore` (`src/core/session.ts:141-326`: concurrent acquire is one session, `requireExisting` never creates, `forget` never deletes, `noteTaskRun`'s two seats move together, placement obligations 1–6, `ownerOf`'s three states, `register`'s first-write-wins, `fork`'s ownership inheritance and committed-leaf snapshot) and on the session-tree storage it hands out (`src/engine/session/storage-base.ts:124-190`: the write CAS, a path that stops at a root or the declared floor, a missing parent above the floor is `invalid_session`). The only executable checks were `TtlSessionStore`'s own tests and one narrow kit, `sessionStoreIncarnationContract` (`src/core/store-contracts/session-store-incarnation-contract.ts`). A backend implementing `SessionStore` directly (the server's SQL stores and its local-lane store) re-wrote its own copies of these checks or none (`test/core-probe-session-stores.test.ts` pins `TtlSessionStore`'s observed behavior from the outside).
|
|
114
|
+
- **Now.** `sessionStoreContract(make, { deleteSession, reopen, replica?, runAssertion?, onOptionalMember? })` (`src/core/store-contracts/session-store-contract.ts`; export `src/index.ts`, the `// #1468 S1` block) runs 43 cases in eight groups: `[acquire]` (5), `[tree]` (8, the storage the store hands out), `[rows]` (4: a session lives in the rows; a leaf switch persists by itself; a woken session — whole or from a bounded window — rebuilds the context the writer saw; `forget` keeps history), `[fork]` (3), `[list]` (3; activity order is judged on `lastActiveAt` itself, ties in either order), `[placements]` (8: obligations 1–6, the creation squat `TtlSessionStore` refuses, a non-declaring store still creating, and `listPlaced`), `[ownership]` (6) and `[incarnation]` (the six cases of `sessionStoreIncarnationContract`, the same case text: both kits now register them through one module-internal function). Only today's obligations are encoded; an optional face a store lacks is reported through `onOptionalMember` (`verified` / `absent` per face, `replica` for the optional hook, and `requireExisting` for a store that takes the interface's other legal mode and rejects the option — such a store passes, its sessions are read back with a plain reopen), never passed in silence. One sentence the interface states today is not a case yet: an ownership row outliving its session across a restart (`session.ts:300-302`) — the bundled `TtlSessionStore` keeps owners in process memory (`src/core/session-store.ts:117-121`) and would fail it; the row-cell owner that makes it true is stage one S2. Same harness shape as the other store kits (`ContractAssertionRunner`; vitest-free). `reopen` may resolve later (a backend whose restart must first release an exclusive lock, such as `FileStorageBackend`). The hooks record is read by the engine's receipt law (`receiveOwnProperties`, `src/core/own-property-receipt.ts`) and a record the kit cannot run with — `make`, `deleteSession` or `reopen` not a function, or `replica` / `runAssertion` / `onOptionalMember` present and not a function — is refused synchronously with `config.invalid_argument` naming the hook, before any case is registered (never a `TypeError` from inside a case).
|
|
115
|
+
- **Server counterpart.** The server mounts the incarnation kit on its SQL stores in `test/store-contract-kit-db-integration.test.ts` §9 (`createVitestCaseRunner` wipes the session tables per case); the same binding shape runs this kit. Run against the server's `LocalSessionStore` (read-only, this tree's kit over the server checkout) the kit names three divergences from the interface: a fork copies an in-flight assistant turn (`src/plugins/local-session-store.ts:1002`; the SQL twins' `fork` at `src/plugins/pg-session-storage.ts:648` copies every event too), a fork without an owner argument does not take the source's owner, and a first `register` whose owner is `undefined` resolves instead of being refused.
|
|
116
|
+
- **Behavior narrowing: none.** No production path calls the kit; the incarnation kit's six cases are unchanged (same names, same assertions, `test/backlog1468-s1-session-store-kit.test.ts` K3).
|
|
117
|
+
- **Who is hurt.** Nobody: one new function and one new hooks type. A backend that adopts the kit may find red cases — those are divergences from sentences the interface already states.
|
|
118
|
+
- **How to migrate.** A store implementer mounts `sessionStoreContract(() => freshStore(), { deleteSession, reopen, runAssertion: (name, fn) => void it(name, fn) })` and asserts the `onOptionalMember` report for the faces it relies on. *server*: mount it next to §9 on the SQL twins, `CachingSessionStore` and `LocalSessionStore`; the three divergences above are the first reds.
|
|
119
|
+
- **Pins.** `test/backlog1468-s1-session-store-kit.test.ts` (K1: four lanes green — `TtlSessionStore` over the in-memory repo, the same reopened from a bounded window, over `FileSessionRepo` with placements declared, and `FileStorageBackend.sessionStore` — each with its exact face report; K2: three positive controls (the memory lane, the file lane, a store that rejects `requireExisting` outright) and twenty-one mutants of the bundled store, each reddening the case that pins its obligation — among them a read-then-write `register`, a leaf switch kept only in the handle, and a lost CAS refused with a hand-rolled error that only carries `code: "conflict"`, which the kit refuses because the engine's reconcile retry (`isSessionConflict`) does not recognize it; K3: the incarnation group is the standalone kit's six cases; K4: the root export).
|
|
120
|
+
|
|
121
|
+
### (7.55.0 theme release, #1468 stage one K2) The computations and judges a store backend must answer exactly as the bundled stores do are package-root exports of the very functions those stores call: `canonicalJsonStringify`, `parkedApprovalCardOf` (+ type `ParkedApprovalCardReading`), `admitSessionPlacement`, `incarnationCell` / `forkRowCells` / `assertForkTarget`, `readIncarnationCell`; core's three literal approval-gate pairs read the pause registry (#1468; additive exports; behavior narrowing: none; hard-breaking: none; closed sets: none; exports: +7 functions, +1 type; members: none) (@server)
|
|
122
|
+
|
|
123
|
+
- **Before.** A backend that stores what core mints had to re-write four of core's computations, because core kept them inside its modules: the canonical JSON the memory export bundle hashes over (`canonicalJsonStringify`, in the memory File backend, not exported), the card a parked approval gate carries (an inline literal pair in `summarizeCheckpoint`), the placement admission of `acquire` (a private `TtlSessionStore` method, with the partition declaration read off `this`), and a session row's occupant cells (`incarnationCell` / `forkRowCells` / `assertForkTarget`, reachable only through the internal harness facade); a stored incarnation cell had no shared decoding at all. Core itself spelled "is this an approval gate" as a literal `human` / `irreversible_ask` pair in three places instead of reading the registry.
|
|
124
|
+
- **Now.** (1) `canonicalJsonStringify(value)` moved verbatim (byte-identical body) from `src/core/memory-engine/file-backend.ts` into the neutral leaf `src/core/canonical-json.ts:423`, beside `canonicalize`; the File backend, the engine and the bundle module import it from there (no re-export left behind). It is not `canonicalize`: that one is the type-tagged, length-prefixed, non-JSON serializer of the bound-input hash, deliberately unexported because nobody may recompute that hash across the wire; the two answer different questions and their outputs are not interchangeable, so they are not merged. Server counterpart: `canonicalJson` (`src/canonical-json.ts:40`, a verbatim port; `canonicalJsonStrict` is server validation policy and stays). (2) `parkedApprovalCardOf(gate)` (`src/core/pause-registry.ts:81`) answers four ways: `{ kind: "card", card }` (an approval gate carrying a closed-set word), `{ kind: "no_card" }` (an approval gate with no card), `{ kind: "unknown_word", word }` (an approval gate carrying anything else, `word` the value as read), `{ kind: "not_approval" }` (a registered gate whose `pending` column is not `tool_approval`, or a kind the registry does not hold). Each gate member is read once; it is pure and says nothing about loudness. `summarizeCheckpoint` projects `approvalCard` only on a `card` answer. Server counterpart: `parkedApprovalCard` + `readApprovalCard` (`src/approval-content-kind.ts:51` / `:31`). Name and shape differ from the design draft's `approvalCardOf(gate) → card | undefined`: core already has an internal `approvalCardOf(call, declared)` (the card a live call renders under) and one name must not mean two functions; a two-answer result would have left server re-reading the registry and the raw card to tell an out-of-set word from absence. (3) `admitSessionPlacement(sessionId, persisted, request, capability: { subagentPlacement })` (`src/core/session-store.ts:113`) is the retired private method's body with the declaration as an argument; `TtlSessionStore` calls it on its four resolution paths (cache hit, pending-promise inherit, open, idempotent re-create) and reads its declaration at each call. Server counterpart: `assertPlacementAdmission` (`src/plugins/session-placement.ts:151`, a declared-partition store: `subagentPlacement: true`). (4) `incarnationCell` / `forkRowCells` / `assertForkTarget` (`src/engine/session/repo-utils.ts:19` / `:43` / `:38`, the bundled repos' one mint) are root exports, plus `readIncarnationCell(raw, disclose)` (`:29`): a non-empty string is the token; `undefined` / `null` is an unknown occupant, silently; `""` or a non-string is an unknown occupant and `disclose` is told once with the cell's shape, never its value. Exported only: the File repo's header read is unchanged (it still hands out `""` as a token and still discloses `null`); switching core's and server's readers to the decoder is a later change. Server counterpart: `mintSessionIncarnation` / `incarnationFromCell` / `SessionIncarnationMissingError` (`src/session-incarnation.ts:45` / `:103` / `:77`). (5) The policy-ask capture lane keys on the row's registry `resume` column (`resumeGateMatches(kind, "policy_ask")` over the row's kind read once, `src/core/runner/resume-policy-outcome.ts:78-79`), the wake refusal names its decide from the same column (`src/core/runner/resume-admission.ts:22` / `:242`; a registry row with a new resume word now fails the build until its copy exists), and the summary's card reads the `pending` column through (2).
|
|
125
|
+
- **Behavior narrowing: none.** Every existing reader answers byte-for-byte as before: the summaries of every gate kind × card value, the placement outcomes of every resolution path (declared and undeclared, the declaration set or withdrawn after construction), the wake refusal copy per gate kind, the policy lane on a question row and an approval row, the bundle and erasure-selector hashes, and the File header's incarnation read were printed from the base tree and from this tree through their real entry points and compared identical (28 547 bytes). One read-count change, reachable only through a stored row whose gate is accessor-backed (no bundled or SQL store returns one; a custom store that hands back live objects could): the row's gate kind is now read once per judgment where it was read up to twice (the policy-ask lane, the summary's card), so an accessor that answers differently on a second read now gets the lane / card of its first answer. The placement declaration keeps its read position exactly: read only when the mirror direction is judged, after the request's members.
|
|
126
|
+
- **Who is hurt.** Nobody: additive exports. The serializer's old module path was never importable (the package's `exports` closes deep paths), so its move breaks no import.
|
|
127
|
+
- **How to migrate.** *server*: `canonicalJson` becomes `canonicalJsonStringify` (the same body, so the same bytes on every input, the two inherited non-JSON edges included); `parkedApprovalCard` reads `parkedApprovalCardOf(gate)` and counts `server.approval-card.closed-word-out-of-set` exactly on `unknown_word` (the current tuple and SQL `IN` literal `APPROVAL_GATE_KINDS` / `_SQL_IN` retire with the stage-two checkpoint car); render `word` only bounded and redacted (it is the stored value, unscreened), and do not let an `unknown_word` go silent (warn). `assertPlacementAdmission` becomes `admitSessionPlacement(id, persisted, opts ?? {}, { subagentPlacement: true })` — one sentence of copy changes (the absent-tuple refusal ends "or the repo dropped the tuple" instead of "or the tuple was written away out of band"). Mint new rows with `incarnationCell(undefined)` / `forkRowCells(source)`; adopting `readIncarnationCell` in place of `incarnationFromCell(…, "refuse")` turns a missing cell from a thrown 500 into an unknown occupant plus a disclosure: that change is stage two's, after the one-time backfill window closes. *cli* / *client-core*: nothing.
|
|
128
|
+
- **Pins.** `test/backlog1468-k2-laws.test.ts` (① root identity, the old home keeps no re-export; ② golden bytes and the inherited edges; ③ bundle identity hash; ④ four answers over every gate kind × card value; ⑤ one read per member; ⑥ the summary's card is the reading's; ⑦ the policy lane on a question row and an approval row; ⑧ the wake copy per kind, byte-exact; ⑨ the admission against the retired rule over 150 cells; ⑩ the four store paths, declared / undeclared; ⑪ the declaration read at the call; ⑫ the decoder table; ⑬ the cell functions; ⑭ the File header read unchanged); `test/design178-v2b-erasure-evidence.test.ts` imports the serializer from its home.
|
|
129
|
+
|
|
130
|
+
### The question a server's tool meets when nobody granted it is cleared by the allow layer or by an answer given for the bytes that run, and by nothing else: a name-list policy's hit is no objection, never consent (#1359; behavior narrowing, ruled: clay C-R638 (2026-10-18); **hard-breaking, ruled: clay C-R638 (2026-10-18): `createAllowDenyPolicy`'s `scope` option is retired, refused by name**; closed sets: none; exports: none added or removed — `createAllowDenyPolicy` loses one option; members: none) (@server @cli @client-core @test)
|
|
131
|
+
|
|
132
|
+
- **Before.** At the fold's last station a call to a tool whose permission seat is `passthrough` (every MCP server tool and A2A skill) met the mode's `unstated` question only when the surviving allow carried no `decisionReason`. Any layer could therefore skip the question by putting a reason word on its allow: `createAllowDenyPolicy`'s allow list (unless `scope: "narrowing"`), `createApprovalPolicy`'s `autoAllow` and `createCoarseCommandNamePolicy`'s command list stamped `"rule"` on a hit; a host policy's own `{ action: "allow", decisionReason: … }` did the same; the fold carried the first such word out, and a delegated child's final-form re-check restated it. So "does this need asking" had two answers — the allow layer's members and a policy's word.
|
|
133
|
+
- **Now.** One rule (`asksUnstated`): a passthrough-seat call whose fold allow is not covered by an ANSWER — an engine record of a settled wait whose license covers the bytes that run, i.e. a person's or the deployment approver's yes (`createApprovalPolicy`'s `approve`, the ask resolver, an inherited approver) over those arguments — meets the question, and the allow layer answers it (`RunnerDeps.allowedTools`, `TaskSpec.allowedTools`, a stored row, a skill's list, a PreToolUse hook's own `allow`), as do dontAsk / auto / a person as before. `decisionReason` takes no part. A yes given for other bytes covers nothing: a later layer that rewrites the arguments leaves the call asked. The approved-edit round reads the same rule on the edit's final form. The three factories answer a bare allow on a hit; the license comparison has one judge, shared with the gate's settlement ledger. A license is compared by the EXACT digest only: arguments whose canonical form the serializer had to shorten (nesting past its depth cap, an unreadable property or structure, a function, a stateless object brand) are covered by no answer, so such a call meets the question again after its approval, and its settlement is not attributed on `tool_end` (the ledger shares the judge). A decision receipt's detached copy of a rewrite keeps the record of which bytes a tool's argument reading took it from, so an approved edit the reading normalizes (`"2"` → `2`) is still covered. The headless refusal's remedy for the question now names the allow layer (`grant it with an allow entry for its name (RunnerDeps.allowedTools / TaskSpec.allowedTools)`), not a policy.
|
|
134
|
+
- **Hard-breaking (ruled: clay C-R638, 2026-10-18).** (1) **What changed:** `createAllowDenyPolicy({ scope })` is retired — passing it throws `config.deps_retired_key` naming the replacement (an allow list is only ever a narrowing); an allow-list hit, an `autoAllow` hit, a command-list hit or a host policy's reason word no longer spares a passthrough tool the `unstated` question: in `default` / `acceptEdits` / `plan` / `auto` mode with an approver the call now carries a card first; in `dontAsk`, and on a leg with no approver (headless), the call that ran before is now **refused, with no card** (`deniedBy: "mode"` under dontAsk; `ask_resolution` / `no_approver` headless). (2) **Who is hurt:** a host that says "these tools need no asking" with a name-list factory or a reason word on its own policy's allow — server's per-leg auto-approve budget (`createDurableAskPolicy`'s budget arm, `src/approval.ts:423` → `grantedByRule()` `:332`, `decisionReason: "rule"`; its outside-the-list arm `:372` and its session-exemption hit `:404` already answer a bare allow and are unaffected), cli's settings seam (`policyFromPermissions`, settings allow → `createAllowDenyPolicy`) — sees MCP / A2A tools start carding, and refused where nobody can be asked; a host passing `scope` fails to compile / is refused at construction. (3) **How to migrate:** write the consent as an allow entry in the same grammar — `RunnerDeps.allowedTools: ["mcp__<server>__*"]` for the deployment's, `TaskSpec.allowedTools` for a request's; a consent decided per call (an auto-approve budget) is an ANSWER, so give it through an approver — `createApprovalPolicy`'s `approve` resolving `{ allow: true, settledBy: "policy" }`, or the ask resolver — and the engine records it for the call's bytes; keep the factories for what they still do (refuse what a list does not name, `denyByDefault`'s exceptions, the approval wait); drop `scope` (client-core's `AllowDenyPolicyOptionsView.scope` mirror goes with it).
|
|
135
|
+
- **Behavior narrowing, two shapes that ran unasked before (ruled: clay C-R638, 2026-10-18).** (a) An ancestor's PreToolUse screening that answered `allow`, replayed in a delegated child as an inherited constraint, no longer clears the child's question (an inherited constraint only tightens; the child's own hook member still answers) — who is hurt: a deployment whose child leg runs a different screening chain than its parent (its own `mods`, a chain the child's admission refused, a different matcher) and relies on the parent's `allow` to wave a passthrough tool through; a child that runs the same chain clears the question with its own hook member, as before. (b) An ancestor's frozen classifier that allowed an ancestor's ask settles no wait, so a child's passthrough call still meets its own question — who is hurt: an `auto` child pays one more classifier round on such a call (a `default` child, a card).
|
|
136
|
+
- **Behavior narrowing, the write gate's session exemption (ruled: clay C-R638, 2026-10-18).** (1) **What changed:** `createFsWriteGatePolicy`'s `isExempt` still answers `allow`, but for a write tool whose permission seat is `passthrough` (an MCP / A2A tool declaring a write `pathTarget` through `toolFaces`) that allow is no answer, so the call meets the `unstated` card again after the session exemption — `Write` / `Edit` / `NotebookEdit` (no seat) are unchanged. (2) **Who is hurt:** a deployment that records "allow for this session" grants through `isExempt` and serves MCP write tools under a mode whose own write gate lets the write through (`acceptEdits`): there the call ran with no card and now carries one `unstated` card; under `default` the mode's write gate already puts an `ordinary` card to such a write, which the exemption never cleared, so nothing visible changes. (3) **How to migrate:** grant the tool in the allow layer (`TaskSpec.allowedTools` per session / `RunnerDeps.allowedTools`); folding the write gate's session exemption into the rule store's session partition is a separate design step (#1360).
|
|
137
|
+
- **For the consumers (release-post sentence).** From 7.54.0 the server's per-leg auto-approve budget (`approval.ts:423`) no longer spares MCP / A2A tools: each such call meets the `unstated` card, and is refused where nobody can be asked; the budget for the engine's own tools is unaffected. To migrate, move the budget to the approval seat, so that it answers that card.
|
|
138
|
+
- **Pins.** `test/backlog1359-allow-layer-only-home.test.ts` (D1–D8 real Runner over a real stdio MCP server; G1–G5 the gate's station over each producer's real allow; N an inherited approved edit the reading normalizes; L one license judge, the ledger and the station row by row; source pins of the retired readings); re-pinned (the pins move with the rule; no fix of those tickets): #1065 `test/backlog1065-mcp-default-ask.test.ts` ③ (3c → a deployment allow entry; 3c' / 3d now card), ⑭b, ⑬c, ⑬d; #794 `test/backlog794-inherited-classifier-round.test.ts` ③; #1314 `test/backlog1314-unavailable-arm-remedy.test.ts` ⑪ (the `unstated` remedy sentence names the allow layer); `test/inherited-gate-pretooluse-hook.test.ts` (the screening-face mapping: an inherited constraint's `allow` carries no reason word).
|
|
139
|
+
|
|
140
|
+
### `memory.enabled: false` is a tighten-only seat like every other: `tightenTaskSpec` refuses an override that turns it back on, and the child side reads the same clamp-table row instead of a hand-spelled `memoryOff` (#1567; behavior narrowing, ruled: clay C-R638 (2026-10-18); behavior change, the loosening direction, ruled: clay C-R646, 2026-10-19: an override writing `memory: undefined` / `null` no longer erases the base's declaration; **hard-breaking, ruled: clay C-R638 (2026-10-18): the public type member `ParentSpecSeat.memoryOff` is removed and `ParentSpecSeat.memory` (`{ readonly enabled: false }`) takes its place; `createSubagentResume`'s `currentClamps.memoryOff` is refused as a retired key**; closed sets: none; exports: none; members: −1 `ParentSpecSeat.memoryOff`, +1 optional `ParentSpecSeat.memory`, `createSubagentResume` deps `currentClamps.memory` replaces `currentClamps.memoryOff`) (@server @cli @client-core)
|
|
141
|
+
|
|
142
|
+
- **Before.** Two readings of one fact. The clamp table (`CLAMP_SET`) listed every seat that only tightens, and `tightenTaskSpec` merged by it — but `memory` was not a row, so an override's `memory` object replaced the base's whole: a base `{ enabled: false }` merged with an override `{ enabled: true }` came out ON, silently. Separately the child side carried "this run turned memory off" as a hand-spelled `ParentSpecSeat.memoryOff` (minted by one line, applied by another, folded for a retained child's wake by a third), which the table and its gate could not see.
|
|
143
|
+
- **Now.** `memory` is a row of the table: a FIELD row — an object-valued seat whose one member (`enabled`, restricting value `false`) is the clamp; the rest of the declaration (scopes, write scope, knobs) is the caller's own. One row, every reader: (1) `tightenTaskSpec` — a base `memory.enabled: false` meeting an override that states `enabled: true` throws `TaskSpecTightenError` ("cannot loosen memory.enabled from false to true"), exactly as every other restricting seat does; an override that declares `memory` without stating `enabled` (or with `undefined`) loosens nothing and its declaration stands whole with `enabled: false` stated on it; an override that does not declare `memory` keeps the base's; the later declaration is never mixed with the base's members. (2) The spec seat: a run whose spec states `memory.enabled: false` hands its children `ParentSpecSeat.memory = { enabled: false }` (the clamped member alone — the parent's scopes never ride it), and `applyParentSpecSeat` rewrites that one member on the child's own declaration, keeping everything else it authored. (3) The revival fold and the waker's clamp seat (`currentClamps`) carry the same row, so a retained child woken by a run that is itself off wakes off. The mint, the apply, the resume fold and the send lane's clamp record are table readers now (`applyClampSeats`, `clampFieldSeatsOf`); `memoryTurnedOff`, the hand-written mint line and the send lane's second record line are deleted. `gate:child-clamp-seats` grows the field-row legs: a field row rides the spec seat only and is never script-declared, its carried member must be exactly `{ readonly enabled: false }`, and a `ParentSpecSeat` member that names no `TaskSpec` member (the retired `memoryOff` shape) is red.
|
|
144
|
+
- **Behavior narrowing (ruled: clay C-R638, 2026-10-18).** `tightenTaskSpec(base, overrides)` with a base `memory.enabled: false` and an override stating `enabled: true` now throws where 7.53.0 returned a spec with memory on. An override declaring `memory` beside a base that is off now keeps `enabled: false` where 7.53.0 dropped it.
|
|
145
|
+
- **Behavior change (ruled: clay C-R646, 2026-10-19; the loosening direction).** A table row's law is "an override writing `undefined` erases nothing", and `memory` is a row now, so the law reaches the whole declaration: `tightenTaskSpec(base, { memory: undefined })` (and an untyped `memory: null`) keeps the base's `memory` declaration — the memory face stays mounted — where 7.53.0's plain spread erased it (no memory face). One law for every seat, not a rule for this one. To switch memory off in an override, write `memory: { enabled: false }`; `undefined` / `null` no longer mean "off". Nothing in the engine builds that override (the governed workflow lane's overlay cannot carry `memory`), so the readers it can reach are direct callers of the exported `tightenTaskSpec`.
|
|
146
|
+
- **Hard-breaking (ruled: clay C-R638, 2026-10-18).** (1) **What changed**: `ParentSpecSeat.memoryOff?: true` (a member of the exported `ParentSpecSeat` type) is removed; the same fact is `ParentSpecSeat.memory?: { readonly enabled: false }`. `createSubagentResume`'s `currentClamps.memoryOff` is refused by name (`config.deps_retired_key`, the sentence names `currentClamps.memory: { enabled: false }`); `currentClamps.memory` is the seat. A seat that still carries `memoryOff` (a host's `enrichCtx`, a hand-built tool context, `RunWorkflowToolDeps.parentSpecSeat`) is refused by name too — `applyParentSpecSeat`, the tool-context reader and the `SendMessage` wake each throw `config.deps_retired_key` ("ParentSpecSeat.memoryOff is retired"), never read it as absent, which would have dropped the restriction. The seat's `memory` member is frozen at mint, like `envFacts`, `compaction` and the two directory lists (the seat's `clientContext` and `mods` members are passed by reference, as before). (2) **Who is hurt**: a host that reads or builds `ParentSpecSeat.memoryOff` or passes `currentClamps.memoryOff` (none in cli or client-core source; server reads nothing of it at runtime, but its `test/memory-off-request.test.ts` ⑥ pins the exact text of the retired mint and apply lines in core's `parent-spec-seat.js`, which goes red when it takes this version); a caller of `tightenTaskSpec` that relied on an override turning a base-off memory back on (the merge now throws). (3) **How to migrate**: read `seat.memory?.enabled === false` where you read `seat.memoryOff === true`; build `{ memory: { enabled: false } }` where you built `{ memoryOff: true }`; a `tightenTaskSpec` caller that must start from a memory-on base states the base without `enabled: false`. *server*: re-anchor `test/memory-off-request.test.ts` ⑥ ①② on the seat's `memory` member and the table's apply (the behavior it pins — a parent off ⇒ children off, a child's own `enabled: true` cannot lift it — is unchanged); `src/http/wire-types.ts` names `memoryOff` in a comment. *client-core*: docs and two comments only — the S-3 consumption rows of `docs/INTEGRATION-CLIENTS.md` name `ParentSpecSeat.memoryOff?` / `currentClamps.memoryOff?` as declined typeKeys, and `src/request/taskRequest.ts` mentions the member in two comments; the package reads neither. *cli*: nothing (its `memoryOff*` identifiers are its own request-side names).
|
|
147
|
+
- **Other members of the declaration.** `scopes`, `writeScope`, `scopeContract`, `trustedTools`, `execIsExternalContent`, `scopeOrigins` and `projectKey` stay the later author's, whole (naming layers or tools; mixing a base's `writeScope` into an override's `scopes` would mint a layering nobody wrote). Not closed here and now a finding rather than a row: `capture: "off"` and `writeScope: null` (read-only) also narrow, and a later declaration that omits them still drops them in `tightenTaskSpec`; the table's one-row-per-seat shape needs a decision on a second clamped member of one seat before they can be rows.
|
|
148
|
+
- **Pins.** `test/backlog1567-memory-clamp.test.ts` (F1 the cx01 shape and its neighbours; F2 no floor; F3 tightening; F4 each other member is the later author's; F5 type-level agreement of the table, the seat and the waker's record; the field row's readers — projection, fold, apply, seat mint / apply; the gate over mutated trees, including the old `memoryOff` put back); `test/backlog863-memory-off-floor.test.ts` and `test/backlog1052-clamp-set.test.ts` re-anchored on the `memory` row.
|
|
149
|
+
|
|
150
|
+
### An allow member clears a standing ask exactly when the ask's class row says it may — one reading, the classification the card carries; `bypassPermissions` refuses an unresolved write target only where that class is itself immune, and reads a classification's face from one place (#1329 C1a; behavior change, ruled: PM C-R645 → clay C-R646, 2026-10-19; behavior narrowing for a host's own `runToolGate` input only; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `AskClassification.askFace`) (@server @client-core @cli @test)
|
|
151
|
+
|
|
152
|
+
- **Before (v7.53.0).** The gate's allow layer — a hook's allow, `RunnerDeps.allowedTools`, a stored allow row, a skill's `allowed-tools`, `TaskSpec.allowedTools`, the read-only shell reader, an extension's `tool.check` allow — cleared an ask only when three readings agreed: a structural predicate (the org layer spoke, `requiresRealApproval`, a hook's ask, the question tool, a marked call), the mandate derivation's enforcement output (its doors and the tool / seat marks), and the class row's `ruleMayClear`. The first two answered for doors the third already held, so changing one class row changed nothing anyone could see (the removal breaker's row could be flipped with no pin going red). One input of the gate had a mandate word and no class: `runToolGate`'s `egress: true` beside `shellGated: true` — its ask was class `ordinary`, held only by the second reading. A Runner never hands the gate that pair: a `ToolSpec.egress` on a shell-face name keeps the shell seat off the tool (`shellGated` is false for a name the deployment marked), so on the Runner such a tool's ask was already `operator_forced`.
|
|
153
|
+
- **Now.** One reading: the standing ask's class row (`classifyAsk`, the classification the card's `askClass` / `classes` carry). The `egress` mark is a class on every seat, classed by who put it there like every other tool mark (`operator_forced` for the deployment's own declaration, `tool_marks_mcp` / `tool_marks_a2a` for a server's or a peer's), whatever `shellGated` says. Over every combination of the inputs the three readings took (501 568 cells: every mark × provenance × tighten record × decision shape, every decision-member combination × gate-fact shape, and a seeded sample of the whole product) the one reading clears exactly what the conjunction cleared.
|
|
154
|
+
- **Behavior narrowing, a host's own `runToolGate` input only (open to veto).** No Runner deployment changes here. A host that calls the root-exported `runToolGate` itself with `shellGated: true` and `egress: true`: its asks carry the tool-mark class, so `bypassPermissions` keeps the question (v7.53.0: released, class `ordinary`), `acceptEdits` no longer answers a file command by itself, and the card names `operator_forced` (or the MCP / A2A word) where it named `ordinary`. With `target_unresolved` beside it the question goes to a person too (v7.53.0: released) — see the next bullet. A stored allow row still does not clear it — unchanged.
|
|
155
|
+
- **Behavior change at the mode point (ruled: PM C-R645 → clay C-R646, 2026-10-19).** `bypassPermissions` refuses an ask holding `target_unresolved` only where THAT class is bypass-immune on the call's face — every face but the shell's, which releases it — instead of wherever the folded row is immune. Before (v7.53.0), on the shell face (`Bash`, `Monitor`) a set holding `target_unresolved` beside ANY bypass-immune class was refused outright (`denied_by_mode`, nobody asked); now that immune class keeps the question and the call reaches a person or the park (`ask`). Against v7.53.0 the mode point's input-domain table (968 000 cells × six modes, every classification `classifyAsk` derives) moves 73 641 cells: 71 640 are this transition — shell face, `bypassPermissions`, `denied_by_mode` → `ask`, `target_unresolved` in the set — and 2 001 are the `egress` cells of the bullet above (`released` → `ask` 1 785, `accepted` → `ask` 81, both 135; a host's own `runToolGate` input only). Zero cells move to `released` and zero to `denied_by_mode`: nothing is let through that was asked, and no new hard refusal appears. The immune companions seen beside `target_unresolved` in the moved cells, the whole set: `operator_forced` (a deployment-authored ask, a self-declared `mandated`, the `egress` cells above), `rule_unreadable`, `ask_rule`, `hook`, `requires_user_interaction`, `shell_floor` (the operator's `shellGate: "always"`), `policy_rra`, `denial_limit_fallback`, `classifier_no_ruling`, `dangerous_delete`, `unverifiable_delete`, `unverifiable_shell_text`, `org_rule`, `ancestor`, `content_question` — every bypass-immune class. No other face, mode or class set moves; a set with `target_unresolved` and no immune class is released on the shell face and refused on every other face, as before.
|
|
156
|
+
- **`AskClassification.askFace` (+1 optional member) — the one carrier of the face the mode point reads.** The tool face the classification was derived on. Present exactly when it was derived with a face (`AskClassFacts.askFace` set — the gate's own classification of every call, and every inherited station's); absent when it was derived face-less (a host's own `classifyAsk` call with no `askFace`; the engine's fixed classifications for the shared-artifact read and the ancestor-unresolved remedy), which reads as the `other` face. `arbitrateByMode` and the headless remedy read whether a mode can release the ask from the class set on this face alone — never from `row.bypassImmune` — so a classification a host builds itself cannot answer two ways: with no `askFace` it is read on the `other` face whatever face its `row` was folded under (fail-closed: an unresolved target is refused). For every classification `classifyAsk` derives, `row.bypassImmune` and this reading agree. The wire manifest records the member (+1 `typeKeys`). *server*: a reader that projects or alerts on a classification it received from core may see the member; a classification it builds itself for `arbitrateByMode` must carry `askFace` to be read on the shell face (@server).
|
|
157
|
+
- **Who is hurt.** A deployment or probe that pinned the shell-face hard refusal of an unresolved write target beside an immune class (`denied_by_mode`) now sees the question asked (an approver answers it, or a park holds it; with no approver it is the headless refusal of an ask) — on a Runner, e.g. `touch loop/x` whose target the environment cannot resolve, under `bypassPermissions`. A host that calls `runToolGate` itself with `shellGated: true` + `egress: true`: the question is kept under `bypassPermissions` and its `askClass` reads `operator_forced` instead of `ordinary`. A host that builds its own `AskClassification` with its `row` folded on the shell face and no `askFace`: read on the `other` face now.
|
|
158
|
+
- **How to migrate.** Nothing for a Runner deployment beyond answering the ask (an approver, a park). A host driving `runToolGate` itself: pass `egress` only where a person confirms the tool's external writes. A host building an `AskClassification` by hand: set `askFace` beside the `row`, or take the classification from `classifyAsk`. *server*: keep `mandated` in the standing-answer predicate (`standingAnswerHome`) — an inherited station hands its frozen approver a card with `classes: ["ordinary"]` and `mandated: true` (the ancestor's question), so the class row alone would let a remembered release answer it; only `requiresRealApproval` there is redundant with the class row (every real-approval ask holds a class whose row says no). *client-core* / *cli*: no wire shape changes — the class words are members of the existing closed set.
|
|
159
|
+
- **Pins.** `test/backlog1329-gate-one-reading.test.ts` (① a stored allow row over every door, ② the read-only reader over the same doors, ③ the `runToolGate` shell-seat `egress` under `bypassPermissions`, with and without an unresolved target beside it, ③c a host-built classification is read on its `askFace` alone, ④ a real Runner: the removal breaker under a whole-tool allow entry, ⑤ the read and the card are one classification); re-pinned: `backlog997`, `backlog1111`, `backlog1139`, `backlog1303d` (the mandate derivation no longer has an enforcement output — the class row is asserted where it was); `backlog1423` (the shell face's unresolved target beside an ask rule or an unreadable rule: `ask`, not `denied_by_mode`); `backlog955` F5 (a real Runner: `touch loop/x` whose target the environment cannot resolve, under `bypassPermissions` — a card `unverifiable_shell_text`, not `denied:mode`); `backlog1212` (its hand-built classification carries the face it folds under, as `classifyAsk` writes it).
|
|
160
|
+
|
|
161
|
+
### A parked approval covers ONE object — its tool (identity and face), where its target resolved, and a Skill card's lists — judged by one judge at three stations of the resume; a moved face or target vetoes the call (`resume.target_moved`) (#1391 / #639 / #1185; behavior change and behavior narrowing, ruled: clay C-R638 (2026-10-18) — the Cost bullet stays open to veto; hard-breaking: yes, four; closed sets: `REQUIREMENT_WORDS` +1 `approval_targets`, the engine-minted `tool_end.errorCode` words +1 `resume.target_moved`; exports: −1 function `judgeParkedToolIdentity`, +2 functions `approvalObjectOf` / `judgeApprovalObject`, +2 constants `APPROVAL_STATION_MEMBERS` / `APPROVAL_TARGET_ROLES`, +7 types; members: −1 optional `pendingAction.rosterDigest` (no longer minted), +1 optional `pendingAction.targets`; `parkedToolIdentity` no longer returns `rosterDigest`) (@server @cli @client-core @test)
|
|
162
|
+
|
|
163
|
+
- **Before (v7.53.0).** A parked call's row bound its argument bytes, a Skill card's lists and the tool's four identity members, each judged by its own check at its own place; the tool's FACE (display family, declared path target) was copied onto the row and never compared, and where the target resolved was not recorded at all. A resume after the tool's `pathTarget` declaration changed, or after a link the call names was re-pointed, executed the approved bytes against the new object — a person approved reading `/out/a.txt` and the resumed call read whatever the link pointed at by then.
|
|
164
|
+
- **Now.** (1) The row's approval object (`approvalObjectOf(row)`) is the tool (four identity members + face), the targets (new row member `targets`: per reading of the call's path slot — `protective` / `declared` — the location it resolved to when the card was asked, copied from the gate's own reading of the parked bytes, or `null` when the environment was asked and did not answer, so a target that resolves later counts as moved) and the grants (the existing `boundGrantDigest`). (2) ONE judge, `judgeApprovalObject(row, live, station)`, and ONE table, `APPROVAL_STATION_MEMBERS`: before the claim the grants (refused, the row pending — unchanged); before the resumed session is touched the tool; right before the execution, after every station that can rewrite the bytes, the tool again (as mounted then: a host's MCP re-dial can replace it between the two stations), the grants and the targets, read again there. (3) The law: the tool not mounted (`resume.tool_unavailable`) or a replacement wearing its name (`resume.tool_contract_mismatch`) ⇒ the row is reopened for the original tool, as before; a moved FACE or TARGET ⇒ the call is vetoed: not run, the approval spent, `tool_end.isError` with `errorCode: "resume.target_moved"` and `gate.disposition` `{ kind: "denied", deniedBy: "policy" }`, the model reads why and re-issues — the new call's card shows the current target. A reopen would keep the decided winner and meet the same mismatch again, so it is not used for these. (4) "Are these the approved bytes" has one judge, the engine's license judge `licenseCovers(row.boundInputHash, bytes)` — the bytes, as the tool reads them, digest EXACTLY as the row's binding (a value whose canonical form a depth sentinel shortened is never the approved bytes; such a value cannot park anyway — the row digest refuses it). A decision that rewrote the bytes to ones the binding does not cover is not judged on targets (the row never recorded a target for other bytes; the person approved those) and is judged by the resumed belts as bytes no gate saw (a deny row; the read boundary's edit seat refuses a read outside the allowed directories); an `updatedInput` the tool reads as the parked bytes — another key order, `"50"` for `50` — is no edit: its targets are judged. The same judge decides whether the gate's target reading describes the bytes a park files. (4b) A parked file approval never meets a moved workspace root: a resume that would execute an approved call on a workspace restored under another root is refused before its leg is built (`resume.env_failed`, nothing runs, the row back to `pending`), as before this change — so the recorded targets are always compared in the root they were recorded in. (5) A `deny` is never judged against the object: it runs nothing (before, an identity mismatch on a deny reopened the row, which kept the deny and reopened again). (6) `rosterDigest` is no longer minted (nothing decided by it). (7) The leg's target-reading stamp now runs whenever the park lane is armed, not only while a lane holds a path rule: the park lane binds the target, so it is a path judge.
|
|
165
|
+
- **Cost (open to veto).** On a leg whose park lane is armed — a checkpoint store together with `durableApproval`, `runtimeCaps.forceDurableGate`, egress / irreversible tools, or a mounted shell (its read station's out-of-root question parks), which covers a typical server leg — every call that names a path takes one more canonicalization of its target at the gate (measured: an in-root `Read` canonicalizes 23 times where it did 22), and one symlink-chain read when the leaf is a link. A leg whose park lane is not armed is unchanged. Who is affected: hosts whose high-frequency path tools run on park-armed legs (latency per call; no change in what is decided).
|
|
166
|
+
- **Closed sets ("this word was the only one, now it is one of two").** `REQUIREMENT_WORDS` +`approval_targets`: a row with a `targets` member lists it, so an OLDER worker refuses such a row as `version_newer` instead of executing a target it cannot judge (the `skill_card_binding` precedent). `tool_end.errorCode` on a vetoed resumed call: `resume.target_moved` (present exactly when the approval object's face or target moved, or its targets member is damaged; absent on a grants veto, which keeps its v7.53.0 shape). A table exhaustive over the requirement words, or a consumer that read every engine-minted `errorCode` on a resumed call as a reopen code, sees one more word.
|
|
167
|
+
- **Hard-breaking (four, ruled: clay C-R638, 2026-10-18).**
|
|
168
|
+
1. *What changed:* a resumed approval whose tool face or target moved is vetoed (not run, approval spent) where v7.53.0 executed it against the new object. *Who is hurt:* a deployment that changes a caller tool's or an MCP override's `pathTarget` / `family` while approvals wait, and a parked call whose link is re-pointed before the decision — each waiting approval is refused once and asked again (a workspace restored under another root is not among them: its approved resume is refused before the leg is built, unchanged, (4b)); a probe pinned on "face changed ⇒ still executes". *How to migrate:* nothing to change in code: the model re-issues, a person approves the new card. A face change that is truly the same tool is meant to cost exactly that — each waiting approval vetoed once and approved again; do not bump `implementationRevision` for it (a replaced tool's reopen never meets the original again, so the row would stay pending with nothing able to run it).
|
|
169
|
+
2. *What changed:* `judgeParkedToolIdentity` is removed from the package root. *Who is hurt:* a host that judged a row's identity itself. *How to migrate:* `judgeApprovalObject(row, { tool: rosterEntry }, "session")` — `"match"`, or `{ member, disposition, code, message }` (`code` `resume.tool_unavailable` / `resume.tool_contract_mismatch` / `resume.target_moved`).
|
|
170
|
+
3. *What changed:* a row with `targets` carries the requirement word `approval_targets`; `rosterDigest` is no longer written. *Who is hurt:* a mixed fleet where an older worker resumes a row a newer one parked (refused `version_newer`, the row pending); a store or projection that persisted `rosterDigest` as a column (it stays absent on new rows; old rows keep it and nothing reads it); a store that whitelists row members and drops `targets` (the row's digest then fails: `requirements_damaged`). *How to migrate:* *server*: upgrade every worker that resumes rows before parking on the new one; a SQL store that column-izes `pendingAction` members stores `targets` (a record of at most two roles, each one canonical key — an absolute path — or `null` for a target that did not resolve when parked; a NOT NULL text column breaks on it) or keeps it in the JSON payload; drop any `rosterDigest` column read. *cli*: the vetoed call's result carries the reason and the model's re-issued call parks a new card whose args name the current target — render as any denied tool result; `errorCode` `resume.target_moved` is available to key on.
|
|
171
|
+
4. *What changed:* the root export `parkedToolIdentity(roster, toolName)` no longer returns `rosterDigest` (it returns the four identity members, `Partial<ToolIdentity>`). *Who is hurt:* a host that read `parkedToolIdentity(...).rosterDigest` — it was a string in v7.53.0 and is `undefined` now; a type that spread the old return shape. *How to migrate:* nothing decided by the roster digest; to know where a parked call's target resolved read the row's `pendingAction.targets`, and to judge a row's tool read `judgeApprovalObject(row, { tool }, "session")`.
|
|
172
|
+
- **Known limit.** The execution station reads where the target resolves and the tool then resolves the spelling again; a link moved inside that window still reaches the new target for a caller or in-root file tool (an out-of-root file read is held to the read station's admitted key by the tool): `KL-1391-a`.
|
|
173
|
+
- **Black-box criterion (for the criteria post).** On a leg with no path rule: park an out-of-root `Read` of a symlink (durable approval, no live approver); before deciding, re-point the link to another file; decide `allow` ⇒ the resumed `tool_end` for that call is `isError` with `errorCode: "resume.target_moved"`, the new file's content never reaches the model, the row is `resolved` (not reopened), and the model's re-issued `Read` parks a new row whose `pendingAction.targets.protective` is the new file. Same for a caller write tool through a re-pointed link (nothing written).
|
|
174
|
+
- **Pins.** `test/backlog1391-approval-object.test.ts` (B4r / B4w / B4' / B6 vetoes, B4e / K1 / K2n the parked bytes handed back — verbatim, in another key order, or in a form the tool reads alike — are judged, K2 an undefined-valued member is other bytes, B6' an edit executes, B6'b the edit is still judged by the read boundary, B1 face veto, B2 / B3 / B11 reopen, X1 a tool replaced by a host's re-dial between the two stations reopens at the execution, R an approved resume onto a workspace restored under another root is refused before the leg (`resume.env_failed`, nothing runs, the row pending), DN deny unchanged, FC no reading ⇒ veto, DM damaged member, B8 control, the judge's table and the requirement word); `test/tool-roster.test.ts` (G6e re-pinned on the judge); `test/tool-face-bounds.test.ts`; `test/backlog1328-parked-skill-rescreen-swap.test.ts` and `test/backlog1414-approval-card.test.ts` B7 (unchanged, rerun).
|
|
175
|
+
|
|
176
|
+
### `@sema-agent/mods-kernel` is a public package, cut with core at the same version, with a README that states what a stranger installs (#2104; release packaging; additive; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none) (@client-core @cli @web-client)
|
|
177
|
+
|
|
178
|
+
- **What changed.** (1) `packages/mods-kernel/package.json` carries `"private": false` (an explicit boolean, as the package gate has always required) and `"publishConfig": { "access": "public" }`, so the first publish is public by the package's own word and not by a command-line flag someone must remember. A tag cut from this tree therefore promises `@sema-agent/mods-kernel@<that version>` on the registry, which `scripts/verify-published-tags.mjs` already reads for every non-private package; until the 7.54.0 publish the name is a 404. (2) The tarball holds `package.json`, `README.md`, `LICENSE` and the 14 compiled files under `dist/` (seven modules, `.js` and `.d.ts`), nothing else; one entry `.`, zero dependencies, the same compiled source the engine embeds (`src/core/mods/kernel/`), the same version as core. (3) The README is rewritten: how to install, that the package is cut with core at the same version, that a host loading two copies compares `KERNEL_CONTRACT_VERSION` for equality (the number itself is not written in the README, the constant is its one place), how to read which side raises an event, and an Exports section listing the 67 names of the frozen export surface. (4) `docs/RELEASE-TIERS.md` (multi-package base): the publish step reads `npm publish --ignore-scripts -w packages/<dir>`, the first publish's access comes from `publishConfig`, and the registry check gains a stranger smoke (an empty directory, `npm i @sema-agent/<dir>@X.Y.Z`, `node -e` reading the contract number and one event's side); `docs/INTEGRATION-CORE.md` §10 says the package is on the registry from 7.54.0.
|
|
179
|
+
- **Licence (ruled: clay C-R646, 2026-10-19).** The package ships the licence text: `packages/mods-kernel/LICENSE` is a byte copy of the root `LICENSE`, and the root file's "Licensed Work" line now names the satellite packages published from this repository (`@sema-agent/core` and currently `@sema-agent/mods-kernel`); no other clause of the licence changes. Before this, a workspace tarball did not inherit the root file, so a consumer of the package alone received only the `BUSL-1.1` identifier in `package.json`, without the Additional Use Grant, the Change Date and Change License, or the terms. The wording of the Licensed Work line is the licensor's to rule.
|
|
180
|
+
- **Which side raises an event.** No new column. Every row of `MOD_EVENT_TABLE` already carries `side` (`engine`, `surface` or `both`; the closed set is `MOD_EVENT_SIDES`), the package exports the table, the closed set and the readers `readOnSide` / `stationOn`, and the shipped declaration types the member (`ModEventRow.side: ModEventSide`). `KERNEL_CONTRACT_VERSION` is unchanged and nothing in `src/` changes.
|
|
181
|
+
- **Not established here.** That the package resolves from the registry: this is the cut's own publish step, and the stranger smoke above is its check. Before it, the same reading runs on the gate's own tarball, offline.
|
|
182
|
+
- **Who is hurt.** Nobody: no root export, behavior or declaration changes. A tag that promises the package and does not publish it now blocks the next release's start, by the check that already exists.
|
|
183
|
+
- **How to migrate.** Nothing for hosts of the engine. A shell or client that wants the kernel on its own bus installs `@sema-agent/mods-kernel@7.54.0` (the same number as `@sema-agent/core`) once it is published, and compares `KERNEL_CONTRACT_VERSION` if it loads a second copy.
|
|
184
|
+
- **Pins.** `test/backlog2104-mods-kernel-publish.test.ts` (A: `private` is `false`, `publishConfig` is `{ access: "public" }`, the release gate reads the package as a promise of the root version, and for each such package its `LICENSE` is the root file's bytes and the root's Licensed Work parameters name it; B: the README's install, same-version and contract sentences, no contract number written down, no stale status banner, the Exports section equal to the frozen surface; C: every name has a `side` in `MOD_EVENT_SIDES` and the two readers answer from that row, the closed set is spelled once in the kernel source, the surface reaches the table / set / readers / row type; D: over the gate's own tarball, the file set is exactly `package.json`, `README.md`, `LICENSE` and the compiled modules, the shipped `LICENSE` is the root file's bytes, the installed manifest and declarations are the published ones, and a stranger install answers the source's contract number and the source's side for every name and runs the README's example); `test/mods-kernel-package-wiring.test.ts` (the manifest pin now reads `private: false`).
|
|
185
|
+
|
|
186
|
+
### A tool's effect is read from the tool that answers to the name: the safety scan writes only the caller tools' own declarations, every engine effect arrives with its tool's mount, and a caller alias's safety axes no longer land on an engine tool's name (#1680, #2105; behavior narrowing and behavior change for #1680, ruled: clay C-R638 (2026-10-18); for #2105 a safety narrowing for a caller alias's probe and a loosening for its `egress` / `always` marks, both ruled: clay C-R646, 2026-10-19; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @cli)
|
|
187
|
+
|
|
188
|
+
- **Before (v7.53.0).** The prepare safety scan wrote four engine effects into the leg's effect table by name, before any tool mounted: `ReadToolResult` → read (over a caller tool of that name), every hand-band name (merged with a caller tool's claim into the less repeat-safe of the two), `Bash` → read under `handsReadOnly`, and `ExitPlanMode` / `EnterPlanMode` → read under plan mode (over a caller tool of that name). Each entry stood whether or not the engine tool mounted. Measured before this change: with offload off (`toolResultThresholdChars: 0`), a caller's own `ReadToolResult` declaring `write` mounts and runs, but the table said `read`: a session `allowDirs` confinement admitted it and a wake reconcile called an interrupted call a safe retry. A caller tool declaring nothing there had a roster row that said `read`. A caller `ExitPlanMode` declaring `write`, under plan mode with no checkpoint store (the engine's plan pair does not mount), behaved the same. A caller `Write` declaring `read` under read-only hands (the band mounts no Write) read `write` in the table and `read` on its roster row. A manager-less hands leg kept an `LSP` row, and a read-only one a `TaskOutput` row, for tools nothing mounted.
|
|
189
|
+
- **Now.** The scan writes the caller tools' own declarations (the roster's kept instance, as before) and nothing else; every engine effect arrives with its tool's mount, from the tool's own declaration (the roster builder records it, as it already did). The four by-name writes are gone, and so are the two by-name writes the delegation surface made right before its own mounts (`ReadInbox`, `ListAgents` / `ListPeers`: the builder records the same `read` at their mount). A name nothing mounted under has no entry. The wake reconcile is the one reader that runs before any mount, so before the roster can say who owns a name that a caller tool and an engine definition both answer to. It reads the scan's projection of the table, built by one rule: each name reads the least repeat-safe effect among the members that may answer to it — the caller tool claiming it (which counts as a write when it declares nothing) and every engine definition the catalog lists that this leg may still mount under the name or an alias (every definition but the `scenario` ones, which reach a leg only as a caller's own entries; the hand-mounted ones only where the hand is enabled; of the hand's two shells, the one matching `handsReadOnly`). The one merge rule replaces the three shapes of the old writes (overwrite / merge / overwrite). `handBandEffects()` is unchanged. Aliases follow the same rule. After the mounts, an alias's entry is the entry of the member the alias resolves to (the dispatcher's own lookup over the harness's own list: a member's own name first, then the first member carrying it). When an engine mount takes a name from a caller tool, the aliases that tool claimed move to their next claimer, effect and safety axes alike; an unmount (the leg's selector) moves the removed members' aliases the same way and drops the effect entry of a name nothing answers to by its own name. Before the mounts, the wake reads every caller tool carrying the alias. Measured before this change: with `Read` (`effect: "read"`, `aliases: ["Foo"]`) and `Charge` (`effect: "write"`, `aliases: ["Foo"]`) on a full-hands leg, the band's `Read` takes the name and a call by `Foo` runs `Charge`. The table still said `read` for `Foo`. A reconcile reads the table under a call's persisted spelling, so an interrupted call by that spelling would have been told to retry a write as safe.
|
|
190
|
+
- **Open defect #2107 (7.55.0, design ticket).** A leg selector that names a caller tool's alias can keep that caller instance under an engine tool's name while it unmounts the engine's own instance (`Read{effect: "write", aliases: ["X"]}` with `allowTools: ["X"]` on a full-hands leg). The table then keeps the unmounted engine member's `read` for `Read` while the caller's write tool answers to it, and a session `allowDirs` confinement admits a call to it (the same on v7.53.0). The roster row says `write`; the wake reads `write`. A removed name that another member still answers to by alias reads as write (over-careful, never fail-open).
|
|
191
|
+
- **Safety narrowing (#2105, ruled: clay C-R646, 2026-10-19).** (1) **What changed:** a caller tool's irreversibility tier, reversibility probe and egress mark are no longer mirrored onto an alias that is an engine tool's name. Before: `Deploy{irreversibility: "maybe", reversibilityProbe: () => ({ reversible: true }), aliases: ["Bash"]}` on a full-hands leg. The engine's full shell takes `Bash` by exact name, the shell seat kept `Deploy`'s probe, and a `touch` through the engine shell ran unasked in the default mode. Now it asks as it does with no `Deploy`. (2) **Who is hurt:** a host whose own tool aliases an engine tool's name (server documents `MyShell{aliases: ["Bash"]}` as a host pattern) and that expected its marks to apply to the engine tool's calls while that tool is mounted. Those calls now get the engine tool's own seat. When the engine tool is not mounted, a call under the alias still carries the claimer's own marks, because the gate reads a call's axes under every name it answers to. A caller tool NAMED exactly like the engine shell is unchanged: its marks still reach the engine shell, by design. (3) **How to migrate:** nothing for the documented alias pattern. To set the engine shell's judgment, declare it on a tool named exactly like it, or set the shell gate's `"always"` floor (`TaskSpec.shellGate: "always"`).
|
|
192
|
+
- **Behavior change, the loosening direction (#2105, ruled: clay C-R646, 2026-10-19).** (1) **What changed:** the same withdrawn mirror carried a caller alias's tightening marks too. A caller tool aliased `Bash` with `egress: true` or `irreversibility: "always"` no longer tightens the engine shell. Measured with a real Runner on a full-hands leg, `Deploy{effect: "write", egress: true, aliases: ["Bash"]}`, the model calling `Bash`: a default-mode `ls` asked once on v7.53.0 and is not asked now. A `bypassPermissions` `touch made` asked once and made no file on v7.53.0; it now runs unasked and makes the file. `irreversibility: "always"` behaves the same: default `ls` goes from 1 ask to 0; bypass `touch` goes from 1 ask to 0, and the file is made. (2) **Who is hurt:** a deployment that tightened the engine shell through a tool of its own aliased `Bash`, for example the server's documented `MyShell{aliases: ["Bash"]}` host shape when it carries `egress` or `always`. The engine shell's calls are no longer asked because of that tool. (3) **How to migrate:** name the tool exactly `Bash`, which is the designed channel: a same-named tool's marks still reach the engine shell, and `Bash{egress: true}` still asks under `bypassPermissions`. Or make every shell call ask with `TaskSpec.shellGate: "always"`, which is kept under `bypassPermissions`. There is no `RunnerDeps` seat for the engine shell's own axes.
|
|
193
|
+
- **Behavior narrowing (ruled: clay C-R638, 2026-10-18).** A caller tool under an engine name the engine does not mount (`ReadToolResult` with offload off, `ExitPlanMode` / `EnterPlanMode` under plan mode with no plan pair mounted) is read by its own declaration: declared `write` or nothing ⇒ `allowDirs` refuses it and an interrupted call is reconciled as UNKNOWN rather than safe to re-issue. Its roster row says `write` where it said `read`.
|
|
194
|
+
- **Behavior change (ruled: clay C-R638, 2026-10-18).** The other direction. A caller tool under a hand-band name the band does not mount on that leg (a `TaskStop` or a `Write` under read-only hands, an `LSP` with no manager wired) reads its own declaration after the mounts, where it read the merge. A `TaskStop` that declares `read` is now admitted under `allowDirs`, as its roster row always said. A tool under a path-confinable catalog name (`Write` / `Edit` / `NotebookEdit`) is still refused there when it declares no path target of its own: that refusal is the confinement's own and does not read the effect. The wake reconcile still reads the merge for such a name (before the mounts the band's tool may own it). The effect table (`Prepared.toolEffects`) no longer holds rows for engine tools that did not mount.
|
|
195
|
+
- **Who is hurt.** A deployment whose own tool shares a name with an engine tool and leaned on the engine's `read` to pass an `allowDirs` confinement (`ReadToolResult`, `ExitPlanMode`, `EnterPlanMode`) is now refused there unless the tool says `effect: "read"`. A test or probe that pinned an entry for an unmounted engine name (an `LSP` row on a manager-less hands leg) sees no entry (reads as write). *server* (host tool family) / *cli* (local tool family): your tools whose names are engine names read their own declarations now; no consumer-side code change is needed when the declarations are true.
|
|
196
|
+
- **How to migrate.** Rename your tool off the engine's name, or declare its real `effect` (a read-only tool says `effect: "read"`; omitting it is a write).
|
|
197
|
+
- **Pins.** `test/backlog1680-effects-by-mount-identity.test.ts`: F1 (a member the band does not shadow reads its own declaration); F2 (a band tool that mounts reads its own effect); F3 (no entry for a name nothing mounted under: LSP, read-only TaskOutput, a shell-less read-only Bash); F4 (offload off `ReadToolResult` / plan mode `ExitPlanMode`: wake UNKNOWN, allowDirs refuses; controls); F5 (contested `Write`: the band wins, the wake reads write); F6 (engine-only names keep their wake wording); F7 (table = roster across leg shapes); F8 (an alias displaced by a band mount or an unmount follows its next claimer; the wake reads every claimer); F9 (the wake's candidates come off the catalog: a caller `Monitor` declaring read is not a safe retry); F10 (#2105: `Deploy` aliased `Bash` does not vouch for the engine shell; an unmounted engine shell leaves the claimer's own `always` on a call under the alias); F11 (an alias's tier and probe follow its member). `test/design385-slice2b-agents.test.ts` C1c reads `ListAgents` / `ListPeers` off the builder's mount write. Also `test/backlog1674-hand-band-same-name.test.ts` ② (no entry for an undeclared caller `LSP`) and `test/name-readings-behavior.test.ts` (the merge lives in `wakeEffects`; the table keeps the member's own declaration).
|
|
198
|
+
|
|
199
|
+
### A text holding a lone surrogate — a string value, an object key, a RegExp source, an Error's name or message, a tag, a binary view's brand — has its own binding form: two different such texts no longer share one digest (#2109; behavior change, ruled: clay C-R646, 2026-10-19; hard-breaking: none for plain JSON arguments, see Who is hurt; closed sets: none; exports: none; members: none) (@server @client-core)
|
|
200
|
+
|
|
201
|
+
- **Before (v7.53.0).** The binding serializer (`canonicalize`, the input of `boundInputHashOf`, the license judge's exact digest and the checkpoint row digest) emitted every text raw (a string value, an object key, a RegExp source, an Error's name and message, a URL, a tag) and the digest hashed it as UTF-8, which replaces every lone surrogate (U+D800–U+DFFF without its pair) with U+FFFD — so `{ leaf: "\uD800" }`, `{ leaf: "\uD801" }` and `{ leaf: "�" }` hashed alike, and so did `{ "\uD800": v }`, `{ "\uD801": v }` and `{ "�": v }`: one row `boundInputHash` and one answer `inputDigest` for each such family. Every reader that compared bytes by that digest let one lone surrogate stand for another: `createApprovalPolicy`'s "the request still holds the shown bytes" (a request rewritten from `"\uD800"` to `"\uD801"` while its approval was pending was allowed and ran the rewritten bytes) and the synchronous gate's license (a resolver that showed the person `"\uD800"` while `"\uD801"` ran was allowed as `human_allowed` on a real-approval ask). Only the resumed belt was not fooled — it compared the bytes that run with the parked ones by structural equality (`deepJsonEqual`), so a resumed leg's PreToolUse rewrite from one lone surrogate to another was re-checked as unseen bytes (in this version's earlier stage a digest-based reading of "the parked bytes" let that through too; fixed before release). Now every such text has its own digest, and every one of those readers tells them apart.
|
|
202
|
+
- **Now.** ONE spelling law for every text the serializer emits (`segment`, `src/core/canonical-json.ts`; seven sites: a string value, an object key, a RegExp source, an Error's name and message, a URL, a tag, a binary view's brand): a well-formed text is `<utf8 byte length>:<text>`, exactly as before, and a text with a lone surrogate is `S<length>:<its JSON spelling>` (JSON escapes the lone surrogate). Every digest minted over well-formed values is unchanged, with two exceptions, both reachable only through a value a deployment hands the engine (model arguments are JSON): a binary view (typed array / `DataView` / `Buffer`), whose brand is now length-prefixed like every other text, and a boxed `String` (`new String(…)`) holding an astral character, whose own index keys are the character's two surrogate halves. All readers of the serializer — the row's `boundInputHash`, `licenseCovers` (the resumed belts' "are these the approved bytes", the park's target mint, the approval object's targets judge, the synchronous gate's license and "the request still holds the shown bytes"), the row digest — tell such texts apart with no reader of their own.
|
|
203
|
+
- **Who is hurt.** A row parked by v7.53.0 or earlier with a lone surrogate in ANY member its row digest covers — an argument value or key, the gate's `reason` (a deployment policy's ask text), the `toolCallId` (minted by the provider) — and a row whose arguments hold a binary view or a boxed `String` with an astral character (the two exceptions above), no longer recomputes its digest, so the resume is refused before the claim as damaged (`requirements_damaged`, the row pending): re-issue the call. A host that compared argument digests it stored from the old engine against new ones for such values sees them differ. Rows and arguments of plain JSON values that are well-formed throughout: nobody. Only bindable values (the #2111 section: a record of plain JSON data nested at most 256 levels deep) have an exact digest, so no sentinel ever stands in a license; every value the digest does not tell apart — inside that domain, and in the total digest the row digest takes over other values — is listed in `KL-2109-a`.
|
|
204
|
+
- **How to migrate.** Nothing for well-formed values. A host holding parked rows that may carry lone surrogates expires them and lets the model re-issue.
|
|
205
|
+
- **Pins.** `test/backlog1391-approval-object.test.ts` (K-sur: a resumed PreToolUse rewrite `"\uD800"` → `"\uD801"` in a value reaches the deployment policy's recheck and is refused, the rewritten value never runs, the same lone surrogate handed back runs; K-sur-key: the same in an object key, to `"\uD801"` and to `"�"` — both are regression guards on the belt's reading, green on v7.53.0 too, not proof of the digest fix; the digest units — the proof: distinct lone surrogates differ in a value, a key, a RegExp source, an Error message and a tag, the same one is equal, twenty well-formed keys keep the old spelling byte for byte).
|
|
206
|
+
|
|
207
|
+
### Arguments no approval can be bound to — anything but a record of plain JSON data nested at most 256 levels deep — are refused before anyone is asked, by one judge in one sentence, and so is a person's edit to them on every path; every answer is licensed by the exact digest it is judged by (#2111, #1391; behavior narrowing, ruled: clay C-R646, 2026-10-19: such arguments and edits are refused, never asked; hard-breaking: none for plain JSON arguments; closed sets: none; exports: none; members: +1 optional `EngineAttestation.bindingRefusal`) (@server @client-core)
|
|
208
|
+
|
|
209
|
+
- **Before (v7.53.0).** An answer's record (`inputDigest`) was minted AND judged with the lossy `boundInputHashOf`. For arguments whose canonical form a sentinel shortens — nesting deeper than 256 levels or a cyclic object graph (values that clone, but no exact digest binds) — a person's yes ran as `human_allowed`, bound to a lossy digest that other such arguments share: two argument sets that differ only below the cap (a deep leaf `x` against `y`) had ONE digest, so a yes for one licensed the other. (A difference above the cap changed the lossy digest too: `command: "safe-operation"` against `"dangerous-operation"` beside the same deep padding hashed differently and the license refused it.) A function, a symbol or a throwing getter does not clone: those were already refused before any ask, as "could not present the args safely", and still are. In this version's earlier stage the mint stayed lossy while the judge became exact, and such a yes on a `requiresRealApproval` ask was refused with "the resolution's allow carries no engine settlement record for it", which was not the cause.
|
|
210
|
+
- **Now.** An approval can be bound only to arguments that are a record of plain JSON data nested at most 256 levels deep — the BINDABLE domain, judged in ONE place (`bindableValueOf`, `src/core/canonical-json.ts`: at the top the payload's own shape — a call's arguments a record, a question's answers a list — never a bare scalar or `undefined`; inside, records, dense lists, strings, finite numbers, booleans, `null`, a tree; read by the engine's one receipt of a caller's value FIRST — the top's shape is then read off its copy, so a Proxy is refused before anything is asked of it — so no getter, Proxy, hidden or symbol-keyed member, function, symbol, bigint, Map / Set / Date / binary or other object passes, nor a cycle; then JSON's own five — a number that is not finite, a hole, a named member on a list, an `undefined` list element, one object met twice). The exact digest is defined by it: `exactBoundInputHashOf` has a value exactly for bindable values (the serializer's own lossy marks retired). That one judge is asked wherever an approval is about to be asked or adopted — through `bindableArgsOf` (`src/core/tool-policy.ts`) at every seat and edit entry, through `capturedJsonOf` at the durable capture — on the arguments as given (no getter is ever called, a value read through one has no fixed bytes; the same reading every license and mint takes), and every refusal is its one sentence (`bindingRefusalOf`), naming what it met and where by the receipt's own reading (a value is never asked what it is: a Map, a class instance or another realm's list is "an object that is not a plain record or list") — e.g. "no approval can be bound to the arguments of "X": they hold an object that is not a plain record or list at args.when — an approval binds arguments that are a record of plain JSON data (…) nested at most 256 levels deep; denied fail-closed": before the ask resolver calls the approver (`presentation_failed`), in its no-approver arm (settled `no_approver`, as every headless refusal is — the denial-limit stop and its notice read that word; the tool text is this sentence alone, without the headless remedy, whose approver half would not let the call run, and the `delegation.ask_unresolvable` notice still goes out with this sentence as its remedy — the refusal carries the finding on its engine record, `EngineAttestation.bindingRefusal`, and the gate's deny exit reads it rather than judging the arguments again; a mode that releases the ask, `bypassPermissions`, still runs such arguments, since a release needs no binding, and a literal blanket `onAsk: "allow"` allows before any judge, as before (an ask requiring a real approval aside) — neither is an approval), before `createApprovalPolicy` calls `approve` (`presentation_failed`), where the resolver adopts a person's edit (`presentation_failed`), in the gate's approved-edit loop, where an edit a host's own `resolveAsk` hands back is judged (recorded as the engine's resolver records the same refusal: `deniedBy: "ask_resolution"`, `presentation_failed`; the engine's own resolver's edit is already bound and passes as it is), and where a durable decide's edit is captured, before the claim (`checkpoint.invalid_outcome`, the row pending; no `detail.field`). A persisted payload has ONE capture (`capturedJsonOf`, `src/core/canonical-json.ts`): the judge on the value as given, then its JSON form — a durable decide's edit and a question's answers both take it. The durable capture's own plain-JSON domain check and its two sentences ("could not be captured as plain data", "outside the plain-JSON value domain") are retired into it, and so are the seats' "could not present the args safely" and the edit's "is not safely clonable": a cycle, a list's named member, a Map or nesting past 256 levels reads the same on the live and the durable path. The gate's record of what it decided (`finalInput`) is read by the engine's one receipt too: a plain-data projection, frozen at every level, of the input's containers to 256 levels (one receipt per record; a container's list of symbol-keyed markers sits one level below it, so the record is at most 257 deep), with a `[not recorded: …]` marker in place of every member that is not data (a bigint, a Map, a Date, a class instance, a function, an accessor; a symbol-keyed member's marker in its container's `[not recorded: symbol-keyed members]` list, under a key no data member holds, so no recorded member is ever overwritten), never a reference to the caller's objects — on either exit: an allowed call runs with the value itself, the record being its projection; and an approved edit refused as unbindable is no args of the call: it leaves through the gate's one exit for a value nothing took (`untakenValueExit`, the exit the model's args the gate cannot take into its own copy already leave by), so no reading of the tool's runs over it and the refusal carries no judged bytes — the deny exit's one receipt records the edit as given, as its projection, or as the one marker when the receipt refuses it whole (a Map, a Date, a class instance, a Proxy or a function in place of the arguments; never handed to the tool's reading as if it were arguments) — so nothing of the input runs, at the exit or in the deny observer's copy; and a getter that throws in a host's input to `runToolGate` is refused as a deny (v7.53.0's record copied the input again and let the thrown value escape `runToolGate`). Every answer's record is minted with `exactBoundInputHashOf` — the digest the one license judge (`licenseCovers`) reads — at all five mints: the ask lane, the ask resolver, the approval policy, the inherited-grant collapse of one call's two frames, and the park row's `boundInputHash`. The "no engine settlement record" refusal is said only where no record of THIS ask covers the bytes that run — no record at all, one for other bytes (a resolver that showed the person something else), or one for another question (a host that wraps `resolveAsk` without passing the ask's `origin` and classes). `createApprovalPolicy`'s "changed while its approval was pending" keeps its one cause.
|
|
211
|
+
- **Behavior narrowing (ruled: clay C-R646, 2026-10-19).** *What changed (where an ask is put — a mode's release and a literal blanket `onAsk: "allow"` still run any of these, as before):* a call whose arguments are not a record (`undefined`, a scalar, a list — never the engine's own arguments, which are always a record; a host calling a seat directly) is refused, never asked, and so is an edit that is not one (a durable decide's `updatedInput` of `null`, `-0` or a list, which v7.53.0 recorded — normalizing `-0` —, claimed the row for and left to fail the tool's validation; a host resolver's edit that is a list, which v7.53.0 adopted); a call whose arguments are plain records and lists but outside the bindable domain — nesting deeper than 256 levels, a cycle, a number that is not finite, a bigint, a hole, a named member or an `undefined` element on a list, one object met twice — is refused, never asked, where v7.53.0 asked and ran it on a person's yes; so is a person's edit into such a shape (live, v7.53.0 adopted it; durable, v7.53.0 already refused every such shape but the depth, in sentences of its own); a value v7.53.0's seats cloned into plain data and then asked about — a class instance, another realm's object or list, a list of a subclass, a record with a symbol-keyed or non-enumerable member (the clone dropped the prototype and those members) — is refused at both asking seats, at the resolver's edit adoption, in the gate's edit loop and at the durable decide; `createApprovalPolicy`, called directly with a Map / Set / Date / binary value, refuses it too (the resolver already refused those; the policy cloned and judged them); with no approver wired the refusal of such arguments says why (still `no_approver`, without the headless remedy); a host's own `resolveAsk` that approves with an edit into such a shape is refused in the gate's edit loop (v7.53.0 adopted it, reading a getter once, and ran it unless the ask required a real approval); a question's answers holding a getter, a class instance or a symbol-keyed or non-enumerable member read as contradictory (v7.53.0 cloned them first); the durable park binds its row only to bindable bytes — a backend that keeps the structured-clone domain (the in-memory one) refuses such arguments with the park's own sentence (a bigint used to park there), while a JSON or undeclared backend first projects them into JSON (a non-finite number, a hole and an `undefined` element become `null`, a list's named member is dropped, sharing becomes copies), re-judges and parks the projection, and refuses only what its codec cannot encode (a bigint, in the codec's sentence) — only a deployment's rewrite can hold such values, model arguments are JSON; and a getter in arguments a host hands a seat directly, or in a person's edit (live, durable, or from a host's own resolver), is refused and never called, where v7.53.0 read it once into its snapshot (the Runner's own arguments are the gate's copy and hold none). A function or a symbol was already refused before any ask ("could not present the args safely") and still is, now named with its place. A refused input's `finalInput` holds a `[not recorded: …]` marker in place of every member that is not data, where v7.53.0 held a clone of a Map or a Date (or, around a function, a shallow copy of the live members, reading their getters again); a container with a symbol-keyed member keeps its data members, the symbol-keyed ones listed by marker; an allowed call's `finalInput` is the same projection, which differs from v7.53.0's (a deep-frozen clone of the args the call runs with) in exactly three ways — a bigint is a marker (the call runs with the bigint), so is the member that closes a cycle, and containers past 256 levels are cut to one marker (`[not recorded: containers nested deeper than 256 levels]`) — since nothing else that is not data reaches the allow exit (the gate's own copy refuses a Map, a Set, a Date, a binary value and a function, and its clone has already read a getter into data, dropped a symbol-keyed member and a class instance's prototype, as in v7.53.0). A host's own resolver's edit refused by the judge is recorded as the resolution's refusal (`deniedBy: "ask_resolution"`, `presentation_failed`), the record the engine's resolver mints for the same refusal (v7.53.0: `deniedBy: "policy"`, no settlement). *Who is hurt:* only an in-process host can reach any of this — server, cli and client-core hand the engine JSON (an edit over HTTP, a UI's answer); such a host whose hooks or policies rewrite arguments into such values and expected a person to approve them (or edits approved arguments into such a shape); a host that calls `createApprovalPolicy(...).check()`, `resolveAsk` or `runToolGate` itself with such values, or hands a decide, an edit or an answer built with getters or class instances; a consumer pinning a retired sentence ("could not present the args safely" at the two seats, "the approved edit … is not safely clonable", "could not be captured as plain data", "outside the plain-JSON value domain"; the repo's own pins re-pinned: `test/core-toolgate-coverage.test.ts`, `test/ask-bidi-presence.test.ts`, `test/backlog1359-allow-layer-only-home.test.ts`, `test/question-sync-first.test.ts` — its getter edit is now refused, never read, and its `null` / `-0` edits are refused pre-CAS — `test/gate-judged-is-run.test.ts` J5b / J8, `test/ask-bidi-presence.test.ts`'s bare-string case (now refused before the approver is shown it), and `test/park-mint-backend-fidelity.test.ts` — a bigint no longer parks on the in-memory backend); a consumer that renders `presentation_failed` alone, which shows the family and not the cause — render the message. *How to migrate:* pass plain JSON data; keep nesting below 128 levels (the depth a durable park can hold).
|
|
212
|
+
- **Pins.** `test/backlog1391-approval-object.test.ts` — the bindable domain (each class outside it has no exact digest and is named; inside, 256 levels, `-0`, an `undefined` member, a null-prototype record, lone surrogates keep their digest); E-dom (a person edits approved plain arguments into a cycle, a list's named member, a Map or 257 levels: the SAME sentence on the live resolver, `requiresRealApproval` and not, and on the durable decide, naming what was met; nothing runs, the row pending) and E-dom control (256 levels: adopted live, run durable); NA (no approver: 300 levels, a function, a getter or a Date — the sentence the asking seats give, settled `no_approver`; the resolver's approver seat and `createApprovalPolicy` give the same sentence, nobody asked; plain arguments keep the headless refusal); NA-gate (a real Runner with no approver: the tool text is the binding's sentence alone, the `delegation.ask_unresolvable` notice carries it as its remedy; plain arguments keep the headless remedy); DL-stop (auto mode, no approver, every call 300 levels deep: `failed / classifier.denial_limit`, its notice and `delegation.ask_unresolvable`, nothing ran); HE (a host's own `resolveAsk` approving with an edit into a getter, a bigint, a non-finite number, 300 levels, a cycle, a shared object, a list's named member, a class instance or a symbol-keyed member: refused in the one sentence by the gate's edit loop, the getter never called; a plain edit adopted); WIDE (a plain record of 130 000 members: bindable, digested, asked once at both seats, no throw); the refusal's own reading (a `Symbol.toStringTag` getter, a subclass's list, another realm's list: "an object that is not a plain record or list", the getter never called); LIST-edit (a durable decide's list edit refused before the claim, the row pending); REC (a refused caller input's `finalInput`: markers for a Map and a function, symbol-keyed members listed, data members kept — a recorded member named `Symbol(s)` or like the list's key is never overwritten, two symbols of one description get two markers — and the Map changed afterwards changes nothing recorded); REC-allow (an allowed call's record: a bigint is a marker while the call runs with the bigint); REC-deep (6 000 levels with a function: recorded to 256 levels then a marker, one bounded receipt, under 100 ms); HE-throw (a host resolver's edit with a throwing getter: the refusal returned, not thrown, the getter never read, the edit recorded by the deny exit's one receipt — its projection); REC-not-args (a host resolver's edit that is a Map, a Date, a class instance, a Proxy or a function in place of the arguments, under a tool reading that uses `in`, spreads or hands the value back — fifteen cells: the refusal returned in the one sentence, no reading run over the edit or its record, `finalInput` the one marker); REC-revoked (a revoked Proxy, whole or as a member: the judge, the ask resolver's approver seat, `createApprovalPolicy` and a host resolver's edit each refuse it in the one sentence, nothing thrown, nobody asked); REC-allow-three (an allowed call's record against v7.53.0's clone: the same for a hole, a list's named member, `undefined`, `-0`, non-finite numbers, a shared object, a null-prototype record, a getter, a symbol-keyed member, a class instance and 256 levels; markers for a bigint, a cycle and 300 levels; a Map, a Date or a function in a policy rewrite refused before the exit); REC-depth (a symbol-keyed member on the 256th container: its marker list at the 257th level, nothing deeper); KL-2111-a / KL-2111-b (the two known limits `docs/KNOWN-LIMITS.md` enters for this section, each pinned: the sentence names the first fault the receipt meets, so a list holding a getter is refused for the getter first; a list's symbol-keyed markers are absent from the record's JSON form, as in v7.53.0); PROXY (a Proxy wearing a list or a record: refused as a Proxy before its shape is read); X2 (a host resolver handing the engine's resolver other bytes: the exit reads the refusal's own record — the binding's sentence alone when the asked bytes are unbindable, the headless remedy when they bind); one capture (an answer with a getter, unread, or a class instance: contradictory; plain answers keep their JSON form); R18-DEEP (300 levels at both asking seats: nobody asked, nothing runs, `presentation_failed`); A2-sub (a resolver showing `safe-operation` + 300-deep padding for a `dangerous-operation` request: refused before the person is asked; without padding the license refuses the substitution, unchanged); P-exact (the park row's binding is the exact digest; 130-deep arguments never park); `test/backlog1359-allow-layer-only-home.test.ts` G5 (re-pinned: the approval policy refuses 260-deep arguments before its approver is asked; nothing reaches a card) and G5 control (unchanged); `test/ask-bidi-presence.test.ts` (the cyclic-graph cell names the cycle; the scan itself fed a cyclic graph: no hang, no throw, still detects); `test/core-toolgate-coverage.test.ts` (a getter throwing a hostile value: `runToolGate` returns its deny, the getter tried once by the gate's own copy and never by the record; the seat's judge never calls it).
|
|
213
|
+
|
|
214
|
+
### The post-compaction restore is a Read on the model's behalf delivered in the attachment seat's frame: each restored file is an engine-note frame after the compaction entry, read with the Read tool's read under the synthetic-Read judge and deduplicated against what the summary leaves; the `<working-file>` blocks in the summary and the second file reader retire (#1311 car C; behavior change, open to veto; behavior narrowing, open to veto: a file a deny or mandated-ask layer refuses, or that a layer cannot answer for (`unanswerable`: a throw, a timeout, an unreadable rule store, a rewrite), is no longer restored, a file over its share is named, not sent in part; **hard-breaking, open to veto: `MaybeCompactOptions.workingFileAttachments.readFile` is replaced by `restore` (+ optional `undelivered`), and `MaybeCompactOptions.onApplied` loses its first parameter `attachedComplete`**; closed sets: none; exports: +2 types `CompactionRestoreAsk` / `MintedAttachmentFrame`; members: +1 optional `UserMessageAttachment.origin` (`"compaction_restore"`)) (@client-core @cli @server @test)
|
|
215
|
+
|
|
216
|
+
- **Before (v7.53.0).** A landed compaction re-read up to three recently read files with a second reader (`readTaskFile`: the hands' resolution and read-deny table — the person's rule deny rows compiled into it included — but none of the other layers the synthetic-Read judge consults: caller and inherited policies, skill narrowing, org rules, asks; the whole file buffered) and appended their sanitized contents, clipped at 16 000 of the file's own characters, INTO the summary text as `<working-file path="…">` blocks — ahead of the kept tail, though they are newer than it. "Still visible in the kept tail" was read from the kept tail's Read tool calls only, so a file a user attachment frame in the kept tail already showed was re-attached into the summary (a duplicate; a whole re-attachment re-registered its read state, a clipped one dropped it). A deny-withheld file was a `<working-file … withheld=…/>` block; a vanished one was skipped.
|
|
217
|
+
- **Now.** One reader, one frame: the restore is a Read the engine issues on the model's behalf — the run's synthetic-Read judge (probe face `compaction-restore`), then the Read tool's own `readForModel` (the Read tool's gate order: a judgment that re-reads the person's rows stands for the read after it); a judgment that named another file than the read's own final resolution is the attachment's own "changed while it was being checked" — and its frame is the attachment seat's (`src/core/user-attachment.ts` `readCompactionRestore`). **The restore holds no object** (it reads as the Read tool reads, on every execution lane, including the ones that cannot bind a read); **a user's declared attachment still holds its read** (`openBound`), since a person declared that path. Each restored file is its own `provenance: "engine-note"` frame after the compaction entry (summary, kept tail, restored files — the reference's order, cli296.js:249517 `hookResults`), opening `The conversation was summarized, so the engine re-read a file it had read before (attachment n of N).`, record `origin: "compaction_restore"`; the derived views name it `file re-read at summarization n of N`. Visibility in the kept tail is one predicate over both carriers — a Read call with real content, or a frame whose record says its contents were delivered (an `unchanged` frame is not visible). The reads dedup against the read state the summary leaves (`readFileStateAfterSummary`, the one answer the clear itself applies; the reference's restore turns its unchanged-dedup off the same way, cli296.js:250054 `dedupUnchangedReads`), so a summarized-away file is never answered "unchanged", and they are not held back by the 60 s disclosure throttle (the disclosure the window remembers left with the summarized context; #2125); the read happens before the landing and the read state's clear plus the restored reads' commits happen together after it — a pass that does not land touches neither. **The per-file share is counted in the file's own characters** (`maxCharsPerFile` keeps the v7.53.0 unit: the text before the Read's line numbers), **the total in the file contents the frames send** (line numbers included — the unit of the window's ~15% share, so short-line files cannot drown a small window: their line numbers can outweigh their text several times over; a refusal's sentence is not contents and is not counted); an image costs the estimator's fixed weight × chars per token in both. A PDF is a reference (size and page count, no PDF pipeline run). Every candidate gets a frame: a file over its per-file or total share, a spent total included, is a frame that names it and says it is too large (the reference's `compact_file_reference` cli296.js:250425); a file a judge layer refuses carries that layer's message (a layer that cannot answer: the judge's sentence alone); a file the read itself refuses (gone, outside the roots, the read-deny table) carries the Read refusal; each of these is also an `onError` line (phase `degraded`, classification `attachment-read`), since a restore's outcome has no wire face. When the request guard's last-resort trim drops an attachment frame (a restore's or a user's) or a Read's result, the read-state entry it delivered retires with it — by transmission, so a later read of the same file survives, and a call id that a kept message still carries is not gone — and the next Read sends the contents again (v7.53.0 retired nothing on a trim: a Read whose result the trim had dropped was answered "unchanged since you last read it" with the contents gone). A read state rebuilt from the transcript by a later run (a new process on the same session) carries the same transmission (a frame's entry the frame's spelling, a whole-file Read result's entry its call id), so the trim retires it there too.
|
|
218
|
+
- **Behavior change (a user's attachment frame's words, open to veto).** A user's attachment whose path resolves, at the held read's own final resolution, to another file than the one opened (re-pointed between the open and the read) now says the attachment's own sentence — "The file changed while it was being checked, so it was not read." — with no data, the record's note `target_moved`; v7.53.0 put the Read refusal ("… resolved to a different file while it was being checked, so it was not read.") into the frame's data under "The contents were not attached; the reason is stated above." (note `not_attached`). The outcome (`not_attached`) and the `human_input` projection are unchanged; a judgment that named another file already said the first sentence.
|
|
219
|
+
- **Behavior change for `compacted.attachedFiles` (shape unchanged, @client-core @cli).** The list is projected from the delivered frames' records — the frames that carried contents: `path` as before, `chars` now counts the characters of the line-numbered data the frame delivered (it counted the raw clipped characters; the total share is counted in this unit, the per-file share in the file's own characters, so for one file the two differ by the line-number prefixes), `truncated` = the frame holds a partial view; a file named too large, refused, gone, an empty file or a PDF reference is not listed (no contents were delivered). A display that shows `chars` shows a slightly larger number for the same file.
|
|
220
|
+
- **Hard-breaking (open to veto).** *What changed:* `MaybeCompactOptions.workingFileAttachments.readFile(path) → string | null | { withheld }` is removed; the option takes `restore(ask: CompactionRestoreAsk) → Promise<ReadonlyArray<MintedAttachmentFrame>>` (and an optional `undelivered(label, reason)`), and `onApplied(attachedComplete, preserveReadState?)` becomes `onApplied(preserveReadState)`. *Who is hurt:* a host that calls `maybeCompact` directly and passed `readFile` or read `attachedComplete` — none in server / cli / client-core (grepped). A consumer that parsed `<working-file>` out of the persisted summary text finds none (none found). A deployment that relied on the restore being bound to the judged object: none (the v7.53.0 restore was not bound either). *How to migrate:* use the Runner's compaction (it wires the restore); a direct caller that wants no restore omits `workingFileAttachments`, and an `onApplied` drops its first parameter. A direct caller that wants the restore loses it: `restore` must return minted frames, and their mint (`readCompactionRestore` / the frame mint) is not exported — there is no such caller in server / cli / client-core.
|
|
221
|
+
- **Pins.** `test/backlog1311c-compaction-attachments.test.ts` (C1 frame after the compaction entry, same frame shape and `readForModel` read, marked static sentences, contents outside the marks; C2 dedup against the post-compaction context; C3 an `unchanged` kept-tail frame is not visible; C4 a pass that does not land keeps the read state and adds nothing; C5 / C6 the views and `resumeAt` on a restore frame; C7 no `<working-file` literal or second reader in `src/`; C8 `attachedFiles` against the v7.53.0 shape on a real Runner; C9 / C9b the per-file share in the file's own characters and an image's weight; C10 a lane without `openBound` still restores, a user's attachment there does not; C10c / C10d the read held to the file the judgment named; C11 a guard trim of a restore frame makes the next Read send the contents; C12 / C12b a summarized-away disclosure reappears in the restore frame (text and notebook); C13 a PDF is a reference, not listed; C15 / C15b the same after a new process rebuilds the read state from the transcript (a frame, a whole-file Read result); C16 / C16b short-line files stay within the window's share; C16c a spent total still gives every later candidate its frame; C17 a trim that drops a Read's result makes the next Read send the contents, a kept result (also under a reused call id) keeps its entry; C18 a paged restore is charged what it sent and the next page is still served).
|
|
222
|
+
|
|
223
|
+
### The device lane's cross-boundary vocabulary and data plane: every tool states the legs a call travels (`ToolRosterEntry.boundaryLegs`), one table answers a call's cross-boundary obligation (`crossBoundaryObligation`), the deployment's posture has a seat (`RunnerDeps.crossBoundaryPosture`), an MCP / A2A deployment can declare an engine-only tool (`toolAxes[tool].boundaryFlow: "engine"`) — the launch gate (the last section of this release) reads it (#469 O9b S0; additive; behavior change: none in any decision the engine mints, and a host's policy or hook decision is received exactly as v7.53.0 received it; behavior narrowing, open to veto: two new refusals at configuration, see below; behavior loosening: one `toolAxes` form v7.53.0 refused is accepted, see below; hard-breaking: type-level only, see Who is hurt; closed sets: `EngineNoticeCode` +1 `config.cross_boundary_posture_undeclared`, `CheckpointConfigRefusalCode` +1 `config.cross_boundary_posture` (41 → 42), +3 sets `CrossBoundaryTaintBit` / `CrossBoundaryPosture` / `BoundaryLeg`; exports: +2 functions `crossBoundaryObligation` / `readCrossBoundaryRecord`, +4 constants `BOUNDARY_LEGS` / `CROSS_BOUNDARY_TAINT_BITS` / `CROSS_BOUNDARY_POSTURES` / `CROSS_BOUNDARY_RECORD_TYPE`, +10 types; members: +1 required `ToolRosterEntry.boundaryLegs`, +1 required `ToolFaceDeclaration.boundaryLegs`, +1 optional `ToolSpec.boundaryLegs`, +1 optional `RunnerDeps.crossBoundaryPosture`, +1 optional member `boundaryFlow` of `McpServerSpec.toolAxes[tool]` / `A2aServerSpec.toolAxes[skill]`) (@server @client-core @test)
|
|
224
|
+
|
|
225
|
+
- **What lands.** The vocabulary the launch gate reads: the tree's two taint bits and its `governed` fact (`CrossBoundaryTree`, presence-coded, joined only upward), the three legs (`env` — into or out of an execution environment; `outside_read`; `outside_write`), and the one obligation table: on a governed armed tree an outside write is a person's (`mandate`), and so is a device-bound call after an off-target read; an off-target read on an armed tree sets the off-target-read bit once allowed (`record`), governed or not; a tree that never armed (the host lane) has no obligation in any cell. Every roster row carries its legs: a catalog definition declares them (required — a definition without them does not compile); the registry `TaskOutput` / `TaskStop` and `Artifact` declare `"per_call"` (the legs follow the handle / the action, given by their dispatch); an MCP tool or A2A skill states `[]` exactly when its deployment's `toolAxes` entry declares `boundaryFlow: "engine"`; a tool a host mounts with a core contract (`contract.contractId` beginning `core.`) is read by its own declaration, like the engine's definitions; every other row follows its effect (`read` ⇒ `["outside_read"]`, anything else ⇒ `["outside_write"]`) — a caller tool without a core contract is read by its effect when it declares well-formed legs (its declaration is not read), and a malformed declaration refuses the roster (`config.tool_face_invalid`). The session record is a custom entry (`CROSS_BOUNDARY_RECORD_TYPE`, not replayed to the model) and `readCrossBoundaryRecord(branch)` its reader: the branch's records are joined (the state only grows, so a later record with fewer bits never lowers it), and any record or entry that cannot be read reads as the strictest state. Nothing writes the record, mints the notice or reads the posture past its door in this version. The ask class, the mandate word and the decision member that put the launch gate's question to a person land with the station that enforces them (the launch-gate section): this section alone adds none of them to the decision face.
|
|
226
|
+
- **Behavior narrowing (open to veto).** *What changed:* (a) `RunnerDeps.crossBoundaryPosture` other than `"single_user"` / `"multi_tenant"` refuses the prepare (`config.cross_boundary_posture`, the two words in the sentence) and a resume before its claim (`checkpoint.config_refused`, `detail.field` `"deps"`); (b) a `boundaryLegs` declaration that is neither `"per_call"` nor a list of legs refuses the roster (`config.tool_face_invalid`, the code a caller tool's bad face already has) on every row — an engine definition, a host's tool with a core contract and a caller's tool alike (a caller's declaration is not read, and a malformed one is not let pass either). A caller's tools are judged where the roster enters (the config-door phase's first door), every tool the caller hands, mounted or not — one the valve takes out (`excludeTools`, `excludeAllTools`), one the tool model gate trims and one a later same-named tool shadows included; every other source at the row mint. A list whose slot holds anything but a leg word — a list or a record included (`[["env"]]`, `[{}]`) — is such a declaration, judged at that door like a word outside the set. On a resume the tools the config re-supplies are judged by the same function before the claim (`checkpoint.config_refused`, `detail.code` `config.tool_face_invalid`, `detail.field` `"taskConfig"`, the row pending and byte-identical on any store); that screen reads the roster through the engine's one receipt and runs none of the caller's code, so it leaves to the leg's prepare, after the claim, only a declaration it could read by running that code — an accessor (as the name, the declaration or a slot; an entry or a list of another prototype, which its prototype chain answers), a Proxy — or one the receipt does not take (a non-enumerable member or slot) (KNOWN-LIMITS KL-469-i). *Who is hurt:* a deployment that writes a misspelled posture; a host that puts a malformed `boundaryLegs` on a tool, mounted or not (a member v7.53.0 ignored). *How to migrate:* spell the posture exactly; correct or drop the member.
|
|
227
|
+
- **Behavior loosening.** *What changed:* v7.53.0 refused every `toolAxes` entry naming `boundaryFlow` (an unknown member, `config.invalid_argument`); this version accepts exactly one form, `boundaryFlow: "engine"` with `egress: false` in the same entry (the roster row then states `[]`). Every other form stays refused under the same code, the remedy at the head of the reader's sentence (`needs "egress": false in the same entry — add it, or drop "boundaryFlow"`): `egress: true` contradicts it, and an absent `egress` would leave the server's open-world hint, or an A2A skill's external-write default, in force beside an engine-only claim. *Who is hurt:* nobody relative to v7.53.0. *How to migrate:* nothing; a deployment declaring an engine-only tool writes both members.
|
|
228
|
+
- **Hard-breaking (type level).** *What changed:* two closed sets gain a word (`EngineNoticeCode`, `CheckpointConfigRefusalCode`); `ToolRosterEntry` and `ToolFaceDeclaration` gain a REQUIRED member; a non-empty roster's `digest` changes once (the new column is in the digest; an empty roster digests as before). No checkpoint row format moves in this version: the row's capability mark and its reader-requirement word land with the session record's writer and reader. The machine wire manifest does not list the `ToolRosterEntry` member (the type is declared through TypeBox, which the manifest reader does not resolve): this bullet is its record. *Who is hurt:* a downstream table exhaustive over one of the two sets (it stops compiling, or misses the word at run time); a host that builds `ToolRosterEntry` / `ToolFaceDeclaration` literals or validates roster rows with its own copy of the schema (`additionalProperties: false`); a consumer comparing roster digests across versions. *How to migrate:* add one row per new word (`config.cross_boundary_posture_undeclared`: audience operator; `config.cross_boundary_posture`: `detail.field` `"deps"`, correct the deployment and resume the same token); read rows with the engine's `ToolRosterEntry` schema (`boundaryLegs` is a list of legs or the string `"per_call"`); re-baseline roster digests.
|
|
229
|
+
- **Consumers.** *server:* feed `RunnerDeps.crossBoundaryPosture` explicitly and the same on every worker (the launch gate that makes it decisive lands with the wiring); add the notice's audience row (`operator`); every `toolAxes` entry that declares `boundaryFlow: "engine"` carries `egress: false` (an A2A skill's default egress is `true`); the injection gate strips a request-side `boundaryFlow` — the engine cannot tell a request's MCP entry from a deployment's on an unlocked list; a host tool given a core contract is read by its own leg declaration (the trust lies with whoever attaches the contract). *client-core:* `EngineNoticeCode` +1, the roster column (a list or `"per_call"`). *test:* the definition-by-definition leg table is the pin's `LEG_TABLE` (S0-2), observable on every wiring manifest's `tools.entries[].boundaryLegs`.
|
|
230
|
+
- **Pins.** `test/design469-r3-boundary-legs.test.ts` (S0-1 the table over 128 cells and literal rows; S0-2 the catalog, the real-lane roster, a caller's and a core contract's reading, a malformed declaration refused on every row, a caller's at the door whether or not it mounts (a slot holding a container included), the door's judge running none of the caller's code; S0-3 the reader, the remedy against the refusal's bound, the MCP / A2A lanes; S0-5 the posture door, prepare and resume, and the zero-behavior arm; S0-6 the record reader's join, its strictest readings and its guard; the decision face's class, word and member, pinned with their enforcement in `design469-r3-launch-gate.test.ts`; the notice); `test/backlog1950-resume-config-doors-preclaim.test.ts` (A / I: the posture door before the claim; A: a re-supplied tool's leg declaration, a slot holding a container included, mounted or not, before the claim; C: a getter-backed declaration read once, by the snapshot); `test/backlog126-definetool-axis-carry.test.ts` (the member is carried).
|
|
231
|
+
|
|
232
|
+
### A run of stars inside one read-deny / sensitive-path pattern segment compiles to one wildcard, so judging a path against a long run no longer takes exponential time (#2117; src only; behavior change: none, every pattern accepts and refuses the same segments; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
233
|
+
|
|
234
|
+
- **What changed.** `compileSegmentPattern` (`src/tools/fs/read-deny.ts`, the one compiler behind the read-deny rows, a task's or deployment's read-deny additions, and the sensitive-path write list `createSensitivePathPolicy`) turned every `*` of a segment into its own `[^/]*`, so `****x` became four adjacent expressions and a failing match backtracked across all of them. Measured on this tree before the change: twelve stars before a literal against a 17-character segment took 1.6–2.9 s; sixteen did not finish in 90 s; a read-deny table of 64 distinct rows of two stars each (128 stars) judged against one 10 000-character path segment took 23–48 s (16 rows: 15–19 s); nine stars in a sensitive-path pattern took 10.7 s to allow one 17-character file name. A run of stars is now one `[^/]*`: the same shapes take 0.5–6 ms. The pattern is split on `/` before it is compiled, and inside a segment `**` matches exactly what `*` matches (this compiler has no recursive-directory wildcard: a segment of stars is "any one segment"), so no pattern accepts or refuses a different segment, under any of the three case folds. The one reader that looks at the compiled expression (`isAnySegment`, whether a segment is any-one-segment) reads the folded form as it read the run. One comment (`isAnySegment`'s JSDoc) said "each star"; it now says each run.
|
|
235
|
+
- **Who is hurt.** Nobody: match results are identical before and after (the pins below compare the compiler with the unfolded one pair by pair, 13 680 enumerated and 10 680 random pairs, and a baseline-against-head probe of 16 patterns on 19 paths each, through the deny-rule matcher and the segment matcher, differed in no cell). A host whose deployment lists carry long star runs only gets faster. *server* / *cli* / *client-core*: nothing to do.
|
|
236
|
+
- **Not in this release.** Stars separated by a literal (`*a*a*a*b`) still backtrack, polynomially (the degree is the number of stars): `*a*a` against 9 999 `a`s and a `b` takes about 0.4 s, and a 64-row table of such rows takes tens of seconds. Bounding that is a behavior change (a segment-length or star-count bound with a loud refusal, or a linear matcher) and stays on #2117. A workflow script's own deny patterns are already bounded to one star per segment (`child-clamp-seats.ts`).
|
|
237
|
+
- **Pins.** `test/backlog2117-read-deny-star-fold.test.ts`: F1a–c (the compiler against the unfolded reference over 24 shapes x 4 run lengths x 3 folds x 38 subjects, a seeded 400-pattern random corpus, and the readings a person would write; they hold with or without the fold), F1d (the reference's expression with its adjacent wildcards collapsed is the compiler's), F2a–d (12 and 16 stars, the 128-star table through `compileReadDeny`, nine stars through `createSensitivePathPolicy`: upper bounds of 50 / 50 / 200 / 1000 ms, declared in `test/flaky-guard.test.ts`; the 16-star shape reads the compiled expression before the clock so a regression fails at once), F3 (`*/`, `**/`, `***/` before a literal name the same roots). Mutations: reverting the fold reds F1d and F2a–d with F1a–c green; `.*`, an empty replacement, a `[^/]+` fold of runs and a fold capped at three stars each red F1d or more.
|
|
238
|
+
|
|
239
|
+
### One shell lexer: the tightening reader's second lexer is retired — `readShellCommand` is a projection of the one lexer's words, and every reader it fed, and the activity summary, the exit-1 hint and the auto-background gate, read the lexer's facts (#994 S2 + S3, #2121; behavior narrowing and behavior change, open to veto; hard-breaking: none; closed sets: `UnresolvedReason` +1 `trailing_backslash`, `ParseFailureReason` +1 `unterminated_arithmetic_expansion`, the lexer's redirection operators +1 `<>` (none is a package-root export); exports: none added or removed — `readShellCommand`, `isFullyReadable`, `MAX_SHELL_READ_CHARS`, `SHELL_WRAPPER_TABLE` and the four shape types keep their names and shapes, now from `core/shell-run-candidates.ts`; members: none) (@server @cli @client-core)
|
|
240
|
+
|
|
241
|
+
- **What changed.** `src/core/shell-lexer.ts` (424 lines) and its byte scanners `src/core/shell-scan.ts` (168) are deleted. `readShellCommand` reads `shell/lex.ts` and adds only its own domain — the keyword strip and the additive peel; leading assignments are the lexer's bare-assignment fact, a wrapper's `NAME=value` operand its dequoted fact. The read-only allow member reads the assignment and expansion facts instead of three regular expressions; the wrapper table's integer-assignment test reads the assignment fact. The activity summary names the first segment's written command word (the program-position reader's), the exit-1 hint reads the last segment's, and the auto-background gate reads the lexer's segments and a wrapper's command-string word whole (its quote-aware split, its text prefix strip and its payload unquoting are gone). The text assignment grammar (`ASSIGNMENT_PREFIX`, `writtenAssignmentPrefix`, internal) is retired: the assignment grammar is spelled once, in the lexer.
|
|
242
|
+
- **Lexer facts this needed (bash 3.2 / 5.3 measured).** A parse failure carries the segments read before it (`readBefore`: bash runs every complete line before the one it cannot parse); `$[…]` is the arithmetic expansion it is; a backslash that ends the text is `unresolved` (`trailing_backslash`: 3.2 drops it, 5 keeps it); a line continuation between `$` and its quote opens the quote (`$\<newline>"rm"` is `$"rm"`); an unquoted here-document body's expansions are read (`${…}`, `$NAME`, `$(…)`, `$((…))`, `$[…]`, backquotes); `yields` is `many` for an unquoted arithmetic expansion, a quoted per-element parameter (`"$@"`, `"${a[@]}"`) and a tilde prefix holding a pattern character.
|
|
243
|
+
- **Behavior narrowing (the deny/ask lanes ask or reach more).** A deny reaches a program on a line before a line the lexer cannot delimit (`rm -r x⏎echo "…`); `$\<newline>"rm"` reaches a deny on `rm`; a run hidden in `$[a]`, in a parameter text the lexer does not read (`${x:-${arr[a]}}`), in an assignment's subscript (`A[x]=v cmd` — bash evaluates it), in a here-document body's `${arr[a]}`, or behind a trailing backslash on the program word, makes every other rule `unreadable`. The read-only member refuses a locale string `$"…"` (ruling C-2: both faces ask).
|
|
244
|
+
- **Behavior change, the loosening direction (open to veto).** Each is bash's reading where the retired lexer over-read, measured on both bashes: a prefix assignment's subscript or brace is no pattern (`A[0]=v cat f`, `A=x{1,2} cat f` no longer make every rule `unreadable`); a parameter expansion closes at its first `}` (`${x:-{} y`); a bracket holding a `/`, an unclosed `{a,`, are literal; a compound assignment `A=(1 2)` is one word, not a subshell; a syntax error stops the reading — what follows it on its line is not read as a run (bash runs none of it); a here-document's plain arithmetic hides no run. **The here-document `${` arm is retired (design/1102 §2.3):** an unquoted body that holds `${…}` no longer makes every name unknown — a name the body assigns (`${N:=…}`, `${N=…}`, `$((N=…))`, `$[N=…]`) is unknown after it, a parameter text the lexer does not read or a body it cannot delimit still makes every name unknown, a command substitution's assignments stay in its subshell. *Who is hurt:* a reader that relied on the blanket arm — `N=/work/a; cat <<EOF⏎${N:-x}⏎EOF⏎cat "$N"` is vouched where it was a mandated ask.
|
|
245
|
+
- **Security, stock leak closed (#2121; narrowing only — Who is hurt: none).** An expansion a brace list or a bracket class encloses (`{${a[n]},x}`, `[${a[n]}]`, `{a,${a[n]}}`) was swallowed by the pattern piece: with `n='b[$(…)]'` both bashes run the substitution while the full-shell station vouched and the classify faces read a plain pattern. The lexer now keeps it as the pattern piece's fact (`WordExpansion.inner`), and the station, the classify faces, the tightening reach and the read-only member read it as the expansion it is. An unquoted extended-glob opener (`@(…)`, `!(…)`) leaves every segment of its text `unreadable` to the tightening reach (the lexer reads a word and a group where bash with `extglob` reads one pattern). A wrapper's `NAME=value` operand is read by the launcher's own grammar (`env 'A[0]=1' rm …` peels to `rm`), the one spelling the program-position reader already used.
|
|
246
|
+
- **Security, stock leaks closed (bash 5.3 forms; narrowing).** Bash 5.3's function substitution `${ list; }` and value substitution `${| list; }` were read as parameter expansions: `echo ${ cat .env; }` was vouched by the full-shell station under a `.env` deny although 5.3 runs the `cat`, and a deny or ask row on `rm` read `echo ${ rm -r x; }` as `not_reached`. The lexer reads both as command substitutions whose list runs in the CURRENT shell (bash 3.2 refuses them as a bad substitution; measured on 5.3: what the list assigns, where it moves, what it defines outlive the word). The list is read by the walker itself, in one pass: it closes at the first `}` that starts a word where a command may start once the list's own `{` groups are closed, whatever follows the brace (`${ echo a; }x` expands to `ax`); a list that never closes, still owes a here-document body at its brace, or stands more than 64 substitutions deep is `undelimited_substitution`. The deny / ask lanes read the list as a hidden run (`unreadable`, as `$(…)`'s is), the read-only member refuses, the auto-background gate checks the list as a command; the read station plans the list as text this shell re-enters — what it reads still answers to the deny rows (`refuse`), and the segment holding it is never vouched (`N=a; echo ${| N=.; N+=env; }; cat "/work/$N"` reads `/work/.env` on 5.3). *Who is hurt:* a command holding one of these forms asks where v7.53.0 vouched it, even when the list reads nothing (`echo ${ echo hi; }`); a stored rule body spelling an unclosed one (`echo ${ cat .env }`, a syntax error to 5.3) is no longer a readable body.
|
|
247
|
+
- **Security, stock leak closed (`$[…]`; narrowing).** The read station planned an `$[…]` body as command text — its arithmetic test was written for `$((…))` bodies — so `x='a[$(cat .env)]'; echo $[x]` was vouched under a `.env` deny although both bashes evaluate x's value and run the `cat` (measured). The lexer's arithmetic kind is the station's one reading now: what `$[x]`'s evaluator re-reads is planned, as `$((x))`'s always was (`refuse`).
|
|
248
|
+
- **`$((` is arithmetic exactly where its body is one parenthesised group (behavior change, both directions; measured on 3.2 / 5.3).** The `(` right after the opening one must close right before the closing one: `$((1+2))`, `$(((1)+(2)))` and `$((1+2)\⏎)` are arithmetic; `$((git push); :)`, `$(( cmd ) )` and `$((1)+(2))` are the command substitution of a subshell bash reparses them as. Bash reads a `$((` as arithmetic FIRST — its parentheses matched, no here-document read in it — so `$((1<<2))` (`4` on both) is arithmetic, where the one lexer read an owed here-document and refused the whole text (every rule `unreadable`: a deny on `rm` asked about `rm -r x $((1<<2))`); a `$((1<<2))` nested inside another substitution's body still leaves that body undelimited. The fact is read off the walk that delimits the body; the read station's own second test of it (`isArithmeticBody`, internal) is retired, and an arithmetic body's own expansions are read with the same rule (a nested `$((…))` is arithmetic, a `${ list; }` in it runs in the current shell). *Narrowing:* the evaluator reads what a command substitution in an arithmetic body PRINTS as an expression and evaluates its subscripts (`$(( $(echo 'a[$(cmd)]') ))` runs `cmd`, measured on both bashes) — text the station does not see, so it asks where v7.53.0 vouched (`echo $(( $(date) ))`, `echo $[ $(printf x) ]`; what the substitution itself reads still answers to the deny rows) and the auto-background gate keeps such a body in front; the deny / ask lanes read `$(( 1 + 2 ) )` and `$((1)+(2))` as hidden runs (`unreadable`); the auto-background gate checks the command in `$(( git status ) )`, `$((git push); :)` and `$(( sleep 9 ) )` (kept in front; v7.53.0 backgrounded them). *Loosening, open to veto:* `N=a; echo $((N=1); :); cat "/work/$N"` keeps `N` — the assignment stands in the subshell (measured) — so the station vouches it where v7.53.0 asked; `echo $((1+2)\⏎)` is plain arithmetic to the deny / ask lanes (`not_reached` where v7.53.0 read `unreadable`).
|
|
249
|
+
- **Hidden runs in a parameter expansion and in arithmetic (narrowing; the auto-background gate and the deny / ask lanes; measured on 5.3).** A transformation that evaluates (`${P@P}`, `"${P@P}"`, `${a[0]@P}`, `"${a[']']@P}"`) runs what P's value spells: the deny / ask lanes read it `unreadable`, the auto-background gate keeps it in front, and an unquoted here-document body holding one leaves every name unknown after it. The operator is the one written right after the name and its subscript — the subscript closing at its first `]` no quote or backslash holds — so an `@x` ending a default, replacement or pattern word is text (`${USER:-me@x}`, `${x/foo/@B}`, `${var:-x@P}`, `${a[0]:-]@P}`: unchanged from v7.53.0), and a respelling transformation (`@Q` `@E` `@A` `@K` `@k` `@a` `@U` `@u` `@L`) evaluates nothing. A substring offset or a subscript naming a variable is evaluated arithmetic for every parameter — a special one (`${*:N+=1}`, which v7.53.0's station threw on: `Invalid regular expression`) and a multi-digit positional one (`${10:N}`, `${11:N+=1}`) included: `unreadable` to the lanes, kept in front, and a name it assigns is unknown after it on the word path and in a here-document body (#2122). An indirect expansion (`${!R}`) evaluates a value the text does not show: `unreadable`, kept in front, every name unknown after a here-document body holding one. The auto-background gate reads an arithmetic body as arithmetic text — it runs no program of its own, stays in front where its evaluation assigns (`$(( N++ ))`), and what it expands first is checked as any text's is (`$(( $(git push) ))`, `$(( ${| git push; } ))` and `$(( ${P@P} ))` kept in front; v7.53.0 backgrounded all three).
|
|
250
|
+
- **Behavior narrowing, open to veto: the read station asks about every expansion that re-reads its value — an evaluating transformation `${P@P}` or an indirect `${!N}` — and follows no value.** One predicate decides it (`parameterRereadsValue`, the one the deny lanes and auto-background read through `expansionHidesRun`). Wherever the station reads a parameter expansion — a word, double quotes, an unquoted here-document body, an arithmetic body, a brace list — such an expansion leaves the text unestablishable (inside a compound assignment's value, which the station does not read, the next bullet's predicate asks instead): a mandated ask on the full-shell seat (`echo ${P@P}`, `"${P@P}"`, `cat <<EOF⏎${P@P}⏎EOF`, `echo $(( ${P@P} ))`, `echo {${P@P},y}`, `N=HOME; echo ${!N}`, `echo ${!1}`, `echo ${a[0]@P}`; v7.53.0's station vouched each, while bash runs what the value spells — `@P` on 5.3, an indirect reference's subscript on 3.2 and 5.3, measured). The station reads no value: what the text assigns, how deep a value nests, whether the text assigns one at all does not change the answer, so a plain-text value asks too (`P='plain text'; echo ${P@P}`) and a value the text never binds (the environment, `read`, `printf -v`, a nameref, a positional parameter) asks the same (KL-994o). A deny hit read elsewhere in the text still refuses (`P=x; echo ${P@P} $(cat x)` with `x` denied). A name or key list (`${!prefix*}`, `${!a[@]}`) and the respelling transformations (`@Q` … `@L`) re-read nothing and read as before. The deny lanes and auto-background are unchanged (they read these expansions as a hidden run). The card carries the station's sentence for text it cannot place (`the command's text could not be read as shell, so where it reads cannot be established`); it does not name the value re-read. *Who is hurt:* every command expanding `${P@P}` or `${!N}` asks where v7.53.0 vouched it, even when the value is plain text or names nothing. Following the value precisely is #994 S4 (#2131).
|
|
251
|
+
- **A parameter body is read once (narrowing; measured on 3.2 / 5.3).** The read station read a `${…}` body's subscript and substring offset by slicing past the expansion's name, blind to a `#` or `!` before it, while the deny lanes read the body's own grammar — so `x='b[$(cat x)]'; a=(1 2); echo ${#a[x]}` (and `"${#a[x]}"`, a here-document body's `${#a[x]}`), `echo ${!a[x]}` and `v=abc; V=v; echo ${!V:x}`, which evaluate x's value as arithmetic on both bashes (its subscript's substitution runs, measured; with the array or value unset only the indirect `${!a[x]}` evaluates it), were vouched by v7.53.0's station under a deny on `x`; the station reads the subscript whether or not the array is set (it refuses or asks where bash would evaluate nothing). The lexer now holds one reader of the body (`parameterArithmeticTexts`, internal: the head — an optional `#` / `!`, then the parameter, spelled once for every reading of a body — then the subscript and the offset), and both consumers read it: the deny lanes ask whether either text names a variable (their answers unchanged), the station plans what either runs (`refuse` above). The station also reads a here-document body's and an arithmetic body's parameter expansions with it: `a='arr[$(rm -r x)0]'; cat <<EOF⏎${arr[a]}⏎EOF` plans the `rm -r x` its evaluated subscript runs (v7.53.0 vouched it). A length with an offset (`${#a[@]:x}`) is read with its offset too, though 3.2 ignores it and 5.3 refuses it as a bad substitution (over-read, the asking direction; both consumers alike).
|
|
252
|
+
- **Behavior narrowing, open to veto: a compound assignment value the lexer does not read may run something — one predicate, three readers (closes a deny-lane loosening this section had opened against v7.53.0).** The one lexer keeps a compound value `(…)` as one opaque piece and hands the segment only its command substitutions, so a value whose element hides a run — `declare -a A=(${P@P})`, `(${!N})`, `(${v:x})`, `(${x:-$(rm -r x)})`, `([x]=1)`, which bash runs (`@P` on 5.3, the subscript and offset forms on 3.2 and 5.3, measured) — read `not_reached` to the deny / ask lanes as an operand of `declare` / `local` / `typeset` / `readonly` / `export` (v7.53.0: `unreadable`), was vouched by the read station and backgrounded. `compoundValueHidesRun` (internal, in the lexer; the value itself still unread) answers for all three: a `$` (but a `$'` that only opens a standalone ANSI-C string element, which runs nothing), a backquote, or a `[…]` subscript carrying a `$` (an ANSI-C `$'\170'` among them: bash decodes it to a name and evaluates the subscript — `([$'\170']=1)` runs x's value), a backquote or a variable name ⇒ the lanes read the segment `unreadable` (v7.53.0's reading), the station asks (the plan's `unreadable` floor: "a compound assignment value is a list this lexer does not read"; a deny hit read elsewhere still refuses), and auto-background keeps the command in front. A bare `A=(…)` already read `unreadable` under every single-program rule; the compound rule body `echo ok && rm -r` now reads it so too. *Who is hurt:* a command assigning such a value asks where v7.53.0's station vouched it and stays in front where it was backgrounded, even when the value runs nothing (`files=($x)`, `args=("$@")`, `local A=(${P@Q})`, `declare -A M=([key]=v)` — an associative key is not evaluated — and a single-quoted element that only spells a `$`, `a=('$(x)')`: the text is read, not the quoting). An integer array's ANSI-C element (`declare -ai A=($'\170')`) decodes and evaluates too but is not caught here — the `-i` attribute is not in the value text; its deny lane already reads `unreadable`, and #994 S4 closes the station and the gate (KNOWN-LIMITS KL-994p). *Loosening kept, open to veto:* a compound operand with no `$`, backquote or variable-naming `[…]` (`export A=(*.txt)`, `declare -a A=([0]=a [1]=b)`, `typeset -a A=(~/x)`) reads `not_reached` where v7.53.0 read every expanding compound operand `unreadable`.
|
|
253
|
+
- **A here-document parameter's default word is read for what it runs (narrowing; v7.53.0's refusal restored).** `cat <<EOF⏎${x:-$(cat x)}⏎EOF` (and `:=`, `:+`, `-`, a backquote form) was refused by v7.53.0's station under a deny on `x` — its body reader found the substitution — and only asked by this section's reading (the parameter text is one the lexer does not read). The station now reads such a text with the same double-quoted reader it reads the body with, keeping the ask floor: the substitution's deny read refuses again. A default word nested a second time (`${x:-${y:-$(cat x)}}`) still asks where v7.53.0 refused — a loosening in the security direction, open to veto (KL-994p ⑤: the lexer ends a here-document `${…}` body at its first `}`; the ask floor stays).
|
|
254
|
+
- **Behavior narrowing: an arithmetic body is double-quoted text (the bash manual's arithmetic expansion; measured on 3.2 / 5.3).** Quotes and an apparent `#` comment are ordinary characters there — `$(( '$(cmd)' ))`, `$(( "$(cmd)" ))`, `$[ '…' ]` and `$(( 1 # $(cmd)⏎ ))` run `cmd` before the evaluator reports its syntax error. Both readers read an arithmetic body's expansions off the lexer's one reader of such text (the unquoted here-document's) instead of re-lexing it with shell quoting: the station plans the substitution (`echo $(( '$(cat .env)' ))` refused; v7.53.0 vouched every form above) and the auto-background gate keeps it in front (v7.53.0 backgrounded every form above). The same reading takes a `<<` in arithmetic text as the shift it is: `echo $(( 1 << 2 ))` (`4`, runs nothing) is vouched and backgrounded where the re-lexed body read an owed here-document and asked (the loosening direction, measured).
|
|
255
|
+
- **`<>` is a lexer fact (#1106).** The read-write redirection was a parse failure to the one lexer; it is now the redirection `<>` (descriptor 0 unless one is written): the deny/ask lanes read the segment as before (a deny reaches through it), the read station judges its target as a READ and as a WRITE (it is opened for writing and created), the descriptor reading binds descriptor 0, and a shell reading its standard input reads that file. The read-only member treats it as a write.
|
|
256
|
+
- **Not fully reached in this section: the five importers of the retired lexer read only the lexer's facts; `permission-rule-model.ts` does not yet** — its `foldSpacing` quote walk and four whitespace splits (`ruleBreadthWarningsOf`, the `cd` segment and cursor readers, the suggestion tokenizer) still scan the text themselves (design R17), with #994 S4.
|
|
257
|
+
- **Sentences.** An unreadable segment's reason is the lexer's sentence (`a double-quoted word never closes` for `an unterminated double quote`, …); a redirection's operator is the lexer's (`>|` reads `>`, `>&file` reads `&>`, a here-document reads `<<`).
|
|
258
|
+
- **Who is hurt / consumers.** No public name or shape moves. A host that renders `readShellCommand`'s segments or reasons sees the readings above; `readOnlyShellVerdict` judges a shape `readShellCommand` returned (a shape built by hand carries no lexer facts: its assignments are programs and its expanding words values). @server @cli @client-core: no action unless you pin these sentences.
|
|
259
|
+
- **Pins.** `test/backlog994-s2-one-lexer.test.ts` (the deletions, the one assignment spelling, no text scanning in the fact readers, each moved reading on the real function, the here-document arm on the station; the bash 5.3 forms — the closer, the current-shell re-entry, the deny lanes, one-pass nesting under a wall-clock bound registered in `test/flaky-guard.test.ts`; the arithmetic kind, `$[…]`'s re-read value, the subshell's assignment; the transformation's subscript; the value re-read asks, a deny hit still refuses, the one body reader, and the controls that stay vouched — one cell fixture, `test/fixtures/backlog994-s2-fix7-cells.json`, each cell red under the mutation of its own rule; the 35 declaration-operand forms × two rule bodies `unreadable`, with bare-assignment, written-substitution and run-nothing controls); `test/backlog993-tool-policy-leading-assignment.test.ts` "the grammar has ONE spelling" re-pinned on the lexer's fact; the shell pins importing the retired module re-pointed; `test/module-size-ratchet.test.ts` (two rows retired, `shell-run-candidates.ts` 427 (FIX8: one import name), `fs-bash.ts` banked 1_962 → 1_877); `scripts/weak-identity-allowlist.json` +1 row (`LEXED_WORDS`, `call_binding`); the #1102 C equivalence pin's allowed file +5 classes (the #994 S1 dual run's attribution list unchanged: every new difference falls under an existing class); `test/backlog631-shell-lexer-reach.test.ts` `echo @(a|b)` re-pinned to `unreadable`, sentences to the lexer's (`<>` keeps its `reached` cell); `test/backlog1102-read-station-semantics.test.ts` B5 re-pinned to the retired arm (+ B5b); the activity-summary F-3 cells re-pinned (`workflow-caps`, `backlog532`).
|
|
260
|
+
|
|
261
|
+
### The device lane's launch gate: a run that executes on a target device puts a person in front of every call that writes beyond the device — and, once content from beyond it was let in, every call back into the device — under the `multi_tenant` posture; the host lane decides exactly as before (#469 O9b S1; behavior narrowing, open to veto: only on a tree that executes on a target under `multi_tenant`, see below; behavior change: none on the host lane and none on a target tree that is not governed beyond a record, a trace and a report when the record cannot be written (C-R655); **hard-breaking, open to veto: a parked row of an armed tree carries the requirement word `cross_boundary`, which 7.49.0–7.53.0 workers refuse**; closed sets: `AskClass` +1 `cross_boundary` (after `requires_user_interaction`), `PersistedRuleMandate` +1 `cross_boundary_taint` (heads the ladder), `REQUIREMENT_WORDS` +1 `cross_boundary`, `TraceEvent` +1 `permission.cross_boundary_taint`, `EngineNoticeCode` +1 `config.cross_boundary_record_unwritten` (after `config.cross_boundary_posture_undeclared`; audience `operator`, minted by the engine); exports: +6 types `BoundaryLegsFor` / `BoundaryLegsGiven` / `CrossBoundaryCall` / `CrossBoundaryCheck` / `CrossBoundaryExitRefusal` / `CrossBoundaryTreeToken` (the tree's opaque identity); members: +1 optional `crossBoundaryMandated` on `PermissionResult`'s ask arm (and so on `PreToolUseResult`) — the flag's one carrier, +1 optional `CheckpointState.crossBoundaryRecorded`, +1 optional `ToolSpec.boundaryLegsFor`, +1 optional `ToolGateInput.crossBoundaryCheck` (engine-built), +1 optional `ToolExecuteContext.crossBoundaryTreeForChildren` (a `CrossBoundaryTreeToken`), +1 optional `RunInternals.crossBoundaryTree` (the same token), +1 optional `crossBoundaryTree` on `RegisterBackgroundAgentInput` and `BackgroundAgentTaskHandle` (the spawning tree's token, which a handle records) and on `RunWorkflowOptions` and `RunWorkflowToolDeps` (the same token, riding the workflow lane), +1 method `TaskRegistry.boundaryLegsOf` (the legs of a `TaskOutput` / `TaskStop` call, read off the handle the id dispatches to), +1 `uncoveredContent` on `CrossBoundaryRecordRead`'s `absent` / `recorded` arms) (@server @client-core @cli @test)
|
|
262
|
+
|
|
263
|
+
- **Before.** v7.53.0 and the vocabulary section above: every call was decided as if no device existed, and a policy's or hook's ask had no way to say it stands on a cross-boundary obligation (an unknown `crossBoundaryMandated` member was dropped).
|
|
264
|
+
- **Now.** One rule, read at three moments, under the tree's state at that moment (`crossBoundaryObligation` — the legs the call travels, whether the environment its `env` leg reaches declares itself the target now, the tree's three bits): the gate's cross-boundary station (after the write-protection tighten, before the unstated station) turns a surviving allow into a real-approval ask carrying `crossBoundaryMandated` (class `cross_boundary` — bypass-immune, no classifier, no sandbox admission, no persisted rule) and adds those members to a surviving ask; the live gate's allow exit and a resumed call's exit read the obligation again on the bytes that run — a person's yes stands, any other allow of a call that now needs a person (a policy's, the blanket seat's, a mode's release) is refused (`blanket_allow_refused`), and an off-target read sets the tree's bit and is written to the session before the call runs. The tree's state is one object every leg of the tree shares, named by an opaque engine-minted token (`CrossBoundaryTreeToken`: the delegation and workflow lanes hand the token down, a background agent's handle records it, so polling one's own child moves nothing; no tool reads or writes the state through it); each leg's session holds a record of it (`CROSS_BOUNDARY_RECORD_TYPE`, written only for an armed tree — at prepare and at an exit when the state grew, and at a leg's end, before a suspend's row is put and when the leg settles, whenever content follows the newest record), so a new turn, a durable resume and a tier-3 revival continue from it. A record covers the content before it: an armed leg that takes over a session whose content no record covers — none on the branch, or content after the newest one (a leg that ended without writing its record) — reads it as off-target. `TaskOutput` / `TaskStop` (the registry pair) give their legs from the handle (a background shell or monitor reaches its own environment; this tree's agent moves nothing; any other handle is content from beyond the device on a poll) and `Artifact` from its action (a publish reads its source through the device and, on a hosted plane, writes beyond it; the read family reads beyond it on a hosted plane). A policy's or hook's own `crossBoundaryMandated` now means what it says: classed `cross_boundary` and carrying the real-approval bit wherever it is read (the decision receipt adds the bit beside the member, so a composed, delegated or parked ask keeps it; the hook fold keeps the member). The member is legal on the ask arm only: the receipt refuses it on an allow, a deny or a hook result with no `action`, as it now refuses every ask-only member there (below: "Behavior narrowing — every member on the arm that declares it").
|
|
265
|
+
- **The host lane is byte-for-byte unchanged (I-2).** No posture and no target environment: no station is engaged, no record, trace or notice, the same decisions (`P17`; `.gate-logs/car-469-B2/cx/cx1` compares a v7.54.0-stage tree and this one over the same scripted runs). On a target tree that is not governed (it never ran under `multi_tenant` — no record of it says `governed`) a leg records and traces but never asks: every origin cell of every ask equals an undeclared run's (`P2`); a record that cannot be written mints the engine notice `config.cross_boundary_record_unwritten` and the call runs (ruled C-R655: no judgment reads the record of a tree that is not governed). A tree is governed by its state, not by the leg's posture: a session that once ran under `multi_tenant` stays governed under a later `single_user` leg.
|
|
266
|
+
- **Behavior narrowing (open to veto) — only on a tree that executes on a target under `multi_tenant`.** *What changed:* a write beyond the device, and after an off-target read any call into the device, needs a person; a configuration's allow of such a call is refused; on this governed tree, one disposition for a session record that cannot be written (the store throws, or answers with no entry id — ruled C-R655; a non-empty id the store did not actually write cannot be told apart by the engine, and the next leg's reader still finds content after the newest real record and reads it as off-target): a call that owes it before it runs is refused (`deniedBy: "policy"`, fail-closed), a suspend does not put its row (the fallback then answers the question and the record's sentence rides its refusal as the park note, the one note every failed park carries; where nobody judged the call — no approver, or one that reported nobody reachable — the note says the failed park is why it stopped and that it can be issued again once its park can be minted; where someone judged it, that answer is the reason and the note only reports the park), and a prepare or a settle, which have nothing to refuse, mint `config.cross_boundary_record_unwritten` — the next leg on the session then reads the content after the newest record as off-target. A record write is judged again after its store waits: when another leg grew the tree meanwhile, it writes again until the newest record covers the tree as it stands; a resumed call runs only on a pass — the approval-object station, then the exit — whose exit waited on nothing (each pass after a store wait reads its target again). The passes end: an exit waits only on a write the tree's growth owes — a write that failed on a tree that is not governed is not owed again for the same state (the tree's growth, or the leg's end, writes next) — and the tree has three bits. *Who is hurt:* a `multi_tenant` device deployment that ran unattended (blanket `onAsk: "allow"`, a policy-settled approver, `bypassPermissions`) — those calls now stop for a person; a deployment whose session store fails writes; a resumed call whose later pass's station vetoes it keeps the bit an earlier pass's exit set (the tree only grows), so the next leg's device calls ask a person once more. **Open to veto, a residue of KNOWN-LIMITS KL-469-c:** a background child that reads beyond the device while its parent's settle is writing its record, when the first append succeeds and the catch-up append fails, leaves that first (earlier-state) record as the session's newest — it tells the truth about the content already there, but content that arrives later from the child (its completion notice in the next turn) does not make the next leg read the strictest state. *How to migrate:* route these asks to a person (they carry class `cross_boundary` and `requiresRealApproval`); declare an engine-internal MCP / A2A tool `boundaryFlow: "engine"` with `egress: false`.
|
|
267
|
+
- **The failed-park note (every lane; wording only).** A refusal that follows a durable park that could not be minted (a checkpoint the store refused, or — on a governed device tree — a session record that could not be written) carries one note, worded by the deny exit's one reading of whether anyone judged the call (the reading its headless remedy and `delegation.ask_unresolvable` already use: no approver, or one that reported nobody reachable): nobody judged ⇒ the failed park is why the call stopped and it can be issued again once its park can be minted; someone judged ⇒ the note only reports that the park was tried, and the judgment stands as the reason. v7.53.0's note always said the refusal above was "not the reason the call stopped" — untrue where a person had refused.
|
|
268
|
+
- **Behavior narrowing — every member on the arm that declares it (open to veto; every lane, not only the device lane).** *What changed:* the decision receipt reads its member list off one member × arm table (fenced to the `PermissionResult` union both ways) and holds every present member to its row: the 21 members the union declares on the ask arm only (`requiresRealApproval`, `mandated`, `mandate`, `probeMandated`, `crossBoundaryMandated`, `probeCause`, `origin`, `classes`, … `foldedAskClasses`) are refused on an allow, a deny or a hook result with no `action` — a deny, `decisionReason: "rule"` (the gate's `deniedBy: "policy"`), the same family as a word outside a closed set; `action` / `updatedInput` / `message` / `decisionReason` / `attestation` stay legal on every arm. v7.53.0 carried the member on the other arm or dropped it (`{ action: "allow", crossBoundaryMandated: true }` ran). *Who is hurt:* a policy, hook or host wrapper that spreads an ask's members into an allow or a deny, or answers an allow with an ask-only member (`requiresRealApproval: false` included) — that call is now denied, fail-closed. *How to migrate:* answer an allow or a deny with the five members every arm holds.
|
|
269
|
+
- **Hard-breaking (type level) — the decision face.** *What changed:* `AskClass` gains `cross_boundary` (row: bypass-immune, never the classifier's, no sandbox admission, no rule clears it, no hook floor; it explains the real-approval bit) and `PersistedRuleMandate` gains `cross_boundary_taint` (its disclosure: the deployment's approver confirms; a configuration's allow is refused); the ask arm gains `crossBoundaryMandated` (`true` only; any other value is refused at the decision receipt, the `probeMandated` rule). *Who is hurt:* a downstream table exhaustive over `AskClass` or `PersistedRuleMandate` (it stops compiling, or misses the word at run time); a policy or hook that already returned a member of that name (v7.53.0 dropped it; it now classes the ask, refused under `bypassPermissions` when nobody can be asked); **behavior change, the loosening direction, open to veto:** a policy's own `askClass` / `classes` naming `cross_boundary` were unknown words v7.53.0 refused at the receipt, and are now replaced by the stamp's reading as every class word is (under `bypassPermissions` that ask is released) — a policy puts a call to a person on its cross-boundary obligation with `crossBoundaryMandated`, not with the display members (pinned in `design469-r3-boundary-legs.test.ts`, "the decision face with the launch gate"). *How to migrate:* add one row per word; drop the member from a policy that did not mean it.
|
|
270
|
+
- **Hard-breaking (type level) — the notice catalog.** *What changed:* `EngineNoticeCode` gains `config.cross_boundary_record_unwritten` (audience `operator`, minted by the engine through `crossBoundaryRecordUnwrittenNotice`; per failed write; `detail: { sessionId, runId, phase, governed, cause }`, `phase` one of `prepare` / `exit` / `suspend` / `settle`) — the one report of a session record that could not be written and refused nothing (ruled C-R655; it replaces the `onError` line an earlier stage tree printed). *Who is hurt:* a downstream table exhaustive over `EngineNoticeCode` (a notice projector, an audience or minter map) stops compiling or misses the code; a host that watched `onError` for this report sees it on `onNotice` instead. *How to migrate:* add one row (operator audience); project it where `config.cross_boundary_posture_undeclared` is projected (@server @client-core).
|
|
271
|
+
- **Hard-breaking (open to veto) — the requirement word.** *What changed:* a row parked by an armed tree carries `CheckpointState.crossBoundaryRecorded` and lists `cross_boundary` in its requirements. *Who is hurt:* during a rolling release, a 7.49.0–7.53.0 worker refuses such a row before the claim (`checkpoint.unsupported_version`, `version_newer`, `requires: ["cross_boundary"]`; the row stays pending); a ≤ 7.48.0 worker refuses every v10 row already; **a new turn on a device session has no version gate at all** — an older worker reads its content without the record. *How to migrate:* redeem marked rows on ≥ 7.54.0 workers; do not open a `multi_tenant` device lane before every worker of the fleet is ≥ 7.54.0; declare the posture on every worker.
|
|
272
|
+
- **Consumers.** *server:* feed `RunnerDeps.crossBoundaryPosture`, derived from `REQUIRE_PRINCIPAL`, explicitly and identically on every worker; project `permission.cross_boundary_taint` onto `GET /v1/runs/:id`; keep stripping a request-side `boundaryFlow` at the injection gate; three DEPLOY-PREREQS lines (marked rows redeem on ≥ 7.54.0 only; a new turn has no version gate, so the whole fleet is ≥ 7.54.0 before a `multi_tenant` device lane opens; one posture fleet-wide). *client-core:* the `cross_boundary` class / mandate word, the posture notice code and the trace kind are now live (the roster column landed in the section above). *cli:* no code dependency (the card's words come through client-core). *test:* the twelve criteria cells of design/380c §5.1 (G1–G12, G6′, G2′).
|
|
273
|
+
- **Pins.** `test/design469-r3-launch-gate.test.ts` (P1–P18 on the real lane: I-1 / I-2 / I-3, the Artifact actions, the registry handles, one batch in order, a mixed tree, the resumed exit on a moved declaration and on a tree changed since the park, U1, the new turn, the tier-3 revival, the live race, the Workflow line, G12, the host lane, the record that cannot be written; the policy's own member on the real gate under `bypassPermissions`, `auto`, the blanket seat and a persisted allow row); `test/design469-r3-boundary-legs.test.ts` "the decision face with the launch gate" (the member on an allow, denied by policy and never run; the receipt's arm screen per arm); per-fixture mutations in `.gate-logs/car-469-B2/mutations.md`.
|
|
274
|
+
|
|
275
|
+
no-notes: #794(③ 按新律重钉:allow 层之外的表态不再免 unstated 问;无新行为面), #1065(⑬c / ⑭b / ⑬d / ③ 重钉;改形 A 本节正文已述), #1314(⑪ 重钉:继承改参归一化后仍一卡), #595(a pin re-worded and restored within this version: the approval policy's changed-while-pending sentence is unchanged), #548(FIX8 只是修回 FIX7 把无审批人臂结算词改掉造成的 DL-3 停跑回归,#548 自身机制未变;见 #2111 小节「无审批人臂」句), #2122(#994 FIX2 顺手关:参数体 / 下标里的算术赋值在 word 路也按 lexer 事实 kill,机制句在 #994 小节;无独立行为面), #2096(verify-backlog-ledger 反搁置门随 main 并入;脚本门,零包面行为)
|
|
276
|
+
|
|
1
277
|
## 7.53.0 — 2026-10-17
|
|
2
278
|
|
|
3
279
|
### Docs and pins: the task-stream thinking arm, the classifier attribution sentence, the TaskEvent roster, one re-anchored pin (#1042, #1961, #2029, #1432)
|
|
4
280
|
|
|
5
|
-
- **#1042 (docs).** The task stream's thinking arm is `reasoning_delta` with no start / end frame — a sidecar that listens for the provider-layer name `thinking_delta` counts nothing (`docs/sdk/05-streaming-and-http-server.md`, one pointer sentence in `README.md`). INTEGRATION-CORE states the two host-side facts beside it: a host that renders a local date passes `EnvironmentFacts.timeZone
|
|
281
|
+
- **#1042 (docs).** The task stream's thinking arm is `reasoning_delta` with no start / end frame — a sidecar that listens for the provider-layer name `thinking_delta` counts nothing (`docs/sdk/05-streaming-and-http-server.md`, one pointer sentence in `README.md`). INTEGRATION-CORE states the two host-side facts beside it: a host that renders a local date passes `TaskSpec.clientContext.timeZone` (erratum #2072: the first edition said `EnvironmentFacts.timeZone`, which is the facts carrier the engine fills from this field — a host driving a Runner passes the zone on `TaskSpec.clientContext`); a cache-stable deployment keeps one tool block across modes and refuses at call time rather than varying the block (`KL-1042-a` records the `</think>` leading newline as the template's own).
|
|
6
282
|
- **#1961 (docs, JSDoc only).** `GateDisposition.classifier` is the classification round's model attribution — present whenever the classifier ran, including the ask it answered that a person then settled; who settled is read from the settlement record (`human_allowed` / `human_refused` / …), never from the presence of `classifier` (`src/core/gate-outcome.ts` comment lines; one sentence in INTEGRATION-CORE §gate).
|
|
7
283
|
- **#2029 (docs).** INTEGRATION-CORE §1.0 no longer keeps its own copy of the TaskEvent roster; it references the frozen table in `test/defectscan-task-event-surface.test.ts`, so one list exists.
|
|
8
284
|
- **#1432 (pin re-anchored, no behavior change).** `test/backlog1432-deny-universe.test.ts` ② asserted that an undeclared extraTools product's names appear in the model-facing refusal; since #2051 (this release) the model gets one fixed sentence and the names live in `details.cause`. The pin now asserts exactly that (names absent from the text, present in `details.cause`).
|