@sema-agent/core 7.35.2 → 7.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/dist/brain/anthropic.js +6 -6
  3. package/dist/brain/errors.d.ts +1 -14
  4. package/dist/brain/errors.js +2 -6
  5. package/dist/brain/open-responses.js +7 -7
  6. package/dist/brain/openai.js +7 -7
  7. package/dist/brain/provider-echo-shape.d.ts +26 -0
  8. package/dist/brain/provider-echo-shape.js +12 -0
  9. package/dist/brain/tool-call-id.d.ts +27 -15
  10. package/dist/brain/tool-call-id.js +43 -0
  11. package/dist/core/ask-origin.d.ts +35 -5
  12. package/dist/core/ask-origin.js +23 -4
  13. package/dist/core/ask-question.d.ts +0 -30
  14. package/dist/core/ask-question.js +3 -2
  15. package/dist/core/checkpoint-store.d.ts +18 -6
  16. package/dist/core/checkpoint-store.js +2 -1
  17. package/dist/core/context-edit.d.ts +4 -0
  18. package/dist/core/context-edit.js +5 -2
  19. package/dist/core/decision-receipt.d.ts +21 -0
  20. package/dist/core/decision-receipt.js +63 -0
  21. package/dist/core/engine-notice.d.ts +15 -5
  22. package/dist/core/gate-fold.js +2 -1
  23. package/dist/core/gate-lanes.js +20 -23
  24. package/dist/core/gate-outcome.d.ts +7 -5
  25. package/dist/core/gate-outcome.js +5 -1
  26. package/dist/core/governance-codes.d.ts +1 -1
  27. package/dist/core/governance-codes.js +2 -0
  28. package/dist/core/hooks.d.ts +14 -2
  29. package/dist/core/hooks.js +7 -3
  30. package/dist/core/mode-policy.js +2 -1
  31. package/dist/core/park-selfcheck.js +2 -0
  32. package/dist/core/permission-rule-model.d.ts +2 -2
  33. package/dist/core/permission-rule-model.js +2 -2
  34. package/dist/core/permission-rules.js +2 -5
  35. package/dist/core/runner/active-skill-scope.js +5 -5
  36. package/dist/core/runner/decide-continuation.js +2 -1
  37. package/dist/core/runner/denial-limit-arms.d.ts +28 -18
  38. package/dist/core/runner/denial-limit-arms.js +11 -7
  39. package/dist/core/runner/inherited-ask-grants.d.ts +32 -31
  40. package/dist/core/runner/inherited-ask-grants.js +5 -0
  41. package/dist/core/runner/permission-rule-lanes.d.ts +3 -0
  42. package/dist/core/runner/permission-rule-lanes.js +7 -6
  43. package/dist/core/runner/prepare-context-lane.d.ts +4 -0
  44. package/dist/core/runner/prepare-context-lane.js +7 -5
  45. package/dist/core/runner/prepare-gate-stations.js +17 -12
  46. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  47. package/dist/core/runner/prepare-park-ask.d.ts +4 -2
  48. package/dist/core/runner/prepare-park-ask.js +8 -11
  49. package/dist/core/runner/prepare-policy-chain.d.ts +5 -8
  50. package/dist/core/runner/prepare-policy-chain.js +50 -31
  51. package/dist/core/runner/prepare-safety-scan.js +9 -2
  52. package/dist/core/runner/prepare-task.js +3 -3
  53. package/dist/core/runner/resume-apply.js +2 -1
  54. package/dist/core/runner/run-attachment-seats.js +3 -1
  55. package/dist/core/runner/run-harness-handlers.js +6 -1
  56. package/dist/core/runner/run-leg.js +11 -9
  57. package/dist/core/runner/run-recovery-lanes.js +2 -0
  58. package/dist/core/runner/session-rule-policy.js +5 -4
  59. package/dist/core/runner/tool-call-id-fold-seat.d.ts +13 -0
  60. package/dist/core/runner/tool-call-id-fold-seat.js +31 -0
  61. package/dist/core/sensitive-path-policy.js +2 -2
  62. package/dist/core/side-query.d.ts +5 -1
  63. package/dist/core/side-query.js +5 -1
  64. package/dist/core/store-contracts/checkpoint-store-contract.js +18 -0
  65. package/dist/core/tool-name-readings.d.ts +370 -0
  66. package/dist/core/tool-name-readings.js +137 -0
  67. package/dist/core/tool-policy.d.ts +79 -89
  68. package/dist/core/tool-policy.js +86 -49
  69. package/dist/core/tool-registry.d.ts +1 -31
  70. package/dist/core/tool-registry.js +2 -22
  71. package/dist/core/tool-result-budget.d.ts +3 -0
  72. package/dist/core/tool-result-budget.js +5 -3
  73. package/dist/core/tool-roster.d.ts +30 -9
  74. package/dist/core/tool-roster.js +45 -5
  75. package/dist/engine/llm/types.d.ts +4 -0
  76. package/dist/index.d.ts +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/tools/fs/file-read-check.d.ts +1 -1
  79. package/dist/tools/fs/file-read-check.js +4 -3
  80. package/dist/tools/fs/shell-read-check.d.ts +15 -1
  81. package/dist/tools/fs/shell-read-check.js +5 -2
  82. package/package.json +2 -1
  83. package/test/export-surface.snapshot.json +7 -1
  84. package/wire-changes/core@7.36.0.json +288 -0
  85. package/wire-changes/core@7.37.0.json +405 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,145 @@
