@sema-agent/core 7.36.0 → 7.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/brain/anthropic.js +7 -14
  3. package/dist/brain/open-responses.js +6 -13
  4. package/dist/brain/openai.js +7 -14
  5. package/dist/brain/stream-shared.d.ts +21 -0
  6. package/dist/brain/stream-shared.js +13 -0
  7. package/dist/core/ask-origin.d.ts +35 -5
  8. package/dist/core/ask-origin.js +23 -4
  9. package/dist/core/checkpoint-store.d.ts +13 -3
  10. package/dist/core/decision-receipt.d.ts +21 -0
  11. package/dist/core/decision-receipt.js +63 -0
  12. package/dist/core/engine-notice.d.ts +17 -5
  13. package/dist/core/gate-fold.js +2 -1
  14. package/dist/core/gate-lanes.js +8 -13
  15. package/dist/core/gate-outcome.d.ts +7 -5
  16. package/dist/core/gate-outcome.js +5 -1
  17. package/dist/core/governance-codes.d.ts +1 -1
  18. package/dist/core/governance-codes.js +2 -0
  19. package/dist/core/hooks.d.ts +11 -2
  20. package/dist/core/hooks.js +3 -1
  21. package/dist/core/park-selfcheck.js +2 -0
  22. package/dist/core/runner/denial-limit-arms.d.ts +28 -18
  23. package/dist/core/runner/denial-limit-arms.js +11 -7
  24. package/dist/core/runner/inherited-ask-grants.d.ts +32 -31
  25. package/dist/core/runner/inherited-ask-grants.js +5 -0
  26. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  27. package/dist/core/runner/prepare-park-ask.d.ts +2 -2
  28. package/dist/core/runner/prepare-park-ask.js +1 -5
  29. package/dist/core/runner/prepare-policy-chain.d.ts +3 -8
  30. package/dist/core/runner/prepare-policy-chain.js +43 -25
  31. package/dist/core/runner/run-identity-wiring.d.ts +1 -1
  32. package/dist/core/runner/run-identity-wiring.js +2 -0
  33. package/dist/core/runner/tool-turn-repair-seat.d.ts +8 -0
  34. package/dist/core/runner/tool-turn-repair-seat.js +28 -0
  35. package/dist/core/session-reconcile.d.ts +35 -0
  36. package/dist/core/session-reconcile.js +31 -20
  37. package/dist/core/side-query.js +4 -2
  38. package/dist/core/store-contracts/checkpoint-store-contract.js +18 -0
  39. package/dist/core/tool-name-readings.d.ts +4 -0
  40. package/dist/core/tool-name-readings.js +1 -0
  41. package/dist/core/tool-policy.d.ts +74 -84
  42. package/dist/core/tool-policy.js +71 -36
  43. package/dist/engine/harness/agent-harness.d.ts +7 -0
  44. package/dist/engine/harness/agent-harness.js +14 -2
  45. package/dist/engine/llm/index.d.ts +1 -0
  46. package/dist/engine/llm/index.js +1 -0
  47. package/dist/engine/llm/tool-turns.d.ts +20 -0
  48. package/dist/engine/llm/tool-turns.js +91 -0
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/internal/llm.d.ts +2 -2
  52. package/dist/internal/llm.js +1 -1
  53. package/package.json +1 -1
  54. package/test/export-surface.snapshot.json +7 -1
  55. package/wire-changes/core@7.37.0.json +405 -0
  56. package/wire-changes/core@7.37.1.json +122 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,119 @@
1
+ ## 7.37.1 — 2026-10-01
2
+
3
+ **Hard-breaking changes:** none.
4
+
5
+ **Closed sets.** `ENGINE_NOTICE_CODES` / `EngineNoticeCode` (#1224): +`context.tool_turn_repaired` (audience `operator` in `NOTICE_AUDIENCE`); its `detail.repairs[].form` words are `trailing_text` / `late_result` / `missing_result` / `duplicate_result` (not a package export).
6
+
7
+ **Package exports:** +0 / −0.
8
+
9
+ ### A session with a broken tool turn heals on its next request: every provider request holds the tool-turn law, once for the three lanes (#1224; behavior (the request view) + closed set +1 notice code; +0 exports) (@server @cli @client-core @test)
10
+
11
+ - **Before (v7.37.0).** Each brain replayed an assistant's blocks in stored order and merged only ADJACENT tool results into the message after its calls. A stored turn whose calls are followed by another block (the drop note of #1225: [thinking, text, toolCall×2, text]), a result recorded behind a later user or wake message, a call with no result, or two results for one call went to the provider as stored. The Anthropic Messages lane answered 400 `tool_use ids were found without tool_result blocks immediately after`, naming the same id on every later request, so the session could not continue; the two OpenAI-shaped lanes rejected the late / missing / doubled shapes the same way. The acquire reconcile writes the same late shape itself: it closes an orphan call at the transcript's TAIL, behind any later user message.
12
+ - **Now.** The harness's provider-boundary conversion applies one law (`src/engine/llm/tool-turns.ts`) to every request, whichever brain reads it: for every assistant message with calls, (i) the calls are a contiguous tail — every block that is not a call and stands after the first call (between two calls or after the last) moves ahead of the calls, order kept; blocks ahead of the first call, thinking first, keep their place; (ii) the messages right after it are exactly one result per call — a result recorded later moves into the batch (and is not sent again where it was), a second recorded result for the same call is folded into the first (no recorded result is withheld — a withheld result would also make the request lossy for a compaction fork), an interrupted close (the reconcile family's `details.errorKind`) stands in only for a call with no other result (beside a recorded result it is left out), a call with none is closed with the reconcile family's interrupted result (`interruptedResults`, the reconcile family's text and `errorKind` (without its tool-effect refinement — see below); one mint for both). A result answers the NEAREST EARLIER message that emitted its id, — the persisted reconcile's own positional reading, so the two agree on which occurrence a result answers: an earlier use of a reused id with no result before the next use is closed as interrupted, never handed a later use's success, and every result recorded after the last use is that use's. The public `runSideQuery` holds the same law on its caller-supplied history (a host can hand it a stored transcript; an entry's `details` is kept so an interrupted close is recognized; no notice — a side query has no run). The transcript is never rewritten; a legal request list is handed on as the same array. One notice per run per (form, calls): `context.tool_turn_repaired`, `detail: { repairs: [{ form, toolCallIds }], sessionId, runId }`. A result's tool name is NOT an attribution key: the owner is the nearest earlier site that emitted the id, the reconcile family's positional reading and nothing else (the review's two rounds found every name-based reading — the first tree's, and a bounded one — reaching back past a turn the model had already answered and moving its outcome, closing the later call as interrupted). An id reused by another tool before its result is recorded therefore reads positionally, as the reconcile reads it. The repair notice keys a call SET (ids sorted), so one batch read in two orders announces once. The synthesized close is the reconcile family's function with no tool-effect refinement (the view has no effect table), so a read-class tool's close carries the general wording where the persisted reconcile writes the read-class one; and the notice's sentence names a shape "a provider lane rejects" — the OpenAI lanes serialize an assistant's text and calls separately, so a trailing block is announced there but never reached their wire.
13
+ - **Across lanes.** A turn stored by one lane and replayed on another is repaired at the same place: an OpenAI-lane turn [unsigned thinking, text, toolCall×2, note] replayed on the Anthropic lane (the unsigned thinking is not replayed there) goes out as [text, note, tool_use×2].
14
+ - **Who is hurt.** A consumer with a closed table of notice codes (+1 row). A host `Brain` now receives the repaired order for a broken turn, not the stored one — on the run path and in `runSideQuery`.
15
+ - **Known limits.** Two calls sharing an id AND a tool name, the first one's result recorded after the second: it is read as the second's (folded with the second's own, when both were recorded), and the first is closed as interrupted (outcome unknown) — the view cannot tell the two uses apart without an occurrence identity, and it never states a success for the earlier one. Two calls with the SAME id inside one response (admission accepts it; the engine keys a call by its id throughout) go out as two calls with one folded result. The per-message aggregate tool-result budget is measured on the stored batches before the view is built: a batch completed by a moved or folded result can exceed it (the request's total size is unchanged, and the context guard measured it). The acquire reconcile's orphan rule is positional, not name-aware: with an id reused by a later turn it can persist an interrupted close for a call that has a result (the view voids that close; the persisted row is unchanged).
16
+ - **Compensation.** Add the notice row (`operator`). Nothing else moves: a transcript that already has the structure produces the same request as before.
17
+ - Pins: `test/backlog1224-tool-turn-invariants.test.ts` — the user's stored turn through a real Runner on each lane (two requests, both legal, one notice, the transcript unchanged); the sandwich [text, call, text, call] and the lane switch on the Anthropic lane; a late result (6 calls · 5 results · user · 1 result); the reconcile's tail close; a doubled result (folded, both texts sent); a late result crossing a later turn that reuses its id for another tool; a missing result (harness boundary over each real brain); a legal transcript (same array, no notice); the durable-park path (one call of a six-call batch parks, restart, the person types, deny on resume: every request legal, no repair — v7.36.0 the same); an id reused with the same tool, the result recorded after the second use (the first use closed as interrupted, never handed that success); `runSideQuery` over a stored broken turn on each lane; a result whose tool-supplied `details` throws on read (a recorded result, not a close; the view never throws); the law's attribution and mint cells; the seat's per-run dedup. Baseline (the boundary law taken out): 15 of the first 26 red (the sandwich and lane-switch cells, added later, are red under D1019). Mutations D1019 / D1021: `.gate-logs/criteria-7.37.1-mutations.md`.
18
+
19
+ ### A turn's drop notes ride ahead of its tool calls (#1225; behavior (block order); +0 exports) (@server @cli @test)
20
+
21
+ - **Before (v7.37.0).** When a turn dropped a call (arguments cut mid-JSON, no tool name) or unparseable stream frames, the three brains pushed the `[note: …]` text block at the END of the final content, after the surviving tool calls — the persisted assistant read [thinking, text, toolCall…, note], the shape #1224 describes.
22
+ - **Now.** The notes are placed before the first tool call, in order (`placeTurnNotes`, one helper for the three finalizers); a turn with no call keeps them at the end. The note text is unchanged.
23
+ - **Who is hurt.** A reader of an assistant message's block order on a partial-batch turn (a renderer that showed the note after the calls now shows it before them).
24
+ - **Compensation.** None needed — the text is unchanged, and #1224 repairs turns stored before this release.
25
+ - **The silent cause.** One unparseable frame on an otherwise clean tool turn (every call complete, a normal stop) is a `toolUse` turn with no `errorMessage`: the note was its only trace, and it alone produced the rejected order.
26
+ - Pins: `test/backlog1224-note-placement.test.ts` — three lanes × three causes, an Anthropic request built from such a turn (the brain called directly) ends its assistant on `tool_use`, and the silent dropped-frame turn on each lane. v7.36.0 brains: 15 of 15 red. Mutation D1020.
27
+
28
+ ### A dropped call's message names the output cap only when the cap cut it (#1227; behavior (message text); +0 exports) (@server @cli @test)
29
+
30
+ - **Before (v7.37.0).** The anthropic and openai lanes appended `; request output cap in effect: N tokens — a cut mid-arguments commonly means the cap was hit` to the `not valid JSON` message whenever a cap was on the wire — also when the stream ended without a stop frame after a few dozen tokens (the field reading: a torn stream, reported as a 64000-token cap), and on a normal `tool_use` / `tool_calls` stop.
31
+ - **Now.** One rule (`truncationCauseClause`, `src/brain/stream-shared.ts`): the cap is named on the lane's cap stop (`max_tokens` / `length`) or when the counted output reached the cap; a stream with no stop frame reads `; the stream ended before its stop frame — the connection closed mid-response`; any other stop adds nothing. The open-responses lane never carried the clause.
32
+ - **Who is hurt.** A reader matching the cap sentence on a message whose stream the cap did not cut.
33
+ - **Compensation.** None needed — the cap-stop message is byte-identical.
34
+ - Pins: `test/backlog1224-note-placement.test.ts` (7 cells; v7.36.0: the 4 normal-stop and torn cells red); `test/brain-unnamed-tool-use-disclosure.test.ts` — the cap-armed cell re-pinned on `finish_reason: "length"` (it asserted the clause on a `tool_calls` stop, the shape retired here). Mutation D1022.
35
+
36
+ ## 7.37.0 — 2026-10-01
37
+
38
+ **Closed sets, one by one.**
39
+ - `MODE_RELEASE_REASONS` / `ModeReleaseReason` (#1212): a NEW exported closed set, one word (`rule_unreadable`) — the words `tool_end.gate.disposition.released.reason` carries. The type already admitted only that word; it is now a runtime list with a predicate (`isModeReleaseReason`).
40
+ - #1214: no closed set moves — the pair's words are `AskClass` (`ASK_CLASSES`), already a package export.
41
+ - #1221: no exported closed set moves — a decision's `action` was already typed allow / deny / ask (`PermissionResult["action"]`); the out-of-contract screen now holds a decision to it at run time (not exported).
42
+
43
+ **Package exports** (`npm run handoff:diff v7.36.0 HEAD`): +3 / −0 — `MODE_RELEASE_REASONS`, `isModeReleaseReason`, `type ModeReleaseReason` (#1212). #1214 adds no export: `AskClass`, `ASK_CLASSES` and `RELEASABLE_ASK_CLASSES` are already package exports.
44
+
45
+ **The wire list's BREAKING items** (`node scripts/wire-changes.mjs v7.36.0 HEAD --version 7.37.0`): none — 0 BREAKING items. The non-breaking moves it carries: +3 exports; two new closed sets (`MODE_RELEASE_REASONS` and its type `ModeReleaseReason`, `rule_unreadable` each) (#1212); two optional members, `askClass` and `classes`, on four declarations — `AskRequest`, `PendingAction.tool_approval` (the park row), the `PermissionResult` ask arm and `AskCarry` — read as five type rows (`PreToolUseResult` is `PermissionResult` plus a member, so its ask arm gains them too) (#1214). Value moves the list cannot read, from the criteria: F1 (`screenGateOutcome` I7 screens `released.reason`); F2 / F3 (#1217: the inherited lane reads an ancestor's decision once, and screens its ask at receipt); F4 (#1218: an inherited grant covers the classes it answered — the child's gate asks its own protection question; a marked tool's first delegated card is `operator_forced`); F5 (#1221: a decision whose `action` is outside allow / deny / ask is refused, where it ranked as an allow); F6 (#1226: the contract screen is the one receipt of a decision on both lanes — read once into the engine's copy; a host's direct `runToolGate` adjudication and the approved-edit re-check are screened, where they were not).
46
+
47
+ ### A mode release's `reason` is an exported closed set, minted from one list (#1212; behavior (tighten: the record screen); +3 exports, +0 wire keys, +0 codes) (@server @client-core @test)
48
+
49
+ - **Before (v7.36.0).** `tool_end.gate.disposition.released.reason` was typed `"rule_unreadable"` and minted as that literal in `arbitrateByMode`. There was no runtime list or predicate, so a consumer screening the word keyed its own table on the type (`ModeRelease["reason"]`), and `screenGateOutcome` I7 screened a release's `mode`, `askClass`, `classes` and `origin` but not its `reason`.
50
+ - **Now.** `MODE_RELEASE_REASONS` (`["rule_unreadable"]`; every member an ask class), `type ModeReleaseReason` and `isModeReleaseReason(v)`, exported beside `RELEASABLE_ASK_CLASSES`. The release arm of `arbitrateByMode` carries the first member of the list its class set holds (none ⇒ no `reason`), so a new reason is one word in the list. The mint and the predicate read one copy of the list taken at load, so a caller writing into the exported array changes neither; `ModeRelease.reason` and the release arm of `Arbitration` are typed by it. `screenGateOutcome` I7 refuses a `released.reason` outside the set on the same exit as an out-of-set `released.origin`: `I7: released.reason "<word>" is outside the closed set`.
51
+ - **Who is hurt.** A caller that hands `screenGateOutcome` a record whose `released.reason` is outside the set now gets one defect. Only a hand-built or cast record can carry one: the engine mints only `rule_unreadable`, and the type admitted nothing else. Inside the engine the gate's mint throws on a defect and the frame screen withholds the outcome, as for every I7 defect. Compensation: mint the word from `MODE_RELEASE_REASONS`, or check it with `isModeReleaseReason`. A consumer whose table is keyed on `ModeRelease["reason"]` needs no change; it may read the list instead.
52
+ - Not registered in `docs/CLOSED-SETS.md`: that registry's scan (`gate:closed-sets`) refuses a set of fewer than two members. This set has no disposition table and no `switch` reads it; `test/closed-sets-literal-members.test.ts` still sweeps it (string-literal members only).
53
+ - Pins: `test/backlog1212-mode-release-reasons.test.ts`:
54
+ - R1: the set equals the reasons `arbitrateByMode` produces over every set of one or two ask classes under `bypassPermissions`, and no other mode releases the class. Writing into the exported array moves neither the mint nor the predicate.
55
+ - R2: every reason is a releasable class.
56
+ - R3: the predicate.
57
+ - R4: I7, three arms. A registered or absent reason screens clean; an outside word is one defect, spelled as the origin arm's; a registered reason the release's `classes` does not hold is one defect (`I7: released.reason "…" is not a member of released.classes`) — the mint takes the word from that set.
58
+ - R5: a real Runner under `bypassPermissions`, over a person's rule row the call cannot be read against. The frame's `released.reason` is `rule_unreadable` and a member.
59
+ - Mutations (`.gate-logs/criteria-7.37.0-mutations.md`, per fixture): D1000 the mint drops the reason (R1 ×2, R5 red); D1001 the mint spells a word outside the list (R1 red); D1002 the list gains an immune class (R1, R2, R3 red); D1003 the predicate answers true (R3, R4 red); D1004 I7 skips `reason` (R4 red); D1006 the mint reads the exported array live (R1, the export-write cell, red); D1018 I7 does not hold `reason` against `classes` (R4's membership cell red; Part E).
60
+
61
+ ### The pre-7.34.0 mode-seed migration has a stated window: kept through 7.37.x, removed in 7.38.0 (#1213; docs and the notice's message text; +0 exports, +0 codes, +0 wire keys) (@server @test)
62
+
63
+ - **Before (v7.36.0).** The 7.34.0 entry says rows written by 7.33.x are "read ONCE by the resume leg (`migrateLegacyModeSeed`)" and names no removal. The `resume.legacy_mode_seed_migrated` notice's message said "from 7.35.0 a row still carrying these keys is refused", but 7.35.x and 7.36.0 kept migrating such rows.
64
+ - **Now.** One sentence, in the migration's JSDoc, the checkpoint row member's doc, the notice catalog, `docs/INTEGRATION-CORE.md` and the notice's message: the migration is kept through 7.37.x and removed in 7.38.0, which refuses a row still carrying these keys (`autoModeRequested` / `approverPosture` / a doctrine `shellGate`). Resume or abandon a row parked before 7.34.0 before upgrading to 7.38.0. The window closes one release cycle after the server picks up 7.36.0 (planned: server 7.107.0). "Read ONCE" in the 7.34.0 entry means once per row: the next suspend writes the one key. The notice's code and `detail` are unchanged, and so is the migration.
65
+ - **Who is hurt.** Nobody in this release. At 7.38.0: a deployment still holding a row parked by 7.33.x or earlier that has not been resumed since.
66
+ - Pin: `test/backlog525-auto-intent-persistence.test.ts`. The migrated notice's message states the window and names no 7.35.0 (mutation D1005, the old message: red).
67
+
68
+ ### The ask's class rides the request and the park row (#1214; additive: +0 exports, +0 codes, two optional keys on four types) (@server @cli @client-core @test)
69
+
70
+ - **Before (v7.36.0).** An `onAsk` approver received `origin` (which authority raised the ask) but not the ask's class — the `MODE_ARBITRATION` row a permission mode arbitrates it by. A host with nobody at the approval seat that wanted to answer ordinary questions and refuse the protection ones had to infer the class from other members, and `transcript_write`, `rule_unreadable` and the two `tool_marks_*` classes have no request member to infer them from.
71
+ - **Now.** `AskRequest.askClass?: AskClass` and `AskRequest.classes?: readonly AskClass[]`: `classes` is every class the ask belongs to, in `ASK_CLASSES` order, and `askClass` its first member — the classification the gate arbitrated the ask by (the same words `tool_end.gate.disposition` carries when a mode releases or refuses an ask). Every station that fills `origin` fills the pair: the gate's own (both rounds — the first ask and an approved edit's new ask) and the three inherited-lane stations (an ancestor's ask at its frozen approver). The park row carries the same pair (`PendingAction.tool_approval.askClass` / `classes`), written only as a well-formed pair. The `PermissionResult` ask arm (and so a hook's `PreToolUseResult`) and `AskCarry` carry it between the gate's stamp and the request / row: the gate stamps it beside `origin` and replaces a value a policy or hook put on its own ask; an inherited station derives it and never reads a value off the ancestor's decision. The park self-check's representative row carries the pair. `docs/INTEGRATION-CORE.md` §4.6e: an unattended leg's `onAsk` that answers by class — the recommended one answers `ordinary` alone and refuses every other class (the seven protection questions, `mode` — plan's write guard — and every class no mode releases) and a request with no class.
72
+ - **Absent means unclassified.** A request from a station that fills no `origin` — the shared-artifact read confirmation (KL-1214-a) — or one a deployment builds itself; a park row written before 7.37.0 (a resume does not fill it in — KL-1214-b). `CheckpointSummary` does not echo the pair; read the row.
73
+ - **Who is hurt.** Nobody who reads: every key is optional. A store that re-projects the park row field by field drops the pair until it carries the two keys: `probeParkRoundTrip` reports that store `row_not_faithful` (its representative row carries them), and the store-contract kit (`checkpointStoreContract`) fails its new entry `#1214 ask class pair` — the pair must survive `put` → `get` verbatim (`classes` whole, in its order) and an absent pair must stay absent. A test that deep-equals a whole `AskRequest` or park row sees two more keys.
74
+ - **Compensation.** A store that re-projects rows field by field carries `askClass` and `classes` (all four types: optional). A host that answers by class reads `classes` whole and treats absence as a class it does not answer. A host that moves `mode` into its answered set makes a `plan` leg behave like `acceptEdits`.
75
+ - Pins: `test/backlog1214-ask-class-on-request.test.ts` — F1-gate / F1-gate-edit / F1-inherited-shared / F1-inherited-distinct / F1-inherited-edit / F1-inherited-branches (every station that fills `origin`, and the judging station's three branches); F1-census (the resolver's request mints file by file, and the park row's one mint); F1-arbitration (the request's pair equals the pair a `dontAsk` refusal names for the same protected write); F2-row / F2-screen / F2-legacy / F2-selfcheck (the park row); G1-ordinary / G1-write-protection / G1-transcript-write / G1-plan / G1-control-deny / G1-control-allow (a real Runner, a function `onAsk` answering by class: `ordinary` answered; `write_protection`, `transcript_write` and — under `plan` — `mode` refused); G2-gate / G2-hook / G2-inherited / G2-stamp (a policy's or a hook's own value); F1-pair (the pair's shape on every request the file saw). The kit entry: `test/store-contracts-discrimination.test.ts` (a store that drops the pair, and one that re-sorts `classes`, each fail exactly that entry; the reference store passes every entry), run on the in-memory, file and Pg (pg-mem) stores by `test/cross-backend-inmem-file.test.ts` / `test/pg-cross-backend.test.ts`.
76
+ - Mutations (`.gate-logs/criteria-7.37.0-mutations.md`, per fixture): D1010 the gate's stamp writes no class; D1011 the park row does not carry the pair; D1012 a value already on the decision survives the stamp (a policy's, or an earlier stamp's on a re-stamped fallback); D1013 the gate's request carries `askClass` without `classes`; D1014 the inherited-lane carry drops the pair.
77
+
78
+ #### The inherited lane reads an ancestor's decision once, at receipt (#1217; behavior (a stateful or mutated decision is read once; an out-of-contract ancestor decision is refused); +0 exports, +0 codes, +0 wire keys) (@server @test)
79
+
80
+ - **Before (v7.36.0).** The inherited (delegation) lane read an ancestor policy's decision object at every station — the classifier's eligibility, the origin word, the class set, the mode arbitration, the approval request's real-approval and mandate bits, the approved rewrite: 3 to 20 reads per member (measured; 4 to 21 on this release's tree before the fix, once the class set was read on every ask). A decision whose members are getters, or one the policy keeps and changes after returning it, answered each station with its own value. `requiresRealApproval` answering `++reads <= 4` under an ancestor `onAsk: "allow"` was refused on v7.36.0 and ran on the pre-fix tree. A policy that turned its returned ask into `action: "allow"` while its approver answered `"unavailable"` ran the call (a plain object or a function holding the members alike). One that swapped `updatedInput`, or changed a field inside it, after the approval executed the change, not the arguments the approver was shown. An ancestor ASK carrying an out-of-range `decisionReason`, one routed through an accessor, or the retired `reason` field reached the ancestor's approver and ran: the fold's out-of-contract screen only saw what the lane handed on.
81
+ - **Now.** Each call of an ancestor policy's `check` — the distinct-instance wrapper, the shared-instance caller slot, the approved-edit re-check — is received once: one copy of the decision's own enumerable data members, each taken off its own property descriptor (an accessor is refused, never invoked; a Proxy refused before a trap — #1226), their values detached from the policy (a structured clone), the engine's own records on the object carried across (an approval policy's attested settlement, a classifier's cause and model), the object frozen; every station reads that snapshot. A carrier that is not an object — a function holding the members, `null`, `undefined`, a primitive — is refused at receipt, before any approver is consulted. A member of the decision type that a plain read reaches but the copy does not hold — supplied by the prototype, held non-enumerable, or answered by a Proxy for a key it does not list — is refused, fail-closed, and so is a member value the receipt cannot detach (a function inside a rewrite): the copy would drop such a member and a plain read would keep it, and each reading loosened a station that read it the other way. The receipt then runs the fold's out-of-contract screen: its value checks on the snapshot, its carrier checks on the caller's object's property descriptors, never a value. A getter-backed decision is refused (an accessor; #1226); what a policy does to the object, or inside its members, after returning it does not reach the call.
82
+ - **Who is hurt.** An ancestor policy whose decision supplies members through getters: refused at receipt, the getter never invoked (v7.36.0 read it at every station — `++reads <= 4` on `requiresRealApproval` was refused at the approval, `++reads > 2` too). An ancestor policy that reuses and mutates an object it returned: what it held at receipt is what the call sees. An ancestor policy that returns a class instance whose members come from its prototype, a Proxy, an object carrying members on its prototype or as non-enumerable properties, or a rewrite holding a value a structured clone cannot copy: v7.36.0 read such a decision one way at some stations and another way at others; it is now refused at receipt with the fold screen's refusal (`tool_end.gate.disposition` `{ kind: "denied", deniedBy: "policy" }`, `decisionReason: "rule"`) — v7.36.0 denied the shapes measured here too, at the approval (`ask_resolution`) or at the fold, and ran the callable one (the pre-fix tree too; refused now). An ancestor policy whose ask carries an out-of-range `decisionReason` or the retired `reason` field: it reached the ancestor's approver before and is refused now, with the record and text the fold gives the same decision from a task's own caller policy. Compensation: return plain data — a plain object whose contract fields are own enumerable properties, `decisionReason` from the closed set, `message` rather than `reason`, a rewrite that is plain data.
83
+ - Pins: `test/backlog1217-inherited-decision-snapshot.test.ts` — ① the two getter forms refused unread, their plain controls as before; ② a Proxy ancestor decision refused on each receipt arm before a single trap runs (looks 0, gets 0 — the language's `await` probes `then` and nothing else), its ordinary twin riding the lane; ③ an approval policy's settlement rides the snapshot on each arm; ④ the post-return mutations (the object, a field inside the rewrite) and the callable carrier refused at receipt; ⑤ the screen at receipt; ⑥ the refused shapes (a Proxy answering `action`, a prototype `guard` under `bypassPermissions`, a non-enumerable `requiresRealApproval`, an undetachable rewrite, an own getter — its own-data twin the control); ⑦ the member list (its completeness is a compile-time fence over the decision type's keys).
84
+ - Mutations (`.gate-logs/criteria-7.37.0-mutations.md` Part D, per fixture): D1015 the receipt hands on the caller's object (①, ②, ④, ⑤.1–⑤.3, ⑥ red); the record carry dropped (③); the carrier judged on the snapshot (⑤.1, ⑤.4); the own copy without the refusal (②.4, ⑥.1–⑥.3); a plain read materialized instead of the refusal (⑥.1–⑥.3); no detach (④.3, ⑥.4); a callable carrier received as an object instead of refused (④.4; Part E).
85
+
86
+ #### An inherited grant covers the classes it answered, and an inherited station classifies over this task's tool seat (#1218; behavior (tighten: the child's gate asks its own protection question under delegation; a deployment-marked tool's first delegated card is `operator_forced`); +0 exports, +0 codes, +0 wire keys) (@server @cli @client-core @test)
87
+
88
+ - **Before (v7.36.0).** Under delegation (the Agent tool) with the host's `toolPolicy` asking on a tool, the inherited station asked the host's approver once and recorded a grant on the approver's identity and the presented byte shape; the child's own gate reused it class-blind. Its own classification of the same call — `write_protection` (a protected target), `probe` (a shell read outside the session's directories), `operator_forced` (an egress / irreversible mark) — was never put to anyone: a `Write` onto `<root>/.claude/projects/slug/memory/a.md` under an approver answering yes to everything showed one card and wrote the file. The station also spelled every plain ask `ordinary` (its facts named no seat), so a deployment-marked tool's delegated card read `ordinary` beside `riskAxes: { egress: true }`. On this release's tree before the fix, the §4.6e by-class approver (`ordinary` yes, every other class no) therefore answered the one card and the protected write, the out-of-root `cat` and the marked tool all went through unattended.
89
+ - **Now.** One rule: a settlement covers the question it answered. The grant records the request's class set (`InheritedAskGrant.classes`, `ASK_CLASSES` order) and the gate reuses it only when its own stamped `classes` is covered (`askGrantCovers`: `ordinary` — the plain hesitation every other class is a stricter form of — by any card answered; every other class only when it was on the card); otherwise the gate asks its own question — a by-class host refuses the protection class, a person sees the protection card — and the grant is not consumed (the call-site sweep ends it). The inherited station classifies the ancestor's ask over this task's tool seat (the egress / irreversibility / shell-seat marks and their sources, read once with the request's `riskAxes` — `report.markSources` joins the name-readings table), once, before the ancestor's classifier is judged; the sandbox arm, the mode arbitration, the request's `askClass` / `classes` and the grant record all read that one classification. Measured: the protected write under the by-class approver — two cards, `["ordinary"]` then `["write_protection"]`, the second refused, the file not written; under yes-to-everything — the same two cards, then written; the marked tool — one card `["operator_forced"]`, refused by class, executed 0 (a person's yes runs it once, no second card); `cat <outside>` — `["ordinary"]` then `["probe"]`, refused, nothing printed; an ordinary write — one card, written. The `mandate` word is not part of the coverage judgment (it is display, never adjudication input, and the station's word is structurally absent: the ancestor door). A person who answered the station's stricter card — the classifier's bound fallback (`denial_limit_fallback`), a real-approval ask (`policy_rra`), a deployment-authored ask (`operator_forced`), an explicit ask rule (`ask_rule`), a guard — is not asked the plain `ordinary` question again about the same bytes: one card (the first form of this rule, a literal subset, asked two).
90
+ - **Who is hurt.** A deployment that relied on one ancestor-level yes clearing a delegated child's protected write, out-of-root shell read or marked tool silently: the child's gate now asks that question. A host that renders the first card of a delegated deployment-marked tool: under delegation it reads `operator_forced` where it read `ordinary` (the same class the gate has always given the tool's own card), and under `bypassPermissions` / `dontAsk` that inherited ask is bypass-immune at the station (asked at the ancestor's frozen approver, not released or refused by the mode there), as at the gate. `Read` outside the session's directories stays `ordinary` (§4.6e names the coverage). Under `plan`, a delegated call shows one more card than v7.36.0: the station's (`["ordinary"]`, or `["operator_forced"]` for a marked tool) and then the child's gate's `[…, "mode"]` — the mode's own question is a class no ancestor card carried, so it is put to the approver (v7.36.0 reused the grant class-blind and asked it of nobody).
91
+ - **Compensation.** A by-class approver per §4.6e — answer `ordinary`, leave the protection classes to a person — needs no change: the protection question now reaches it and it refuses. A host that wants a marked tool's delegated call to run answers its `operator_forced` card, once.
92
+ - Pins: `test/backlog1218-inherited-grant-coverage.test.ts` — ① the protected write under the by-class host (two cards, refused, not written, one `ask_resolution` refusal); ② the marked tool, both marks (one `operator_forced` card, refused, executed 0; a person's yes: executed once, no second card); ③ the out-of-root `cat` (two cards, `probe`, refused, not printed) and the `Read` control (one `ordinary` card); ④ an ordinary write (one card, covered, written); ⑤ yes-to-everything (`ordinary`, `write_protection`, written); ⑧ the consume half as a unit; ⑨ a stricter card covers the plain question — the bound fallback (`maxConsecutive: 1`) and the four decision-borne classes on the distinct arm, one card each, ran once; the predicate rows; the `write_protection` control (①.1 on the wire). `test/backlog681-auto-mandate-not-classifiable.test.ts`'s lexical anchor follows the sandbox arms to the one classification; `test/name-readings-oracle.test.ts` carries the new row.
93
+ - Mutations (`.gate-logs/criteria-7.37.0-mutations.md` Part E, per fixture): D1016 the consume half reuses without the class comparison (①, ③ shell, ⑤, ⑧.3 red); D1017 the station classifies over the bare station facts (② red, both marks); D1025 (Part G) the coverage predicate back to a literal subset (⑨.1–⑨.5 red: two cards, the second `ordinary`; ⑧ and the control green).
94
+
95
+ ### A decision's action is a closed set (#1221; behavior: tighten; +0 exports, +0 codes, +0 wire keys) (@server @test)
96
+
97
+ - **Before (v7.36.0).** The policy fold ranks a decision by testing `action === "deny"` and `action === "ask"`; every other value ranked as an allow. A `ToolPolicy.check` answering `{ action: "bogus" }`, `{ message }` with no `action`, `{ action: "Allow" }`, `{ action: 1 }`, a bare `"deny"` string, or a function holding `action: "allow"` ran the tool: `tool_end.gate.disposition` `{ kind: "allowed" }`, no engine notice. The same held for an ancestor's policy on a delegated child (the inherited lane's receipt runs the same screen, which did not look at `action`).
98
+ - **Now.** The fold's out-of-contract screen holds `action` to allow / deny / ask. Any other value — absent, a case or whitespace variant, a number, an object, a Symbol, or a decision that is not an object at all (a function included) — is refused fail-closed before any approver, with the screen's own refusal: `tool_end.gate.disposition` `{ kind: "denied", deniedBy: "policy" }` (the record the retired `reason` field and an out-of-range `decisionReason` already get), the tool not run, and a model-facing text naming `"action"` and `allow/deny/ask`. It applies wherever the screen runs: every layer of the gate's fold, the inherited lane's receipt, and the direct re-checks (the resume edit, the park re-projection, the changed-files probe). `null` / `undefined` are refused at the same construction point with the screen's text (`not an object`); before, the caller's read of `.action` threw and the refusal read `policy check aborted (task timed out or cancelled)` — a wrong attribution, not a run. A PreToolUse hook's result is not screened for its action: the hook seam reads anything other than deny / ask as no objection, and the policy still decides the call.
99
+ - **Who is hurt.** A policy — the task's own or an ancestor's — that returns an action outside the three words, spells one in another case (`"Allow"`), leaves it out, or returns something other than an object: the tool ran before; it is refused now.
100
+ - **Compensation.** Return `{ action: "allow" | "deny" | "ask" }`, spelled exactly.
101
+ - Pins: `test/backlog1221-decision-action-closed-set.test.ts` — ① the gate lane and ② the inherited lane, six out-of-set shapes each (a function holding `action: "allow"` among them — refused by the carrier arm on the caller's object, on both lanes) (refused before any approver, the retired-`reason` refusal's record); ③ the three words on both lanes; ④ the screen's value shapes (empty, case and whitespace variants, number, boolean, object, null, Symbol, a null-prototype object, a non-object decision, a callable carrier, null / undefined, an unrenderable and an over-long word), the three words handed back value-equal on the engine's frozen receipt (never the caller's object; a receipt handed back is itself — #1226), and the hook seam left to its own mapping. The set is fenced to `PermissionResult["action"]` at compile time, both ways.
102
+ - Mutations (`.gate-logs/criteria-7.37.0-mutations.md` Part F, per fixture): D1018 the screen's action arm removed (①, ②, ④.1–④.3 red); non-objects passed through (①.5, ②.5, ④.2); a function read like an object (①.6, ②.6, ④.2); a case-insensitive test (①.3, ②.3, ④.1); the arm at the hook seam (④.5); a deny without the screen's `decisionReason` (④.1, ④.2). Part G re-runs this file on the receipt (the `snapcarrier` / `callable` rows of Part F describe a `carrierOf` seat the receipt no longer has; Part G supersedes them).
103
+
104
+ #### The contract screen is the one receipt of a policy decision (#1226; behavior (tighten: a host's direct `runToolGate` adjudication and the approved-edit re-check are screened; the screen hands back the engine's copy, never the caller's object); +0 exports, +0 codes, +0 wire keys) (@server @cli @test)
105
+
106
+ - **Before (v7.36.0).** The fold's out-of-contract screen validated a decision and returned the caller's object, and every station after it read that object again: the task's own policy's `action` accessor was read 17 times on the gate lane (measured), so a decision whose accessor changed its answer was decided by the read count. A host driving `runToolGate` itself (its own `adjudicate`), and the approved-edit re-check after a person's edit, never passed the screen: `{ action: "bogus" }`, `{ message }` (no action), the string `"deny"`, `{ action: "" }`, the retired `reason` field and an out-of-range `decisionReason` were allowed at the gate's entry and re-asked up to the edit chain's cap at the re-check. On this release's tree before the fix, the #1221 action arm read the object once more and returned it (19 reads: an accessor answering `deny` 17 times then `allow` ran the tool), and the inherited lane's receipt detached the decision before the screen saw the fallback member, so a retired `autoDenyAfterMs` on the fallback's prototype was erased by the clone and the call ran where v7.36.0 denied.
107
+ - **Now.** One function, one order, both lanes. `refuseOutOfContractDecision` (1) refuses a carrier that is not an object — a primitive, a function, `null`, `undefined` — on the caller's object; (2) reads ONCE, by one function for the decision and for the records it nests — `denialLimitFallback`, the probe account `probeCause` and its `roots` / `further` operand records (one rule at every level): a contract member is absent or an own enumerable DATA property, its value ONE plain read (no caller code runs in it on an ordinary object) that must agree with two looks at the carrier's property descriptors around it (on a Proxy the traps are caller code — what every channel of the carrier agrees on is kept); every other spelling is refused, the member named by path (`denialLimitFallback.consecutive`, `probeCause.roots.total`): an accessor (never invoked — a getter, a lazy getter that replaces itself, a sibling accessor that would rewrite `action`) or a read or shape that disagrees with the looks, and a member the prototype or a non-enumerable property supplies, unless that descriptor spells absence (an `undefined` data value — the typed spelling of an optional member left out, received as on v7.36.0) — judged by DESCRIPTOR, never by a read; and the carrier is an ORDINARY object: a Proxy, as the decision, as a nested record, or anywhere on the prototype chain (a revoked one too), is refused before a trap of its runs (`util.types.isProxy` invokes none). So the receipt runs none of the carrier's code, and nothing of the carrier's can edit a value between the one read and the detach — the review's r4 round found the tree before it probing an absent member with one plain read, which invoked a prototype getter that edited the rewrite the receipt had already taken a reference to (the edited rewrite ran), and an `in`-based fix invoking a Proxy prototype's `has` the same way. A Proxy decision was accepted on v7.36.0 and on this release's review trees when its channels agreed; it is refused now, with the screen's deny and a sentence naming it; a member outside the contract is neither read nor carried (a policy's self-declared `settlement` finds no reader); (3) refuses the retired `reason` as an own property (after the receipt, so a carrier the receipt refuses is never touched by the look); (4) holds the values of that one-read record — never a second read of the caller's object — to the contract: the retired window absent, `action` and `decisionReason` in their closed sets; (5) detaches the contract members (a structured clone; one that cannot be cloned is refused), carries the engine's identity-keyed attestations, freezes the copy AND the records it nests (the fallback, the probe account and its operand records — a wrapper editing a nested record of the receipt it was handed throws where the fast path used to carry the edit through unscreened) and marks the copy — a receipt handed to the screen again comes back as itself, unread. The descriptor map is read by own entry, so a polluted `Object.prototype` names no member; a prototype chain that throws while looked at is a deny naming the throw, not an exception. Every station after the receipt reads the copy. Entries: each layer of `combinePolicies`' fold, the chain instrumentation (the gate lane's first reader of the task's own policy), the gate's adjudication entry and its approved-edit re-check, the inherited lane's three receipts and the final-form re-check, the direct rechecks (resume edit, park re-projection, changed-files probe). Measured: the 17/19-read accessor — 0 reads, refused, ran 0; deny ×2 then `bogus` — 0 reads, refused (v7.36.0: allowed); the prototype window — denied on both lanes, `denialLimitFallback.autoDenyAfterMs` named; the fallback's window behind a getter answering `undefined` then `1` (gate lane through `combinePolicies`, and inherited) — 0 reads, refused, nobody asked (this release's tree before the r3 fix: the window check read `undefined`, the clone read `1`, the call was asked and ran with the window on the engine's copy; v7.36.0 refused it on the gate lane and, on the inherited lane, never read the member and ran the call); the fallback's counts on a class prototype under a real-approval ask — refused, `denialLimitFallback.consecutive` named (v7.36.0: the approver saw 3/3 and allowed; the tree before the r3 fix cloned the counts to `{}` and the approver refused, silently); a Proxy whose descriptor trap rewrites its target during the read, and one whose traps alternate its target so that every descriptor look reports a pair it never holds — refused as a Proxy, not one trap run (the review trees refused them after the looks, the member named); an operand count of the probe account on a prototype or non-enumerable — refused, `probeCause.roots.total` named (the tree before r3 cloned it away: the approver was shown `shown` alone); the six shapes at `runToolGate`'s own entry and the four at the edit re-check — blocked, `{ kind: "denied", deniedBy: "policy" }`, the screen's text; `null` / `undefined` — the same record with a text naming `not an object` (before: `policy check aborted (task timed out or cancelled)`, the caller's read having thrown). A null-prototype object, a frozen decision with frozen nested arguments and a class instance holding the contract members as its own fields are received as before. The hook seam keeps its own reach (the two retired-field arms, the object back unchanged). `snapshotAncestorDecision` (the inherited lane's receipt of 7.37.0's first cut) is retired into the screen; its member list `DECISION_MEMBERS` lives beside it, and the nested records' (`NESTED_CONTRACTS`: the fallback's `consecutive` / `total` / `limit` / `autoDenyAfterMs`, the probe account's `code` / `roots` / `further`, each operand record's `shown` / `total`) beside that. The bare allow `combinePolicies` answers when no layer rewrote and nothing was attested — one object for every such call in the process — is frozen.
108
+ - **Who is hurt.** A host whose `adjudicate` (driving `runToolGate` directly) or approved-edit re-check answers outside the contract — allowed / re-asked before, refused now. A host that relied on the object it returned being the object the engine holds: reference identity across the gate, or rewriting the decision (or the rewrite inside it) after returning it — the engine keeps its own copy now, and what it read is what runs. A policy whose `denialLimitFallback` carries the retired window on its prototype — refused on both lanes (the gate lane already did). A policy that puts members outside the contract on its decision: they were never read; they are no longer carried either. A policy that returns a Proxy as its decision (a logging wrapper, an observable) — accepted before when its traps agreed with each other; refused now, before a trap runs, with the screen's deny naming it: return the plain object. A policy — the task's own on the gate lane as much as an ancestor's — that supplies a contract member through a getter (a class result whose members are prototype getters, a lazy getter that caches itself as data on first read, an own `get action()`), carries one on its prototype, or answers with an `Error` as the carrier (its `message` is an own non-enumerable property): v7.36.0 read the getter at every station, and this release's tree before the r3 fix read it once and kept the first value; each shape either ran or kept its own text — refused now, in the screen's words, the member named. A policy whose `denialLimitFallback` supplies its counts from a prototype (a class instance): v7.36.0 showed the approver the counts; the tree before the r3 fix silently cloned them to `{}`; refused now with `denialLimitFallback.consecutive` named. A policy whose probe account (`probeCause`) holds a member on a prototype, as a non-enumerable property or behind an accessor: refused now, the member named by path. A host that wraps `combinePolicies(...).check` and edits the decision it is handed: the shared bare allow and the engine's receipt are frozen, so the edit throws a `TypeError` in strict code where it used to edit — for the shared allow, every later allow in the process; in sloppy-mode code (a CommonJS wrapper, `new Function`) the write is silently ignored instead, so a wrapper that edited the rewrite in place to redact arguments now runs the call with the model's own arguments — spread a copy (`{ ...d, updatedInput }`) and return that. A policy whose rewrite (`updatedInput`) carries a function or another value a structured clone cannot copy: on the park path it was refused at the park mint before (a `ParkRefusal` on the error face, the store untouched); on the Runner's synchronous path v7.36.0 allowed it and the call failed at execution (`… could not be cloned.`, the tool not run); a host driving `runToolGate` directly got it back unchanged — it is refused at the receipt now on every path, before any park is attempted, the store untouched, the deny quoting the runtime's sentence (a PreToolUse hook's rewrite still rides its seam to the park mint and is refused there, as before). A policy whose park-time re-adjudication (the stored-shape re-check under a JSON-fidelity checkpoint store) answers with a rewrite carrying an own accessor or a class brand whose data equals the filed value: the park guard used to refuse it as not naming that value (its raw-shape arm); the receipt reads the rewrite once into plain data and the guard compares that, so the call parks and the accessor is never read again.
109
+ - **Compensation.** Return plain data — `{ action: "allow" | "deny" | "ask", … }` with the contract members (and the fallback's counts) as own enumerable data properties, no accessors, no retired members — and treat the returned object as consumed. A wrapper that adjusts a decision spreads a copy (`{ ...d, … }`) and edits that.
110
+ - Pins: `test/backlog1226-decision-receipt.test.ts` — ⑪ a prototype getter for an absent contract member that edits the rewrite / the action when invoked (both lanes × three edits, and the screen: refused with `probeCause` named, invoked 0, ran 0 — the r3 tree invoked it once and ran the edited rewrite; v7.36.0 never invoked it and ran the rewrite as returned); ⑫ the ordinary-object law's edges: an `undefined` data property on the prototype / non-enumerable / on the probe account received as absent (both lanes, as v7.36.0); a Proxy on the prototype chain whose `has` / descriptor trap edits the rewrite refused with 0 traps run (the `in` tree ran the edited rewrite); a revoked Proxy as the fallback record refused in the screen's words, no TypeError (the r3 tree threw); a `get`-only Proxy under an ancestor `onAsk: "allow"` refused (v7.36.0 refused at the approval; the `in` tree ran); ④ a Proxy carrier refused before a single trap (looks 0, gets 0) on the lane and at `runToolGate`'s entry, its ordinary twin received once into the frozen copy; ① the 17/19-read accessor (0 reads, refused, both lanes); ② deny ×2 then `bogus` (0 reads, refused, both lanes); ③ the prototype window (both lanes, and the screen); ④ a Proxy carrier on the gate lane and at `runToolGate`'s entry refused before a single trap (looks 0, gets 0), its ordinary twin received once into the frozen copy, the same members behind own getters refused, none invoked — beside `test/backlog564-ask-origin.test.ts` and `test/backlog619-read-only-gate.test.ts` (a stateful Proxy carrier refused unread, the plain twin carrying the stamped member and its word, an accessor refused unread); ⑬ the copy discipline observed from outside: a host editing the object it returned inside `resolveAsk` — the executed arguments and the re-check's request are the receipt's copy — and a wrapper editing the receipt's nested record throws (frozen); ⑤ `runToolGate`'s entry on eight shapes (the six, `null`, `undefined`) and the three-word control; ⑥ the approved-edit re-check and the final-form re-check on the four shapes, with the in-contract control; ⑦ the null-prototype, frozen and class-instance carriers received on both lanes; ⑧ the Proxy's virtual member and the rewriting sibling accessor (both key orders; refused as an accessor, never invoked) refused on both lanes and at the screen; ⑨ the receipt as a unit (value-equal, not the caller's object, frozen, itself on a second pass; the detached rewrite; the unclonable rewrite; `null` / `undefined` on both lanes; the hook seam); ⑩ the fallback record received by the decision's function — the window getter refused unread on the gate lane through `combinePolicies`, on the inherited lane and at the screen; prototype counts refused with `denialLimitFallback.consecutive` named on both lanes; own-data counts, and an own `autoDenyAfterMs: undefined`, received with the approver shown 3/3 and the record carrying the contract's members only; the frozen bare allow (a wrapper's edit throws, a second Runner's allow unedited); a descriptor trap that rewrites its target mid-read, and one that alternates it (every look agreeing on a pair it never holds), refused on both lanes and at the screen; the probe account's prototype / non-enumerable operand count and accessor `code` refused by path on both lanes, its own-data twin reaching the approver whole. `test/backlog1221-decision-action-closed-set.test.ts` ④.2 / ④.4 and `test/policy-out-of-contract-decision.test.ts`'s four same-reference pins read the receipt form. `test/park-mint-unclonable-args.test.ts`: a policy's uncapturable rewrite refused at the receipt (store untouched), a hook's at the park mint (the `cause` by identity). `test/park-mint-backend-fidelity.test.ts`: under a JSON-fidelity store, a re-projection rewrite carrying a class brand, a symbol key, a null prototype, an inextensible or frozen twin, or an own accessor parks with the plain projected value (the accessor read once); key order and a Proxy stay refused.
111
+ - Mutations (`.gate-logs/criteria-7.37.0-mutations.md` Part G, per fixture): D1023 the screen returns the caller's object after validating it (⑨.1 / ⑨.2 / ⑩.8 / ⑩.11 and `backlog1217` ④ red; ①, ②, ④ green by construction since r5 — accessors and Proxies are refused before anything is handed on); D1024 the fallback's window judged after the detach (③ red); D1025 the coverage predicate back to a literal subset (`backlog1218` ⑨.1–⑨.5 red); D1018 the action arm removed (②, ⑤, ⑥ red); D1015 the receipt hands on the caller's object unread; the pre-fix carrier arm (`null` / `undefined` through); a prototype / Proxy member materialized; the descriptor comparison removed; the receipt unmarked; the chain instrumentation reading the layer's object directly (①.1, ④.1 red: n = 2). Part I (the r4 form): the probe read restored (⑪ ×7, ⑩.4 / ⑩.5, ⑧.1 / ⑧.4 / ⑧.7, `backlog1217` ②.4 red — 13); the carrier's Proxy check removed (⑫.3, ⑫.4, ④, ⑧, ⑩.10 / ⑩.12, `backlog1217` ② red — the prototype-chain check still names it); `undefined` no longer spelling absence (⑫.1 red); the prototype-chain Proxy check removed (⑫.2 red). Part H (the r3 form, per fixture): D1026 the window judged by a plain read of the caller's fallback before its receipt (③, ⑩.1–⑩.3 red: the retired-window words in place of the path, the getter read); D1027 the nested records left to the clone alone, no descriptor judgment (③, ⑩.1–⑩.5, ⑩.8, ⑩.11 red); the probe account's rows dropped from the nested table (⑩.11 red); the read's agreement with the looks removed (⑩.12 and `backlog619`'s disagreeing read red); values taken off the first look instead of a read (⑩.12 and the one-read rulers red); one look (the look-count rulers and `backlog564`'s changing-descriptor twin red; ⑩.10 stays refused by the read's agreement); the Part G variants re-run on the r3 screen, the descriptor comparison's row replaced by accessors read and kept (①, ②, ④.3, ⑧'s accessor rows, ⑩.1–⑩.3, ⑩.11, `backlog1217` ①, ⑤.1, ⑤.4, ⑥.5, `backlog564` / `backlog619` r2 and `policy-out-of-contract-decision`'s accessor pin red); the bare allow unfrozen (⑩.9 red).
112
+
113
+ ### Documentation
114
+
115
+ - The `peer.held_settled` catalog entry (`EngineNotice.code` JSDoc) lists all nine `detail.reason` words (`PeerHeldSettleReason`). It adds `superseded`: a record another lease holder already settled, sent no receipt from this session. Comment only.
116
+
1
117
  ## 7.36.0 — 2026-09-30
2
118
 
3
119
  **Hard-breaking changes — the whole release in three sentences.**
@@ -2,7 +2,7 @@ import {} from "../internal/llm.js";
2
2
  import { BrainError } from "./errors.js";
3
3
  import { startsWithInputTooLongAnchor } from "./input-too-long.js";
4
4
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
5
- import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
5
+ import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs, placeTurnNotes, truncationCauseClause } from "./stream-shared.js";
6
6
  import { admitToolCallId, providerResultIds, providerToolCallId } from "./tool-call-id.js";
7
7
  import { emitBrainTelemetry, reportReasoningWireFacts } from "./status-sink.js";
8
8
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
@@ -616,30 +616,23 @@ export function createAnthropicBrain(config = {}) {
616
616
  const noUsableContent = toolCalls.length === 0 && !anyText;
617
617
  const toolErrorParts = [];
618
618
  if (malformed.length > 0) {
619
- toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, stop_reason="${stopReason ?? "?"}"${sentMaxTokens !== undefined ? `; request output cap in effect: ${sentMaxTokens} tokens — a cut mid-arguments commonly means the cap was hit` : ""}): ${malformed.join("; ")}`);
619
+ toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, stop_reason="${stopReason ?? "?"}"${truncationCauseClause({ capStop: stopReason === "max_tokens", outputTokens: usageRaw.output_tokens, capTokens: sentMaxTokens, terminalFrameSeen: terminalFrameSeen() })}): ${malformed.join("; ")}`);
620
620
  }
621
621
  if (unnamed.length > 0) {
622
622
  toolErrorParts.push(`tool call(s) arrived with no tool name and cannot be executed (stop_reason="${stopReason ?? "?"}"): ${unnamed.join("; ")}`);
623
623
  }
624
624
  const toolError = toolErrorParts.length > 0 ? toolErrorParts.join(" | ") : undefined;
625
+ const notes = [];
625
626
  if (malformed.length > 0) {
626
- finalContent.push({
627
- type: "text",
628
- text: `\n[note: ${malformed.length} tool call(s) were truncated (stop_reason="${stopReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`,
629
- });
627
+ notes.push(`\n[note: ${malformed.length} tool call(s) were truncated (stop_reason="${stopReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`);
630
628
  }
631
629
  if (unnamed.length > 0) {
632
- finalContent.push({
633
- type: "text",
634
- text: `\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`,
635
- });
630
+ notes.push(`\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`);
636
631
  }
637
632
  if (malformedFrames > 0) {
638
- finalContent.push({
639
- type: "text",
640
- text: `\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`,
641
- });
633
+ notes.push(`\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`);
642
634
  }
635
+ placeTurnNotes(finalContent, notes);
643
636
  const truncatedEmpty = stopReason === "max_tokens" && noUsableContent && !toolError;
644
637
  const dynamicCut = truncatedEmpty && options?.maxTokensDynamic === true;
645
638
  const staticReasoningCut = truncatedEmpty && !dynamicCut && reasoningSeen && options?.staticReasoningCutDowngrade === true;
@@ -1,7 +1,7 @@
1
1
  import {} from "../internal/llm.js";
2
2
  import { BrainError } from "./errors.js";
3
3
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
- import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
4
+ import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs, placeTurnNotes } from "./stream-shared.js";
5
5
  import { admitToolCallId, providerResultIds, providerToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
@@ -673,24 +673,17 @@ export function createOpenResponsesBrain(config = {}) {
673
673
  toolErrorParts.push(`tool call(s) arrived with no tool name and cannot be executed (status="${terminalLabel}"): ${unnamed.join("; ")}`);
674
674
  }
675
675
  const toolError = toolErrorParts.length > 0 ? toolErrorParts.join(" | ") : undefined;
676
+ const notes = [];
676
677
  if (malformed.length > 0) {
677
- finalContent.push({
678
- type: "text",
679
- text: `\n[note: ${malformed.length} tool call(s) were truncated (status="${terminalLabel}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`,
680
- });
678
+ notes.push(`\n[note: ${malformed.length} tool call(s) were truncated (status="${terminalLabel}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`);
681
679
  }
682
680
  if (unnamed.length > 0) {
683
- finalContent.push({
684
- type: "text",
685
- text: `\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`,
686
- });
681
+ notes.push(`\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`);
687
682
  }
688
683
  if (malformedFrames > 0) {
689
- finalContent.push({
690
- type: "text",
691
- text: `\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`,
692
- });
684
+ notes.push(`\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`);
693
685
  }
686
+ placeTurnNotes(finalContent, notes);
694
687
  const truncated = incompleteReason === INCOMPLETE_MAX_OUTPUT_TOKENS;
695
688
  const truncatedEmpty = truncated && noUsableContent && !toolError;
696
689
  const dynamicCut = truncatedEmpty && options?.maxTokensDynamic === true;
@@ -1,7 +1,7 @@
1
1
  import {} from "../internal/llm.js";
2
2
  import { repairTextToolCalls } from "./tool-call-repair.js";
3
3
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
- import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
4
+ import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs, placeTurnNotes, truncationCauseClause } from "./stream-shared.js";
5
5
  import { admitToolCallId, providerResultIds, providerToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
@@ -570,31 +570,24 @@ export function createOpenAIBrain(config = {}) {
570
570
  }
571
571
  const toolErrorParts = [];
572
572
  if (malformed.length > 0) {
573
- toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, finish_reason="${finishReason ?? "?"}"${sentMaxTokens !== undefined ? `; request output cap in effect: ${sentMaxTokens} tokens — a cut mid-arguments commonly means the cap was hit` : ""}): ${malformed.join("; ")}`);
573
+ toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, finish_reason="${finishReason ?? "?"}"${truncationCauseClause({ capStop: finishReason === "length", outputTokens: usageRaw?.completion_tokens, capTokens: sentMaxTokens, terminalFrameSeen: terminalFrameSeen() })}): ${malformed.join("; ")}`);
574
574
  }
575
575
  if (unnamed.length > 0) {
576
576
  toolErrorParts.push(`tool call(s) arrived with no tool name and cannot be executed (finish_reason="${finishReason ?? "?"}"): ${unnamed.join("; ")}`);
577
577
  }
578
578
  const toolError = toolErrorParts.length > 0 ? toolErrorParts.join(" | ") : undefined;
579
579
  const noUsableContent = toolCalls.length === 0 && !accumText.trim();
580
+ const notes = [];
580
581
  if (malformed.length > 0) {
581
- finalContent.push({
582
- type: "text",
583
- text: `\n[note: ${malformed.length} tool call(s) were truncated (finish_reason="${finishReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`,
584
- });
582
+ notes.push(`\n[note: ${malformed.length} tool call(s) were truncated (finish_reason="${finishReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`);
585
583
  }
586
584
  if (unnamed.length > 0) {
587
- finalContent.push({
588
- type: "text",
589
- text: `\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`,
590
- });
585
+ notes.push(`\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`);
591
586
  }
592
587
  if (malformedFrames > 0) {
593
- finalContent.push({
594
- type: "text",
595
- text: `\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`,
596
- });
588
+ notes.push(`\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`);
597
589
  }
590
+ placeTurnNotes(finalContent, notes);
598
591
  const truncatedEmpty = finishReason === "length" && noUsableContent && !toolError;
599
592
  const dynamicCut = truncatedEmpty && options?.maxTokensDynamic === true;
600
593
  const safetyCut = finishReason === "content_filter";
@@ -1,4 +1,5 @@
1
1
  import { type RepetitionEvent } from "./repetition.js";
2
+ import type { AssistantMessage } from "../internal/llm.js";
2
3
  /**
3
4
  * design/157 A10 — stream-parser pieces that the anthropic and openai SSE lanes carried BYTE-IDENTICALLY
4
5
  * (verified by diff at 1469481: a 16-line repetition-telemetry closure). Provider-agnostic policy, so it
@@ -28,6 +29,26 @@ import { type RepetitionEvent } from "./repetition.js";
28
29
  * mid-stream promotion (the body threw) and by the parsers' own finalize (content arrived, no finish frame). */
29
30
  export declare const STREAM_TORN_WARNING = "Connection closed mid-response. The response above may be incomplete.";
30
31
  export declare function parseStreamedToolArgs(json: string): Record<string, unknown> | undefined;
32
+ /**
33
+ * Place a turn's engine notes in its final content: AHEAD of the first tool call, in order, or at the end of a turn
34
+ * that has none. Every dropped call (cut arguments, no tool name) and dropped stream frames are stated so the model
35
+ * re-issues them — also when text survived and no call did; a partial batch still executes its valid calls (a brain
36
+ * `error` is a terminal task failure, not a re-prompt). No block may follow the calls: the Anthropic Messages API
37
+ * rejects a `tool_use` followed by anything but another `tool_use`. One rule for the three lanes.
38
+ */
39
+ export declare function placeTurnNotes(content: AssistantMessage["content"], notes: readonly string[]): void;
40
+ /**
41
+ * The cause clause of a dropped call's "not valid JSON" message. The output cap is named only when the stream says
42
+ * the cap cut it — the lane's own cap stop, or a counted output at or over the cap on the wire; a stream that ended
43
+ * without its stop frame names the transport instead; any other stop adds nothing (naming the cap there sent a reader
44
+ * after a budget that was never reached). One rule for the lanes that carry the clause.
45
+ */
46
+ export declare function truncationCauseClause(facts: {
47
+ capStop: boolean;
48
+ outputTokens: number | undefined;
49
+ capTokens: number | undefined;
50
+ terminalFrameSeen: boolean;
51
+ }): string;
31
52
  /** Repetition telemetry for ONE stream — the cut event plus the allowance-spared hits. */
32
53
  export interface RepetitionPoll {
33
54
  /** Inspect the accumulated text; returns whether it is degenerate (the caller trips its own cut). */
@@ -16,6 +16,19 @@ export function parseStreamedToolArgs(json) {
16
16
  return undefined;
17
17
  return parsed;
18
18
  }
19
+ export function placeTurnNotes(content, notes) {
20
+ if (notes.length === 0)
21
+ return;
22
+ const at = content.findIndex((block) => block.type === "toolCall");
23
+ content.splice(at === -1 ? content.length : at, 0, ...notes.map((text) => ({ type: "text", text })));
24
+ }
25
+ export function truncationCauseClause(facts) {
26
+ const { capStop, outputTokens, capTokens, terminalFrameSeen } = facts;
27
+ if (capTokens !== undefined && (capStop || (outputTokens !== undefined && outputTokens >= capTokens))) {
28
+ return `; request output cap in effect: ${capTokens} tokens — a cut mid-arguments commonly means the cap was hit`;
29
+ }
30
+ return terminalFrameSeen ? "" : "; the stream ended before its stop frame — the connection closed mid-response";
31
+ }
19
32
  export function createRepetitionPoll() {
20
33
  let repCut;
21
34
  const repSpared = new Map();
@@ -107,8 +107,9 @@ export interface AskOriginFacts {
107
107
  /** The MANDATE mark inputs of this call — the tool SEAT's marks plus the gate's own per-call records
108
108
  * ({@link MandateMarks}), read from the same two places the lane's own derivation reads them. The
109
109
  * per-call PROBE member is deliberately not here: it is engine-stamped on the DECISION, and
110
- * {@link ruleClearanceOf} reads it there, so one fact cannot arrive twice with two values. A station
111
- * holding no tool seat (the inherited lane) passes `{}` — an honest empty, since the decision's own
110
+ * {@link ruleClearanceOf} reads it there, so one fact cannot arrive twice with two values. An inherited
111
+ * station passes the tool SEAT's marks of the task it runs in (its per-call records are the gate's, absent
112
+ * there); a station holding no seat at all passes `{}` — an honest empty, since the decision's own
112
113
  * members still answer. */
113
114
  readonly mandateMarks: Omit<MandateMarks, "probeMandated">;
114
115
  }
@@ -133,12 +134,15 @@ export declare function askOriginOf(decision: Extract<PermissionResult, {
133
134
  * <out-of-root>` under a standing `Bash(cat:*)` ask rule offered the directory and kept asking after it).
134
135
  * It lives at the one derivation rather than the three stamp sites: the withdrawal's input IS the origin
135
136
  * word, so a site that stamped without it would be a second, quieter answer to one question.
137
+ * The same stamp writes the ask's CLASS members (`askClass` / `classes`, {@link askClassMembersOf} over the
138
+ * `classification` the caller derived from this decision under these facts): the word and the class leave the
139
+ * gate on one object, and a value a policy put on either is replaced.
136
140
  */
137
141
  export declare function stampAskOrigin<T extends Extract<PermissionResult, {
138
142
  action: "ask";
139
- }>>(decision: T, facts: AskOriginFacts): T & {
143
+ }>>(decision: T, facts: AskOriginFacts, classification: AskClassification): T & {
140
144
  origin: AskOrigin;
141
- };
145
+ } & AskClassMembers;
142
146
  /**
143
147
  * CAN a persisted allow rule clear this ask — and, if not, which door closed? ONE derivation with TWO
144
148
  * readers, which is the whole point of the shape: the approval card's `ruleOffersAbsence` word and the
@@ -610,6 +614,18 @@ export declare const RRA_EXPLAINERS: ReadonlySet<AskClass>;
610
614
  /** The classes a permission mode can RELEASE (the `bypassImmune: false` rows) — the only words that can appear on
611
615
  * `GateDisposition.released.askClass`; every other class reaches a person under every mode. */
612
616
  export declare const RELEASABLE_ASK_CLASSES: readonly AskClass[];
617
+ /**
618
+ * The closed set of `reason` words a mode release carries (`GateDisposition.released.reason`, the `released` arm of
619
+ * {@link Arbitration}): each is a releasable class the release record names on its own — `rule_unreadable`, a release
620
+ * over a person's rule the call could not be read against. A release carries the first member (array order) its class
621
+ * set holds, and none when it holds no member. {@link arbitrateByMode} and {@link isModeReleaseReason} read one copy
622
+ * of this list taken at load (a caller that writes into the exported array changes neither), so a new reason is one
623
+ * word here.
624
+ */
625
+ export declare const MODE_RELEASE_REASONS: readonly ["rule_unreadable"];
626
+ export type ModeReleaseReason = (typeof MODE_RELEASE_REASONS)[number];
627
+ /** Membership test for {@link ModeReleaseReason} — the word crosses the wire on `tool_end.gate.disposition.released.reason`. */
628
+ export declare function isModeReleaseReason(v: unknown): v is ModeReleaseReason;
613
629
  /**
614
630
  * The facts the class derivation reads beside the decision: the origin facts the gate holds, plus the tool
615
631
  * seat's mark PROVENANCE ({@link ToolMarkSource}; a mark with no source reads as the deployment's, fail-closed),
@@ -628,6 +644,20 @@ export interface AskClassification {
628
644
  readonly askClass: AskClass;
629
645
  readonly row: ModeArbitrationRow;
630
646
  }
647
+ /** The two members an ask's classification puts on the approval request and the park row: the disclosed word and
648
+ * the whole set, the set as an array in {@link ASK_CLASSES} order (so `classes[0] === askClass`). */
649
+ export interface AskClassMembers {
650
+ readonly askClass: AskClass;
651
+ readonly classes: readonly AskClass[];
652
+ }
653
+ /** The ONE projection of a classification onto {@link AskClassMembers} — every station that carries an ask's class
654
+ * spells it through this, so the word and the set cannot be read from two derivations. The array is a fresh frozen
655
+ * copy per call: an approver that holds it cannot reach the park row's copy or the next card's. */
656
+ export declare function askClassMembersOf(c: AskClassification): AskClassMembers;
657
+ /** The screen a boundary applies before it writes the pair where a host reads it back (the park row): both or
658
+ * neither, and only a well-formed pair — every member of `classes` in the closed set, no repeat, and `askClass` its
659
+ * first member in {@link ASK_CLASSES} order. The copy returned is fresh and frozen, never the caller's array. */
660
+ export declare function screenAskClassMembers(askClass: unknown, classes: unknown): AskClassMembers | undefined;
631
661
  /**
632
662
  * The DECISION half of the class derivation — what ONE ask says about itself through its own members, read
633
663
  * per contributing ask at the policy fold so a member the fold does not copy (a guard mark, the unreadable-rule
@@ -699,7 +729,7 @@ export type Arbitration = {
699
729
  classes: readonly AskClass[];
700
730
  origin?: AskOrigin;
701
731
  mandate?: PersistedRuleMandate;
702
- reason?: "rule_unreadable";
732
+ reason?: ModeReleaseReason;
703
733
  } | {
704
734
  kind: "denied_by_mode";
705
735
  mode: "bypassPermissions" | "dontAsk";
@@ -11,15 +11,16 @@ export function askOriginOf(decision, facts) {
11
11
  return origin;
12
12
  }
13
13
  const TIGHTEN_ONLY_ORIGINS = new Set(["shell_gate_tighten", "safety_tighten"]);
14
- export function stampAskOrigin(decision, facts) {
14
+ export function stampAskOrigin(decision, facts, classification) {
15
15
  const origin = askOriginOf(decision, facts);
16
+ const classed = askClassMembersOf(classification);
16
17
  const { mandate: _selfDeclared, ...unstamped } = decision;
17
18
  const clearance = ruleClearanceOf(unstamped, facts.mandateMarks);
18
19
  const mandated = { ...unstamped, ...(clearance.mandated ? { mandated: true } : {}), ...(clearance.mandate !== undefined ? { mandate: clearance.mandate } : {}) };
19
20
  if (mandated.probeReadRootCandidate === undefined || TIGHTEN_ONLY_ORIGINS.has(origin))
20
- return { ...mandated, origin };
21
+ return { ...mandated, origin, ...classed };
21
22
  const { probeReadRootCandidate: _withdrawn, ...kept } = mandated;
22
- return { ...kept, origin };
23
+ return { ...kept, origin, ...classed };
23
24
  }
24
25
  export function ruleClearanceOf(ask, marks) {
25
26
  const marksWord = persistedRuleMandateOf({ ...marks, probeMandated: ask?.probeMandated === true });
@@ -215,6 +216,23 @@ export const MODE_ARBITRATION = {
215
216
  };
216
217
  export const RRA_EXPLAINERS = new Set(["unverifiable_delete", "transcript_write", "org_rule", "governance_unavailable", "rule_unreadable", "denial_limit_fallback", "ancestor", "artifact_shared_read"]);
217
218
  export const RELEASABLE_ASK_CLASSES = ASK_CLASSES.filter((c) => !MODE_ARBITRATION[c].bypassImmune);
219
+ export const MODE_RELEASE_REASONS = ["rule_unreadable"];
220
+ const MODE_RELEASE_REASON_SET = new Set(MODE_RELEASE_REASONS);
221
+ export function isModeReleaseReason(v) {
222
+ return MODE_RELEASE_REASON_SET.has(v);
223
+ }
224
+ export function askClassMembersOf(c) {
225
+ return { askClass: c.askClass, classes: Object.freeze(ASK_CLASSES.filter((k) => c.classes.has(k))) };
226
+ }
227
+ export function screenAskClassMembers(askClass, classes) {
228
+ if (!isAskClass(askClass) || !Array.isArray(classes) || classes.length === 0 || !classes.every(isAskClass))
229
+ return undefined;
230
+ const held = new Set(classes);
231
+ const ordered = ASK_CLASSES.filter((k) => held.has(k));
232
+ if (ordered.length !== classes.length || ordered[0] !== askClass)
233
+ return undefined;
234
+ return { askClass, classes: Object.freeze(ordered) };
235
+ }
218
236
  export function decisionAskClassesOf(d) {
219
237
  const out = new Set();
220
238
  if (d.guard === "unverifiable_delete")
@@ -339,6 +357,7 @@ export function arbitrateByMode(mode, c, disclosure, call) {
339
357
  return { kind: "denied_by_mode", mode, askClass: c.askClass, classes };
340
358
  if (c.row.bypassImmune)
341
359
  return { kind: "ask" };
360
+ const reason = [...MODE_RELEASE_REASON_SET].find((r) => has(r));
342
361
  return {
343
362
  kind: "released",
344
363
  mode,
@@ -346,7 +365,7 @@ export function arbitrateByMode(mode, c, disclosure, call) {
346
365
  classes,
347
366
  ...(disclosure?.origin !== undefined ? { origin: disclosure.origin } : {}),
348
367
  ...(disclosure?.mandate !== undefined ? { mandate: disclosure.mandate } : {}),
349
- ...(has("rule_unreadable") ? { reason: "rule_unreadable" } : {}),
368
+ ...(reason !== undefined ? { reason } : {}),
350
369
  };
351
370
  case "acceptEdits":
352
371
  return call?.fileCommand === true && c.classes.size === 1 && c.classes.has("ordinary") ? { kind: "accepted", mode } : { kind: "ask" };
@@ -834,6 +834,16 @@ export type PendingAction = {
834
834
  * Written only when it is a member of the set; a pre-existing row reads absent (unreported).
835
835
  * Display metadata — the resume belts keep reading the gate's own bit. */
836
836
  origin?: import("./ask-origin.js").AskOrigin;
837
+ /** (additive, same precedent) — the PARK twin of `AskRequest.askClass`: the parked ask's class, the first member of
838
+ * {@link classes}, from the same classification the gate arbitrated it by — so a durable approval seat can answer by
839
+ * class exactly as a synchronous `onAsk` does. Written by the park mint only as a well-formed pair with
840
+ * {@link classes} (both or neither). A row parked by an engine before this member existed reads ABSENT
841
+ * (unclassified), and a resume does not fill it in: a seat answering by class treats absence as a class it will
842
+ * not answer. Display / seat metadata — no gate, CAS or resume path reads it. */
843
+ askClass?: import("./ask-origin.js").AskClass;
844
+ /** The PARK twin of `AskRequest.classes`: every class of the parked ask, in `ASK_CLASSES` order
845
+ * (`classes[0] === askClass`). Present exactly when {@link askClass} is. */
846
+ classes?: readonly import("./ask-origin.js").AskClass[];
837
847
  /**
838
848
  * design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a
839
849
  * SHA-256 (hex) via {@link import("./canonical-json.js").boundInputHashOf}, computed ONCE here at
@@ -1125,7 +1135,7 @@ export interface CheckpointState {
1125
1135
  * revive the mode where the redeeming deployment denies it, and an `auto` row arms only where a face is
1126
1136
  * wired. Absent on a task that declared nothing and inherited nothing (`default`). Rows written before this
1127
1137
  * member carried `autoModeRequested` / `approverPosture` / a doctrine `shellGate`; the resume leg migrates
1128
- * them once, announced (`resume.legacy_mode_seed_migrated`), and the next suspend writes this member. */
1138
+ * them once, announced (`resume.legacy_mode_seed_migrated`), and the next suspend writes this member. The migration is kept through 7.37.x and removed in 7.38.0, which refuses a row still carrying these keys. */
1129
1139
  permissionMode?: PermissionMode;
1130
1140
  /** Beside a recorded `plan`: the declared word the leg had when a steer or the model's `EnterPlanMode` moved it into
1131
1141
  * plan — the mode an approved or edited `plan_review` resumes under ({@link import("./mode-policy.js").modeRequestAfterResume}). */
@@ -3044,7 +3054,7 @@ export interface LegacyModeSeedMigration {
3044
3054
  * bypass);
3045
3055
  * - `shellGate`: absent ⇒ none; `"always"` ⇒ kept (a live key, not a retired encoding); `"off"` / `"classify"` ⇒
3046
3056
  * dropped (no floor); anything else ⇒ refused (`resume.legacy_mode_seed`).
3047
- * Idempotent: a row the resume leg writes back carries `permissionMode` and the floor only, so a second read migrates
3048
- * nothing. Migration precedes any retired-key door, so a window row is read rather than refused.
3057
+ * Idempotent: a row the resume leg writes back carries `permissionMode` and the floor only, so a second read migrates nothing ("once" is per row).
3058
+ * Migration precedes any retired-key door, so a window row is read rather than refused. The window: this migration is kept through 7.37.x and removed in 7.38.0, which refuses a row still carrying these keys — resume or abandon a row parked before 7.34.0 before upgrading to 7.38.0.
3049
3059
  */
3050
3060
  export declare function migrateLegacyModeSeed(gate: object | undefined): LegacyModeSeedMigration;