1
+ ## 7.37.0 — 2026-10-01
2
+
3
+ **Closed sets, one by one.**
4
+ - `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`).
5
+ - #1214: no closed set moves — the pair's words are `AskClass` (`ASK_CLASSES`), already a package export.
6
+ - #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).
7
+
8
+ **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.
9
+
10
+ **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).
11
+
12
+ ### 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)
13
+
14
+ - **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`.
15
+ - **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`.
16
+ - **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.
17
+ - 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).
18
+ - Pins: `test/backlog1212-mode-release-reasons.test.ts`:
19
+ - 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.
20
+ - R2: every reason is a releasable class.
21
+ - R3: the predicate.
22
+ - 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.
23
+ - 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.
24
+ - 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).
25
+
26
+ ### 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)
27
+
28
+ - **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.
29
+ - **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.
30
+ - **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.
31
+ - 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).
32
+
33
+ ### 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)
34
+
35
+ - **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.
36
+ - **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.
37
+ - **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.
38
+ - **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.
39
+ - **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`.
40
+ - 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`.
41
+ - 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.
42
+
43
+ #### 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)
44
+
45
+ - **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.
46
+ - **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.
47
+ - **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.
48
+ - 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).
49
+ - 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).
50
+
51
+ #### 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)
52
+
53
+ - **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.
54
+ - **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).
55
+ - **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).
56
+ - **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.
57
+ - 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.
58
+ - 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).
59
+
60
+ ### A decision's action is a closed set (#1221; behavior: tighten; +0 exports, +0 codes, +0 wire keys) (@server @test)
61
+
62
+ - **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`).
63
+ - **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.
64
+ - **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.
65
+ - **Compensation.** Return `{ action: "allow" | "deny" | "ask" }`, spelled exactly.
66
+ - 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.
67
+ - 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).
68
+
69
+ #### 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)
70
+
71
+ - **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.
72
+ - **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.
73
+ - **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.
74
+ - **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.
75
+ - 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.
76
+ - 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).
77
+
78
+ ### Documentation
79
+
80
+ - 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.
81
+
82
+ ## 7.36.0 — 2026-09-30
83
+
84
+ **Hard-breaking changes — the whole release in three sentences.**
85
+
86
+ ① *Who is affected.* (design/1209) A host whose PostToolUse / PostToolUseFailure hook (or hook matcher) keys on an alias spelling (PostToolBatch is untouched: its entries keep the spelling, as the reference's do); a deployment that declares one tool under the names of two engine tools (any two engine tool names count, seat-bearing or not: `Custom{aliases:["NotebookEdit","Write"]}`, `NotebookEdit{aliases:["Write"]}`, `Read{aliases:["Bash"]}` — a tool's own `pathTarget` does not exempt it); a caller tool that declares a low irreversibility tier while wearing `Bash` / `Monitor` as an alias; a caller tool that claims an engine hand tool's name as an alias (`Charge{effect:"write", aliases:["Read"]}`); a reader of a park row's `names` order or of a resumed call's `tool_start` / `tool_end` name. (#1207) A host, tool or store that keys on a tool-call id and is served by a gateway whose ids fall outside visible ASCII 1–128 or contain `~` (it now receives the engine's fold, not the provider's bytes, on every frame, card, park row and transcript line); a tool that reads the transcript's `ToolCall.id` as the provider's own id; a host replaying a side query's returned calls to a provider; a consumer with a closed table of notice codes (server / cli / client-core: +1 `route.tool_call_id_folded`).
87
+
88
+ ② *How to change.* (design/1209) Match PostToolUse / PostToolUseFailure on the tool's wire name (during a mixed-version rollout, list both the wire name and the alias); leave a PostToolBatch matcher on the spelling (its entries do not move); keep ONE engine tool's names on a tool (drop the second alias); drop the `Bash` / `Monitor` alias from a tool that must not sit behind the engine's shell seat, or accept the seat; do not claim an engine hand tool's name as an alias; read a park row's `names` as "the spelling first", and pair a resumed call's frames by `toolCallId`. (#1207) Key everything on the engine id (`ToolCall.id`, `toolCallId` on every frame, card and row); read the provider's spelling from `ToolCall.wireId` / `SideQueryResult.toolCalls[].wireId` (present only when the two differ) and hand the provider `wireId ?? id` (replaying through another side query: carry both on the `toolCall` block, key the result by `id`); add the notice row (`operator`).
89
+
90
+ ③ *If you do not.* (design/1209) An alias matcher on PostToolUse / PostToolUseFailure no longer fires (post-execution face only — no decision is widened); a declaration answering to two engine tools refuses the run at prepare with `config.tool_face_invalid` (no turn runs; a call parked under such a tool stays parked until the declaration changes); the shell seat's question now reaches a low-tier tool that wears `Bash` (`default` 0 → 1 card, `dontAsk` ran 1 → 0); an interrupted call spelled a claimed hand tool's name reads the conservative "confirm with the user before repeating it" wording, and the faces that admit a call by its declared effect under that name treat it as the less repeat-safe effect. (#1207) Nothing breaks for an id in shape: every `toolu_…` / `call_…` / uuid id is admitted verbatim (no fold, no `wireId`, no notice), so every face and every request is byte-identical to v7.35.1's; for a folded id the provider is still handed its own spelling for the call, its result, an image carrier's label and a dropped-call note (only engine handles minted from the call id — an offloaded result's `ref` — are minted from the fold); a host that replays a side query's folded call to a provider under `id` alone hands it the fold, not its own id; a notice table missing the row reads the code as an unknown one.
91
+
92
+ **Closed sets, one by one.**
93
+ - `ENGINE_NOTICE_CODES` / `EngineNoticeCode` (#1207): +`route.tool_call_id_folded` (audience `operator` in `NOTICE_AUDIENCE`).
94
+ - design/1209: no closed set moves — its prepare refusal is `config.tool_face_invalid`, already in `TOOL_DECLARATION_CODES`.
95
+
96
+ **Package exports** (`npm run handoff:diff v7.35.1 HEAD`): +0 / −0. Four public types gain one optional member each: `ToolCall.wireId?: string`, `SideQueryResult.toolCalls[].wireId?: string` (#1207, one meaning: the provider's spelling, present only on a fold); `AggregateBudgetOptions.toolNameOf?`, `ContextEditOptions.toolNameOf?` (design/1209; absent ⇒ the recorded name, as before). The wire list carries three of the four (`ToolCall` +`wireId`, `AggregateBudgetOptions` / `ContextEditOptions` +`toolNameOf`, each optional); the fourth sits on an inline array element the list does not read — stated here by hand. design/1209's read ports and table (`src/core/tool-name-readings.ts`) are not exported; `ruleNamesOf` stays exported (now declared beside the ports); the retired call-site helpers (`callAnswersTo`, `toolNameSetHas`, `anyNamed`, `firstNamed`) were never package exports. #1207's `admitToolCallId` and the fold are internal (`src/brain/tool-call-id.ts`).
97
+
98
+ **The wire list's BREAKING items** (`node scripts/wire-changes.mjs v7.35.1 HEAD --version 7.36.0`), one per line:
99
+ - none — 0 BREAKING items. The non-breaking moves it carries: closed set gained a member `ENGINE_NOTICE_CODES` +`route.tool_call_id_folded` (#1207); a new module-level closed set `ToolCallIdFoldReason` (`charset` / `length` — the notice detail's `reason`, #1207; not a package export); type gained optional keys `ToolCall` +`wireId` (#1207), `AggregateBudgetOptions` +`toolNameOf`, `ContextEditOptions` +`toolNameOf` (design/1209).
100
+ - not on the wire list (an inline array element): `SideQueryResult.toolCalls[]` +`wireId?` (#1207) — stated here by hand.
101
+ - value moves on existing faces the list cannot read, from the criteria: F1 (the tier seat), F2 (the post hooks' name), F3 (the declaration refusal), F4 (a park row's `names` order, a resumed call's frame name), F5 / F6 (the effect and result-name readings), F7 (the declaration-time effect table), F8 (a folded tool-call id on every engine face).
102
+
103
+ ### Every per-call site that resolves a call's names reads them one declared way — one table, four readings, typed ports (display and hint sites that only echo the spelling stay outside it: KL-1209-h) (design/1209; behavior (tighten, and the post-execution hooks' name); +0 exports, +2 optional option members (`AggregateBudgetOptions.toolNameOf`, `ContextEditOptions.toolNameOf`), +0 codes, +0 wire keys) (@server @cli @test)
104
+
105
+ - **Before (v7.35.0).** The gate resolved a call's name to the wire name, and each of 41 sites keyed on an engine tool name decided on its own whether to read that name or every name the call answers to — in a comment, and in which of five helpers it called; five sites compared the call's name with an engine name literally. The same review round found three defects of that family.
106
+ - **Now.** `src/core/tool-name-readings.ts` holds ONE table (`NAME_READINGS`, 56 rows: the design's 41, 12 its program scan found, 1 the release review found (the batch hook entry, a spelling row), 2 the review round found) giving each site one of four readings — `any` (a tighten held under every name the call answers to; a set-valued table read as the union), `seat` (a multi-key seat read under ONE name: `tier` = the name whose irreversibility tier is strictest, `member` = the first name the table holds), `wire` (a grant or a routing: the wire name only), `spelling` (a record of what the model said; no port) — and the ports `heldBy` / `heldValues` / `seatOf` / `wireOf`, which take a site id typed by the table's kind: a site read through another kind's port does not compile. `gate:name-readings` (in `gate:all`) reads the program for the ways around the ports: a name-keyed table read outside them, a call's name compared with an engine name without `wireOf`, a site id spelled twice.
107
+ - **The tier seat is the strictest name, with the source band.** *Before:* the gate, the rule-offer door, the park row's risk descriptor and an ask's `riskAxes` read the seat under the FIRST of the call's names holding a tier — `MyShell{irreversibility:"never", aliases:["Bash"]}` called as `MyShell` read `never` and never met the engine's `Bash` seat. *Now:* the seat is the name whose tier is strictest (`never < maybe < always`, absent lowest; ties to the earlier name); its probe and its shell-seat provenance are read under that name; the four sites choose the same name. `shellGated` holds only when no name the call answers to carries a deployment mark (the gate's mark sources are now the union over the call's names). *Who is hurt:* a caller tool that declares a low tier and wears `Bash` / `Monitor`: `rm -rf <root>/y` under `default` asks (0 → 1), under `dontAsk` is refused (ran 1 → 0).
108
+ - **PostToolUse / PostToolUseFailure receive the wire name** (the reference: the post hook is passed the resolved tool's name). *Before:* they received the model's spelling while PreToolUse received the wire name. *Now:* both receive the wire name. **PostToolBatch keeps the spelling** — deliberately, as the reference does (its PostToolBatch payload maps each tool_use block's own name, while its PostToolUse is passed the resolved tool): the batch entry is now a table row (`hook.postToolBatch`, spelling), so the gate sees it — the release review found the projection outside the table; `tool_start` / `tool_end`, the transcript and the activity stream keep the spelling. *Who is hurt:* a post hook or matcher written against an alias spelling (see ①–③).
109
+ - **One order of a call's names; the park row carries the gate's.** *Before:* the gate's face listed the spelling first, the park row's `names` the wire name first. *Now:* the park row copies the names of the face the gate judged the call under (the spelling first); a resume re-mints its face under that recorded spelling only while it still resolves to the row's own tool — a spelling that disappeared, or that another tool now owns, gives the row's wire name (never another tool's face). A resumed call's `tool_start` / `tool_end` carry the recorded spelling (`names[0]`, else the row's name). A row parked by 7.34.0 or earlier (no `names`) reads as before (KL-1194-a); a row parked by 7.35.0 carries the wire name first, which re-mints as it did.
110
+ - **The write verification reads the tool's effect under the wire name** (live and on the resume seed); the orphan reconcile keeps reading the recorded name.
111
+ - **A result's budget exemption and its clearability are judged on the tool the call ran.** *Before:* the per-turn result budget exempted a result whose RECORDED name was in the catalog's exempt set (`Read`), and the micro-compaction pass cleared a result whose recorded name was compactable — so a caller tool wearing `Read` called as `Read` escaped the budget (a 300 000-character result stayed whole) while the same tool called under its own name was capped. *Now:* the Runner hands both passes the roster's resolution (`toolNameOf`), and the set is read under the wire name — the engine's own `Read` stays exempt, a tool wearing its name is capped under either spelling; clearing likewise reaches only the engine's compactable tools. A host calling `capAggregateToolResults` / `clearStaleToolResults` without `toolNameOf` reads the recorded name as before.
112
+ - Pins: `test/name-readings-oracle.test.ts` (the table deep-equals the frozen oracle `test/fixtures/name-readings.oracle.json`; every row has a cell judged by the ORACLE's kind — site / real-lane / port cells; the four compile-time bind pins); `test/name-readings-behavior.test.ts` (the four seat sites agree, the tier counterexample on a real lane, the source band, the three fates of a parked alias, the post hooks' name); `test/name-readings-gate.test.ts` (the gate on the tree, and every way around the ports planted in memory — red at its line). Mutations D-a…D-j (D-g is the declaration refusal of the BREAKING section below; D-k is the effect table's): `.gate-logs/criteria-7.36.0-mutations.md`, its design/1209 part.
113
+
114
+ ### An interrupted call is never reconciled as safe to repeat on a hand tool's word when a caller tool claims that name (pre-existing, fixed in this release; design/1209 review round 2; behavior (tighten); +0 exports) (@test)
115
+
116
+ - **Before (≤ v7.35.0).** The declaration-time effect table first recorded a caller tool's effect under each alias it claims, then registered the hand band's effects unconditionally over them: `Charge{effect:"write", aliases:["Read"]}` with the engine's `Read` excluded filed `Read` as `read`, so a `Read`-spelled call to `Charge` interrupted mid-run was reconciled as "read-only/idempotent … simply call it again" — a write the model was told to repeat.
117
+ - **Now.** A name a caller tool claims as an alias AND a hand tool owns records the LESS repeat-safe of the two effects (`write` over `idempotent` over `read`; a claimer that declares none is a write), whichever tool the roster later dispatches it to: the interrupted call reads the UNKNOWN wording ("confirm with the user before repeating it"). A name no caller claims keeps the hand's own effect. *Who is hurt:* where a caller tool also claims an engine hand tool's name as an alias — an interrupted call spelled that name reads the conservative wording, and the faces that admit a call by its declared effect under that name (a session's `allowDirs`, a skill's `allowPaths`, the final-verification seat) treat it as the less repeat-safe effect (the same over-conservative direction the effect default already takes) — only where the name's mount does not re-register its effect: with the engine's own hand tool mounted under that name, its mount re-registers the hand's effect and those three faces read it (the conservative wording then reaches only the interrupted-call note). Compensation: do not claim an engine hand tool's name as an alias.
118
+ - Pin: `test/name-readings-behavior.test.ts` (the declaration-time effect table block); mutation D-k.
119
+
120
+ ### BREAKING (declaration) — a tool whose names belong to two engine tools is refused at prepare (design/1209 §3, #1208; `config.tool_face_invalid`, an existing code; +0 exports) (@server @test)
121
+
122
+ - **Before (v7.35.0).** `Custom{aliases:["NotebookEdit","Write"]}` declaring no path target was judged on the slot of the name it was called by — called as `NotebookEdit` with the denied path in `file_path`, it ran (KL-1194-d); `Read{aliases:["Bash"]}` joined only the first read station its names met.
123
+ - **Now.** The roster counts, per row, the engine tools its RESOLVED names belong to (its wire name and the aliases that resolve to it: an alias another row owns is not counted; several names of one engine tool — `ListAgents` / `ListPeers` — count once). Two or more ⇒ prepare fails with `config.tool_face_invalid`, before any turn and before a resume's identity comparison. A tool that declares its own `pathTarget` under two engine names is refused too (the literal form). `Custom{aliases:["Read","Bash"]}` beside the engine's mounted `Read` is not refused: `Read` resolves to the engine's row.
124
+ - *Who is hurt / compensation:* see ①–③ above — keep one engine tool's names.
125
+
126
+ ### A provider's tool-call id is admitted once, where the brain builds the call: in shape it is the key, out of shape it is folded — the provider always gets its own id back (#1207; behavior (a folded id on every engine face) + closed set +1 notice code; +0 exports, +2 optional type members, one meaning (`ToolCall.wireId`, `SideQueryResult.toolCalls[].wireId`)) (@server @cli @client-core @test)
127
+
128
+ - **Before (v7.35.1).** The three built-in brains copied the provider's id into `ToolCall.id` as sent. An id outside the provider echo shape — a control sequence (`call_\u001b]0;…\u0007`), a non-ASCII unit, a space, 129+ characters — became the key of every engine face (tool frames, approval cards and askIds, the tool-result join, the park row, the transcript) and reached the wire as upstream bytes. Only a missing id had a rule (the lane's fallback).
129
+ - **Now.** One admission, `admitToolCallId(raw, prefix)` (`src/brain/tool-call-id.ts`), at the one place each brain builds a call (anthropic `closeToolUseBlock`, openai `closeToolCallAccum`, open-responses `closeFunctionCall`), returning two ids:
130
+ - absent (a non-string or `""`) ⇒ the lane's fallback (`toolu_…` / `call_…`), both ids — unchanged;
131
+ - admitted — visible ASCII 0x21–0x7E, 1–128 (`isProviderEchoShape`), and no `~` ⇒ both ids are the raw string — unchanged;
132
+ - otherwise FOLDED ⇒ the engine id is a readable base (the raw's first 32 code units, each one outside the admitted alphabet written `-`) + `~` + 32 hex of sha256 over the raw's UTF-16 code units — always in shape, always carrying one `~`, which the admitted arm refuses, so a fold never equals an admitted id; two different raws fold apart (lone surrogates included); one raw folds to one id in every lane and every run. The raw rides beside it as `ToolCall.wireId`.
133
+ - **The provider always gets its own id.** Each brain hands a call back under `wireId ?? id` and a result under the `wireId` of the call it answers, read off the same request's messages (no side table): anthropic `tool_use.id` / `tool_result.tool_use_id`, openai `tool_calls[].id` / `tool_call_id`, open-responses `function_call.call_id` / `function_call_output.call_id`. `wireId` is persisted with the assistant message, so a resumed run — in another process, after a compaction — hands back the same spelling and never folds again; a row parked by v7.35.x (the raw id as the key, no `wireId`) resumes under that raw id.
134
+ - **A side query's tool calls carry the provider's spelling too.** `SideQueryResult.toolCalls[]` gains an optional `wireId` — present only when it differs from `id`, read from the same `ToolCall.wireId`. The replay rule: hand the provider `wireId ?? id`; replaying through another side query, carry both on the `toolCall` block and key the result by `id` (the brain then hands the provider `wireId`). Absent ⇒ `id` is the provider's own. (@server: the S-889 side-query consumer.)
135
+ - **One notice per run**, `route.tool_call_id_folded` (operator), on the first folded call: `detail: { toolCallId, rawLength, reason: "charset" | "length", sessionId, runId }` — the engine id, never the raw bytes. Read off `ToolCall.wireId` by a runner seat, so it is the same reading for every brain.
136
+ - **Model-facing text names the provider's spelling too.** The label of an image carried beside a tool result (openai / open-responses: `[Image from tool result <id>]`) names the result's provider id; the note a brain adds for a dropped call (a call with no name: `id="…"`, replayed to the provider as assistant text) keeps naming the id as the provider sent it (unchanged).
137
+ - `PROVIDER_ECHO_SHAPE` / `isProviderEchoShape` move to a leaf module (`src/brain/provider-echo-shape.ts`, re-exported from `errors.ts` as before) so the admission stays a leaf; the constant, the predicate and the package exports are unchanged.
138
+ - **Who is hurt.** A consumer that relied on seeing a gateway's out-of-shape bytes as the tool-call id on a frame, card, row or transcript line now sees the fold; the bytes are on `ToolCall.wireId` in the transcript (upstream bytes, never sanitized — render `id`). A host `Brain` is not admitted: its ids enter as it wrote them (KL-1207-a). Compensation: the notice, the deterministic fold (one raw, one id — joinable across runs), and the provider face: every provider-minted id the request carries is the provider's own spelling.
139
+ - Pins: `test/backlog1207-tool-call-id-admission.test.ts` — a real Runner over each of the three brains, the provider a fetch stub, reading frames, notices, the persisted transcript and the request bodies: G1 admitted ids (four shapes) identical on every face, no notice; G2 five out-of-shape shapes folded in shape on every engine face, handed back as sent, one notice per run (two folds, one notice), deterministic across runs; G3 the fallback unchanged; G4 two 129-character ids differing in their last two characters fold apart and both join; G5 a folded and an admitted call side by side — the request never names the fold; a folded call beside a nameless one — the replayed drop note names the provider's spelling; a result carrying an image — the carrier's label names the provider's id; G6 a folded call parked, the process gone, a fresh Runner over the same files resumes it: the call executes under the parked engine id and the provider gets its own id back for the call and its result, no notice again; G6b a row parked with the raw id as its key resumes under it; G6c a compaction between the resumed call and the next request keeps the parked call verbatim (a tool result is never a cut point, so a call and its result are kept or summarized together) and the next request still hands the provider its own id. `test/backlog1207-tool-call-id-unit.test.ts` — U1 the three arms and both fold reasons; U2 injectivity (past the readable base; lone surrogates vs U+FFFD), disjoint arms, determinism, lane independence; U3 the outbound reading; U4 the notice seat (once per run; a host brain's in-shape `wireId` is not a fold); U5 the drop note on all three lanes names the provider's spelling; U6 a side query over the openai brain returns `{ id: <fold>, wireId: <raw> }`, and replaying it through a second side query hands the provider the raw for the call and its result (an admitted id: no `wireId` key). v7.35.1 (2c39696fe) on the first file: G2, G4, G6 red (24), G1, G3, G5 green (21).
140
+ - Mutations (`.gate-logs/criteria-7.36.0-mutations.md`, its #1207 part, per fixture): D970 no fold; D971 / D972 a call / a result handed back under the engine id; D973 the fold without its digest; D974 a fold salted per call; D975 `wireId` kept only in memory; D976 a notice per call; D977 no notice; D978 `~` admitted verbatim; D979 the drop note names the fold; D980 the image carrier's label names the engine id; D981 the admitted arm removed; D982 the empty string folds; D983 the side-query projection drops `wireId`.
141
+ - Known limits: KL-1207-a (a host brain's ids are not admitted). KL-1207-b (a side query returned only the fold) closed in this entry by `SideQueryResult.toolCalls[].wireId`.
142
+
1
143
  ## 7.35.2 — 2026-09-29
2
144
 
3
145
  ### A wait the watchdog cuts at the 30 s disclosure threshold is disclosed before the cut (#746; behavior (status frames: +1 `waiting_first_token` frame per attempt when a first-token or first-byte window is exactly the 30 s threshold); +0 exports, +0 wire keys, +0 codes, closed sets unchanged) (@cli @client-core @server @test)
@@ -3,7 +3,7 @@ import { BrainError } from "./errors.js";
3
3
  import { startsWithInputTooLongAnchor } from "./input-too-long.js";
4
4
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
5
5
  import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
6
- import { mintFallbackToolCallId } from "./tool-call-id.js";
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";
9
9
  import { ANTHROPIC_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, takeHeaderCasefold } from "./request-params.js";
@@ -58,6 +58,7 @@ function toAnthropicMessages(ctx, model) {
58
58
  raw.push({ role: "user", content: pendingDocs });
59
59
  pendingDocs = [];
60
60
  };
61
+ const resultWireId = providerResultIds(ctx.messages);
61
62
  for (const m of ctx.messages) {
62
63
  if (m.role !== "toolResult")
63
64
  flushDocs();
@@ -84,7 +85,7 @@ function toAnthropicMessages(ctx, model) {
84
85
  blocks.push({ type: "text", text: c.text });
85
86
  }
86
87
  else if (c.type === "toolCall") {
87
- blocks.push({ type: "tool_use", id: c.id, name: c.name, input: c.arguments ?? {} });
88
+ blocks.push({ type: "tool_use", id: providerToolCallId(c), name: c.name, input: c.arguments ?? {} });
88
89
  }
89
90
  }
90
91
  raw.push({ role: "assistant", content: blocks.length ? blocks : [{ type: "text", text: NO_CONTENT }] });
@@ -132,7 +133,7 @@ function toAnthropicMessages(ctx, model) {
132
133
  content: [
133
134
  {
134
135
  type: "tool_result",
135
- tool_use_id: m.toolCallId,
136
+ tool_use_id: resultWireId(m.toolCallId),
136
137
  content: inner,
137
138
  ...(m.isError ? { is_error: true } : {}),
138
139
  },
@@ -220,9 +221,8 @@ function closeToolUseBlock(acc) {
220
221
  const args = parseStreamedToolArgs(acc.toolJson);
221
222
  if (args === undefined)
222
223
  return undefined;
223
- if (!acc.toolId)
224
- acc.toolId = mintFallbackToolCallId("toolu_");
225
- acc.closedTc = { type: "toolCall", id: acc.toolId, name: acc.toolName, arguments: args };
224
+ const { engineId, wireId } = admitToolCallId(acc.toolId, "toolu_");
225
+ acc.closedTc = { type: "toolCall", id: engineId, ...(wireId !== engineId ? { wireId } : {}), name: acc.toolName, arguments: args };
226
226
  return acc.closedTc;
227
227
  }
228
228
  export function createAnthropicBrain(config = {}) {
@@ -70,19 +70,7 @@ export declare class BrainError extends Error {
70
70
  apiError?: boolean;
71
71
  });
72
72
  }
73
- /**
74
- * The shape a provider ECHO field must have to be carried: the request id a provider states on a response
75
- * header (`AssistantMessage.requestId`, projected as `TaskResult.apiFailure.requestId`) and the model name it
76
- * reports answering (`AssistantMessage.responseModel`). Both are UPSTREAM BYTES that end up persisted in a
77
- * transcript and rendered by a UI, so one alphabet governs both: visible ASCII (0x21–0x7E — no space, no
78
- * control byte, no non-ASCII), 1–128 characters. A value outside it is dropped (the field is absent), never
79
- * truncated or rewritten; the empty string is outside it. No flags, so `test` is stateless. This object is the
80
- * consumer's copy: the engine judges with its own instance built from the same source, so recompiling or
81
- * otherwise mutating this one cannot change what the engine admits.
82
- */
83
- export declare const PROVIDER_ECHO_SHAPE: RegExp;
84
- /** Does `value` fit {@link PROVIDER_ECHO_SHAPE}? A non-string answers `false` (a JS caller's number is not an echo). */
85
- export declare function isProviderEchoShape(value: string): boolean;
73
+ export { isProviderEchoShape, PROVIDER_ECHO_SHAPE } from "./provider-echo-shape.js";
86
74
  /** How the brains read one provider echo value: a non-string or the empty string states nothing (`absent` — no field,
87
75
  * nothing counted); a string in {@link isProviderEchoShape} is `carried`; any other string is `refused` (dropped and
88
76
  * counted as `echo_dropped`). */
@@ -180,4 +168,3 @@ export declare function brainErrorCodeOf(e: unknown): BrainErrorCode | undefined
180
168
  * each layer's `code:`-prefixed message and skipping layers the parent's message already contains;
181
169
  * a causeless error renders as its plain message. */
182
170
  export declare function describeNetworkError(e: unknown): string;
183
- export {};
@@ -1,5 +1,6 @@
1
1
  import { isErrorLike, readMember, thrownText } from "../core/thrown-value.js";
2
2
  import { emitBrainTelemetry } from "./status-sink.js";
3
+ import { isProviderEchoShape } from "./provider-echo-shape.js";
3
4
  export const ZERO_USAGE = {
4
5
  input: 0,
5
6
  output: 0,
@@ -31,12 +32,7 @@ export class BrainError extends Error {
31
32
  }
32
33
  }
33
34
  const REQUEST_ID_HEADERS = ["request-id", "x-request-id"];
34
- const PROVIDER_ECHO_SOURCE = "^[\\x21-\\x7E]{1,128}$";
35
- const providerEchoShape = new RegExp(PROVIDER_ECHO_SOURCE);
36
- export const PROVIDER_ECHO_SHAPE = new RegExp(PROVIDER_ECHO_SOURCE);
37
- export function isProviderEchoShape(value) {
38
- return typeof value === "string" && providerEchoShape.test(value);
39
- }
35
+ export { isProviderEchoShape, PROVIDER_ECHO_SHAPE } from "./provider-echo-shape.js";
40
36
  export function providerEchoReading(value) {
41
37
  if (typeof value !== "string" || value.length === 0)
42
38
  return "absent";
@@ -2,7 +2,7 @@ import {} from "../internal/llm.js";
2
2
  import { BrainError } from "./errors.js";
3
3
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
4
  import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
5
- import { mintFallbackToolCallId } from "./tool-call-id.js";
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";
8
8
  import { OUTPUT_CAP_KEYS, RESPONSES_RESERVED, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
@@ -80,6 +80,7 @@ function toResponsesInput(ctx, model, replayReasoning) {
80
80
  });
81
81
  pendingImages = [];
82
82
  };
83
+ const resultWireId = providerResultIds(ctx.messages);
83
84
  for (const m of ctx.messages) {
84
85
  if (m.role !== "toolResult")
85
86
  flushImages();
@@ -113,7 +114,7 @@ function toResponsesInput(ctx, model, replayReasoning) {
113
114
  if (text !== "")
114
115
  out.push({ type: "message", role: "assistant", content: [{ type: "output_text", text }] });
115
116
  for (const tc of toolCalls) {
116
- out.push({ type: "function_call", call_id: tc.id, name: tc.name, arguments: JSON.stringify(tc.arguments ?? {}) });
117
+ out.push({ type: "function_call", call_id: providerToolCallId(tc), name: tc.name, arguments: JSON.stringify(tc.arguments ?? {}) });
117
118
  }
118
119
  }
119
120
  else if (m.role === "toolResult") {
@@ -132,11 +133,11 @@ function toResponsesInput(ctx, model, replayReasoning) {
132
133
  const finalText = errorNote === undefined ? withVisionNote : withVisionNote ? `${withVisionNote}\n${errorNote}` : errorNote;
133
134
  out.push({
134
135
  type: "function_call_output",
135
- call_id: m.toolCallId,
136
+ call_id: resultWireId(m.toolCallId),
136
137
  output: functionCallOutputText(finalText, images.length > 0, m.content),
137
138
  });
138
139
  for (const img of images)
139
- pendingImages.push({ toolCallId: m.toolCallId, data: img.data, mimeType: img.mimeType });
140
+ pendingImages.push({ toolCallId: resultWireId(m.toolCallId), data: img.data, mimeType: img.mimeType });
140
141
  }
141
142
  }
142
143
  flushImages();
@@ -206,9 +207,8 @@ function closeFunctionCall(acc) {
206
207
  const args = parseStreamedToolArgs(acc.args);
207
208
  if (args === undefined)
208
209
  return undefined;
209
- if (!acc.callId)
210
- acc.callId = mintFallbackToolCallId("call_");
211
- acc.closedTc = { type: "toolCall", id: acc.callId, name: acc.name, arguments: args };
210
+ const { engineId, wireId } = admitToolCallId(acc.callId, "call_");
211
+ acc.closedTc = { type: "toolCall", id: engineId, ...(wireId !== engineId ? { wireId } : {}), name: acc.name, arguments: args };
212
212
  return acc.closedTc;
213
213
  }
214
214
  function mapTerminalStopReason(incompleteReason, hadToolCalls) {
@@ -2,7 +2,7 @@ import {} from "../internal/llm.js";
2
2
  import { repairTextToolCalls } from "./tool-call-repair.js";
3
3
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
4
  import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
5
- import { mintFallbackToolCallId } from "./tool-call-id.js";
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";
8
8
  import { OPENAI_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
@@ -20,9 +20,8 @@ function closeToolCallAccum(acc) {
20
20
  const args = parseStreamedToolArgs(acc.args);
21
21
  if (args === undefined)
22
22
  return undefined;
23
- if (!acc.id)
24
- acc.id = mintFallbackToolCallId("call_");
25
- acc.closedTc = { type: "toolCall", id: acc.id, name: acc.name, arguments: args };
23
+ const { engineId, wireId } = admitToolCallId(acc.id, "call_");
24
+ acc.closedTc = { type: "toolCall", id: engineId, ...(wireId !== engineId ? { wireId } : {}), name: acc.name, arguments: args };
26
25
  return acc.closedTc;
27
26
  }
28
27
  function textOf(content) {
@@ -174,6 +173,7 @@ function toOpenAIMessages(ctx, replayThinking = true, model) {
174
173
  });
175
174
  pendingImages = [];
176
175
  };
176
+ const resultWireId = providerResultIds(ctx.messages);
177
177
  for (const m of ctx.messages) {
178
178
  if (m.role !== "toolResult")
179
179
  flushImages();
@@ -201,7 +201,7 @@ function toOpenAIMessages(ctx, replayThinking = true, model) {
201
201
  }
202
202
  if (toolCalls.length > 0) {
203
203
  msg.tool_calls = toolCalls.map((tc) => ({
204
- id: tc.id,
204
+ id: providerToolCallId(tc),
205
205
  type: "function",
206
206
  function: { name: tc.name, arguments: JSON.stringify(tc.arguments ?? {}) },
207
207
  }));
@@ -224,11 +224,11 @@ function toOpenAIMessages(ctx, replayThinking = true, model) {
224
224
  const finalText = errorNote === undefined ? text : text ? `${text}\n${errorNote}` : errorNote;
225
225
  out.push({
226
226
  role: "tool",
227
- tool_call_id: m.toolCallId,
227
+ tool_call_id: resultWireId(m.toolCallId),
228
228
  content: toolResultText(finalText, images.length > 0, m.content),
229
229
  });
230
230
  for (const img of images)
231
- pendingImages.push({ toolCallId: m.toolCallId, data: img.data, mimeType: img.mimeType });
231
+ pendingImages.push({ toolCallId: resultWireId(m.toolCallId), data: img.data, mimeType: img.mimeType });
232
232
  }
233
233
  }
234
234
  flushImages();
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The provider echo shape — the ONE alphabet for upstream bytes the engine carries verbatim (#1204): visible ASCII
3
+ * (0x21–0x7E — no space, no control byte, no non-ASCII), 1–128 characters. The echo fields (`requestId`,
4
+ * `responseModel`) are carried or dropped by it, and a provider-minted tool-call id is admitted verbatim or folded
5
+ * by it (`tool-call-id.ts`).
6
+ *
7
+ * Zero-dependency leaf: `tool-call-id.ts` (itself a leaf, see its header) reads the shape from here. `errors.ts`
8
+ * re-exports the two public names, which is where the package root takes them from.
9
+ */
10
+ /** The longest value the shape admits. */
11
+ export declare const PROVIDER_ECHO_MAX_CHARS = 128;
12
+ /**
13
+ * The shape a provider ECHO field must have to be carried: the request id a provider states on a response
14
+ * header (`AssistantMessage.requestId`, projected as `TaskResult.apiFailure.requestId`) and the model name it
15
+ * reports answering (`AssistantMessage.responseModel`). Both are UPSTREAM BYTES that end up persisted in a
16
+ * transcript and rendered by a UI, so one alphabet governs both: visible ASCII (0x21–0x7E — no space, no
17
+ * control byte, no non-ASCII), 1–128 characters. A value outside it is dropped (the field is absent), never
18
+ * truncated or rewritten; the empty string is outside it. No flags, so `test` is stateless. This object is the
19
+ * consumer's copy: the engine judges with its own instance built from the same source, so recompiling or
20
+ * otherwise mutating this one cannot change what the engine admits.
21
+ */
22
+ export declare const PROVIDER_ECHO_SHAPE: RegExp;
23
+ /** Does `value` fit {@link PROVIDER_ECHO_SHAPE}? A non-string answers `false` (a JS caller's number is not an echo). */
24
+ export declare function isProviderEchoShape(value: string): boolean;
25
+ /** Is every character of `value` in the shape's alphabet, whatever its length? (`""` answers `true`.) */
26
+ export declare function fitsProviderEchoCharset(value: string): boolean;
@@ -0,0 +1,12 @@
1
+ const PROVIDER_ECHO_CHARSET = "\\x21-\\x7E";
2
+ export const PROVIDER_ECHO_MAX_CHARS = 128;
3
+ const PROVIDER_ECHO_SOURCE = `^[${PROVIDER_ECHO_CHARSET}]{1,${PROVIDER_ECHO_MAX_CHARS}}$`;
4
+ const providerEchoShape = new RegExp(PROVIDER_ECHO_SOURCE);
5
+ const providerEchoChars = new RegExp(`^[${PROVIDER_ECHO_CHARSET}]*$`);
6
+ export const PROVIDER_ECHO_SHAPE = new RegExp(PROVIDER_ECHO_SOURCE);
7
+ export function isProviderEchoShape(value) {
8
+ return typeof value === "string" && providerEchoShape.test(value);
9
+ }
10
+ export function fitsProviderEchoCharset(value) {
11
+ return providerEchoChars.test(value);
12
+ }
@@ -1,21 +1,33 @@
1
- /**
2
- * REF-C5 — the ONE place a **fallback tool-call id** is minted.
3
- *
4
- * When a provider's wire frame carries no `id` for a tool call, the adapter must still hand downstream a
5
- * non-`undefined`, process-unique handle: the whole execution/pairing chain keys on it (a tool_use must pair
6
- * with its tool_result, and an in-stream executor's result must pair with the final content). Three adapters
7
- * used to hand-roll the same `Math.random().toString(36)` short id with only their protocol prefix differing.
8
- *
9
- * The prefix is the caller's protocol identity (`toolu_` / `call_` / `repair_<name>_`) and is the ONLY
10
- * parameter — the id BODY is deliberately opaque and uniform: no length/charset options, so there is exactly
11
- * one collision profile to reason about for every lane.
12
- *
13
- * Zero-dependency leaf on purpose: `tool-call-repair.ts` is a pure parser with no runtime imports, so this
14
- * must not drag a stream/telemetry dependency in. Internal module: not re-exported from `src/index.ts`.
15
- */
1
+ import type { Message } from "../internal/llm.js";
2
+ /** The character every folded id carries and no admitted id may: what keeps the two arms' outputs apart. */
3
+ export declare const TOOL_CALL_ID_FOLD_SEPARATOR = "~";
16
4
  /**
17
5
  * Mint a placeholder tool-call id under `prefix`. Uniqueness is per-process and probabilistic
18
6
  * (`Math.random`), which is what every call site already relied on — the id never crosses a trust boundary
19
7
  * and is never a capability; it only has to not collide inside one response.
20
8
  */
21
9
  export declare function mintFallbackToolCallId(prefix: string): string;
10
+ /** The two ids of one admitted tool call (see the module header). Equal unless the admission folded. */
11
+ export interface AdmittedToolCallId {
12
+ readonly engineId: string;
13
+ readonly wireId: string;
14
+ }
15
+ /** Why a raw id is folded: `"charset"` — it holds a character outside the admitted alphabet (a control byte, a space,
16
+ * a non-ASCII unit, or the fold separator); `"length"` — every character is admitted and it is longer than 128. */
17
+ export type ToolCallIdFoldReason = "charset" | "length";
18
+ /** The fold reading of a non-empty raw id: `undefined` ⇒ admitted verbatim; otherwise why it is folded. */
19
+ export declare function toolCallIdFoldReason(raw: string): ToolCallIdFoldReason | undefined;
20
+ /** Admit a provider-minted tool-call id (see the module header for the three arms). Pure: announcing a fold is the
21
+ * run's job, read off `ToolCall.wireId`. */
22
+ export declare function admitToolCallId(raw: unknown, prefix: string): AdmittedToolCallId;
23
+ /** The id a provider is handed for this call: its `wireId` when it carries one (a non-empty string), else its `id`. */
24
+ export declare function providerToolCallId(call: {
25
+ readonly id: string;
26
+ readonly wireId?: unknown;
27
+ }): string;
28
+ /**
29
+ * The id a provider is handed for the tool RESULT of `engineId`, read off the calls in the same request's messages:
30
+ * a result carries only the engine id, and the call it answers carries the provider's. A result whose call is not
31
+ * in the messages is handed on under its engine id.
32
+ */
33
+ export declare function providerResultIds(messages: readonly Message[]): (engineId: string) => string;
@@ -1,3 +1,46 @@
1
+ import { createHash } from "node:crypto";
2
+ import { fitsProviderEchoCharset, isProviderEchoShape } from "./provider-echo-shape.js";
3
+ export const TOOL_CALL_ID_FOLD_SEPARATOR = "~";
4
+ const FOLD_BASE_CHARS = 32;
5
+ const FOLD_DIGEST_HEX = 32;
1
6
  export function mintFallbackToolCallId(prefix) {
2
7
  return `${prefix}${Math.random().toString(36).slice(2)}`;
3
8
  }
9
+ export function toolCallIdFoldReason(raw) {
10
+ const separated = raw.includes(TOOL_CALL_ID_FOLD_SEPARATOR);
11
+ if (!separated && isProviderEchoShape(raw))
12
+ return undefined;
13
+ return !separated && fitsProviderEchoCharset(raw) ? "length" : "charset";
14
+ }
15
+ export function admitToolCallId(raw, prefix) {
16
+ if (typeof raw !== "string" || raw === "") {
17
+ const id = mintFallbackToolCallId(prefix);
18
+ return { engineId: id, wireId: id };
19
+ }
20
+ return { engineId: engineIdOf(raw), wireId: raw };
21
+ }
22
+ function engineIdOf(raw) {
23
+ if (toolCallIdFoldReason(raw) === undefined)
24
+ return raw;
25
+ const base = raw.slice(0, FOLD_BASE_CHARS).replace(/[^\x21-\x7D]/g, "-");
26
+ const digest = createHash("sha256").update(Buffer.from(raw, "utf16le")).digest("hex").slice(0, FOLD_DIGEST_HEX);
27
+ return `${base}${TOOL_CALL_ID_FOLD_SEPARATOR}${digest}`;
28
+ }
29
+ export function providerToolCallId(call) {
30
+ return typeof call.wireId === "string" && call.wireId !== "" ? call.wireId : call.id;
31
+ }
32
+ export function providerResultIds(messages) {
33
+ const byEngineId = new Map();
34
+ for (const m of messages) {
35
+ if (m.role !== "assistant" || !Array.isArray(m.content))
36
+ continue;
37
+ for (const c of m.content) {
38
+ if (c?.type !== "toolCall" || typeof c.id !== "string")
39
+ continue;
40
+ const wire = providerToolCallId(c);
41
+ if (wire !== c.id)
42
+ byEngineId.set(c.id, wire);
43
+ }
44
+ }
45
+ return (engineId) => byEngineId.get(engineId) ?? engineId;
46
+ }