@sema-agent/core 7.42.1 → 7.44.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 (115) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/dist/agents/send-message-tool.js +1 -0
  3. package/dist/agents/subagent.d.ts +3 -1
  4. package/dist/agents/subagent.js +5 -3
  5. package/dist/agents/teacher.js +1 -1
  6. package/dist/agents/verify.js +2 -0
  7. package/dist/brain/errors.d.ts +23 -2
  8. package/dist/brain/errors.js +26 -2
  9. package/dist/brain/stream-engine.d.ts +2 -1
  10. package/dist/brain/stream-engine.js +10 -9
  11. package/dist/core/allow-entry.d.ts +50 -31
  12. package/dist/core/allow-entry.js +100 -37
  13. package/dist/core/ask-origin.d.ts +16 -5
  14. package/dist/core/ask-origin.js +11 -3
  15. package/dist/core/ask-unresolvable-notice.d.ts +3 -2
  16. package/dist/core/ask-unresolvable-notice.js +1 -1
  17. package/dist/core/auto-mode.d.ts +5 -4
  18. package/dist/core/auto-mode.js +3 -1
  19. package/dist/core/checkpoint-store.d.ts +44 -20
  20. package/dist/core/checkpoint-store.js +10 -16
  21. package/dist/core/child-clamp-seats.d.ts +15 -1
  22. package/dist/core/child-clamp-seats.js +6 -2
  23. package/dist/core/classifier-unavailable-arm.d.ts +42 -23
  24. package/dist/core/classifier-unavailable-arm.js +14 -3
  25. package/dist/core/decision-receipt.js +2 -0
  26. package/dist/core/engine-notice.d.ts +16 -14
  27. package/dist/core/engine-notice.js +4 -3
  28. package/dist/core/gate-lanes.js +37 -29
  29. package/dist/core/governance-codes.d.ts +4 -3
  30. package/dist/core/governance-codes.js +1 -0
  31. package/dist/core/hooks.d.ts +6 -1
  32. package/dist/core/hooks.js +3 -0
  33. package/dist/core/memory-engine/engine.d.ts +2 -2
  34. package/dist/core/memory-engine/engine.js +7 -14
  35. package/dist/core/memory-engine/file-backend.d.ts +2 -2
  36. package/dist/core/memory-engine/file-backend.js +5 -7
  37. package/dist/core/memory-engine/layout.d.ts +37 -4
  38. package/dist/core/memory-engine/layout.js +34 -5
  39. package/dist/core/mode-policy.d.ts +5 -4
  40. package/dist/core/mode-policy.js +4 -0
  41. package/dist/core/permission-rule-consent.d.ts +3 -2
  42. package/dist/core/permission-rule-consent.js +25 -20
  43. package/dist/core/permission-rule-model.d.ts +47 -7
  44. package/dist/core/permission-rule-model.js +80 -6
  45. package/dist/core/permission-rules.d.ts +8 -2
  46. package/dist/core/permission-rules.js +26 -9
  47. package/dist/core/runner/classifier-ladder.d.ts +10 -8
  48. package/dist/core/runner/classifier-ladder.js +3 -1
  49. package/dist/core/runner/contracts.d.ts +1 -16
  50. package/dist/core/runner/denial-limit-arms.d.ts +25 -8
  51. package/dist/core/runner/denial-limit-arms.js +24 -10
  52. package/dist/core/runner/gate-exit.js +2 -1
  53. package/dist/core/runner/permission-rule-lanes.d.ts +2 -2
  54. package/dist/core/runner/permission-rule-lanes.js +8 -2
  55. package/dist/core/runner/prepare-ask-lane.d.ts +3 -0
  56. package/dist/core/runner/prepare-ask-lane.js +2 -1
  57. package/dist/core/runner/prepare-caps-and-workflow.d.ts +2 -3
  58. package/dist/core/runner/prepare-caps-and-workflow.js +3 -2
  59. package/dist/core/runner/prepare-config-doors.d.ts +3 -0
  60. package/dist/core/runner/prepare-config-doors.js +6 -5
  61. package/dist/core/runner/prepare-gate-stations.js +1 -1
  62. package/dist/core/runner/prepare-inherited-gate.d.ts +7 -5
  63. package/dist/core/runner/prepare-inherited-gate.js +5 -3
  64. package/dist/core/runner/prepare-park-ask.d.ts +4 -4
  65. package/dist/core/runner/prepare-park-ask.js +2 -4
  66. package/dist/core/runner/prepare-policy-chain.d.ts +7 -1
  67. package/dist/core/runner/prepare-policy-chain.js +59 -38
  68. package/dist/core/runner/prepare-project-context.js +6 -2
  69. package/dist/core/runner/prepare-task.js +6 -6
  70. package/dist/core/runner/prepare-wiring-manifest.d.ts +6 -5
  71. package/dist/core/runner/prepare-wiring-manifest.js +8 -9
  72. package/dist/core/runner/resume-checkpoint-screen.js +1 -1
  73. package/dist/core/runner/runtask.js +1 -5
  74. package/dist/core/runner/steer-admission.d.ts +10 -11
  75. package/dist/core/runner/steer-admission.js +6 -8
  76. package/dist/core/runner/stream-steer-verb.d.ts +5 -4
  77. package/dist/core/runner/stream-steer-verb.js +6 -8
  78. package/dist/core/runner/synthetic-tools.d.ts +3 -2
  79. package/dist/core/runner/synthetic-tools.js +1 -1
  80. package/dist/core/session-policy-store.d.ts +3 -3
  81. package/dist/core/session-policy-store.js +18 -4
  82. package/dist/core/skill-grant-binding.d.ts +44 -0
  83. package/dist/core/skill-grant-binding.js +22 -0
  84. package/dist/core/skill-grant.d.ts +59 -28
  85. package/dist/core/skill-grant.js +96 -23
  86. package/dist/core/steer-replay.d.ts +46 -0
  87. package/dist/core/steer-replay.js +22 -0
  88. package/dist/core/task-event.d.ts +3 -2
  89. package/dist/core/task-spec.d.ts +14 -1
  90. package/dist/core/task-stream.d.ts +12 -6
  91. package/dist/core/tighten-task-spec.d.ts +2 -0
  92. package/dist/core/tighten-task-spec.js +3 -0
  93. package/dist/core/tool-errors.d.ts +2 -2
  94. package/dist/core/tool-name-aliases.d.ts +35 -5
  95. package/dist/core/tool-name-aliases.js +29 -4
  96. package/dist/core/tool-policy.d.ts +10 -0
  97. package/dist/core/tool-policy.js +9 -2
  98. package/dist/core/tool-spec.d.ts +7 -0
  99. package/dist/core/types.d.ts +2 -2
  100. package/dist/core/types.js +1 -1
  101. package/dist/core/wiring-manifest.d.ts +56 -14
  102. package/dist/core/wiring-manifest.js +29 -4
  103. package/dist/index.d.ts +6 -4
  104. package/dist/index.js +5 -4
  105. package/dist/orchestration/run-workflow-tool.d.ts +4 -0
  106. package/dist/orchestration/run-workflow-tool.js +1 -1
  107. package/dist/orchestration/workflow-primitives.d.ts +3 -1
  108. package/dist/orchestration/workflow-primitives.js +6 -6
  109. package/dist/orchestration/workflow-types.d.ts +5 -0
  110. package/dist/orchestration/workflow.d.ts +3 -2
  111. package/dist/orchestration/workflow.js +14 -6
  112. package/package.json +1 -1
  113. package/test/export-surface.snapshot.json +21 -5
  114. package/wire-changes/core@7.43.0.json +985 -0
  115. package/wire-changes/core@7.44.0.json +469 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,176 @@
1
+ ## 7.44.0 — 2026-10-07
2
+
3
+ **Hard-breaking changes (the whole release):** one — #1362. **#1362 — What breaks:** the closed set `USER_STEER_UNDRAINED_CAUSES` and its type `UserSteerUndrainedCause` are renamed `USER_INPUT_UNDRAINED_CAUSES` / `UserInputUndrainedCause` — the same two words in the same order, no alias left behind. **Who goes red:** a host that imports either old name from the package root (a TypeScript build fails; plain JavaScript reads `undefined`), and a downstream table generated from or pinned to the old name. **How to fix:** import the new names; nothing else about the set changed.
4
+
5
+ **Package exports (the whole release):** +5 / −2 — +`PROXY_TUNNEL_REFUSED` (#1157), +`USER_INPUT_UNDRAINED_CAUSES`, +`UserInputUndrainedCause` (#1362), +`STEER_REPLAY_FACETS`, +`SteerReplayFacet` (#1374); −`USER_STEER_UNDRAINED_CAUSES`, −`UserSteerUndrainedCause` (#1362). **Closed sets (the whole release):** `UserSteerUndrainedCause` → `UserInputUndrainedCause` (renamed, members unchanged); new `SteerReplayFacet`; new `ScopeHomeForm`; the memory code registry +1 (`memory.scope_home_not_directory`). The paragraphs below state each ticket's own share.
6
+
7
+ **#1319 —** hard-breaking: none; closed sets: none; exports: none added or removed; the `WorkflowRun.parks` entry gains two optional members, `parkedBy` and `generation` (wire typeKeys +2, both optional).
8
+
9
+ **#1319 — behavior:** a workflow run record's `parks` entry names the run standing on the park and the generation of the call's park line; a recorded entry whose `parkedBy` / `generation` is malformed, or that carries one without the other, refuses the resume as a malformed entry (`workflow.park_binding_broken`).
10
+
11
+ **#1157 —** hard-breaking: none; closed sets: none added (`BrainRetryErrClass`, `BrainStatusPhase`, `BrainErrorCode`, `TerminalFailureDetail` unchanged; the `connect_refused` bucket now also names a proxy refusal). Exports: +1 / −0 — `PROXY_TUNNEL_REFUSED`. **Behavior narrowing:** one — a proxy that refuses the tunnel no longer gets the long retry ladder (see below).
12
+
13
+ **#1368 —** hard-breaking: none; closed sets: +1 — `ScopeHomeForm` (`symlink` | `file` | `other`, the `detail.form` of the new refusal below); the memory code registry +1 (`memory.scope_home_not_directory`, non-governance, control-plane IO). Exports: none added or removed. **Behavior:** a memory scope home that is not a real directory is refused with a code instead of the code-less control-plane error, and a coded store refusal's sentence carries no host path (it rides `detail.path`).
14
+
15
+ **#1362 / #1374 —** closed sets: `UserSteerUndrainedCause` → `UserInputUndrainedCause` (renamed, members unchanged); new `SteerReplayFacet` (`text`, `trusted`, `priority`, `actor`). Exports: +4 / −2 — +`USER_INPUT_UNDRAINED_CAUSES`, +`UserInputUndrainedCause`, +`STEER_REPLAY_FACETS`, +`SteerReplayFacet`; −`USER_STEER_UNDRAINED_CAUSES`, −`UserSteerUndrainedCause`. New optional member: `CheckpointError` `detail.differs`.
16
+
17
+ **#1362 / #1374 — behavior:** `task.user_followup_undrained` now carries `detail.cause` (`leg_end` from this engine) like `task.user_steer_undrained` (#1362); `steering.duplicate_input_id` now carries `detail.differs` on both faces, and a repeat that differs only in tier / trust / attribution is no longer told to re-issue under a fresh `inputId` (#1374). What counts as a replay is unchanged.
18
+
19
+
20
+ ### A workflow park entry names who stands on it — `parkedBy` / `generation` (#1319; additive record members) (@server @client-core @test)
21
+
22
+ - **Before (v7.43.x).** An entry carried `callKey` / `sessionId` / `token`, `originRunId` (the run whose journal holds the park's material — not the run standing on it) and `originUnconfirmed`. A same-token re-park whose journal append failed and an inherited park the run never reached left the SAME entry on the successor (measured on the base: `{ token, sessionId, originRunId: <prior run> }` in both), and nothing said which record of a resume chain is its tail — so a consumer routed a decision by the record's `agents[]` rows and redeemed it by `parks[]`, two readings of one question.
23
+ - **Now.** "Does this record's run hold this park" has ONE reading: `entry.parkedBy === record.id`.
24
+ - `parkAgentLeg` writes `parkedBy: <this run>` and `generation` in the synchronous stretch that sets the row `parked`, on both branches (a new token, a same-token re-park) and on every spawn lane (`ctx.agent`, `ctx.parallel` legs, `ctx.agentStream` — measured: the same entry shape on all three), before any journal append is awaited: a failed append leaves `originRunId` / `originUnconfirmed` as they were and moves neither member.
25
+ - `generation` is the generation of the call's inherited entry, else `0`: a call's first park in its chain is `0`; a park at a call whose entry the run inherited keeps that entry's generation — the same token again, **or a new token after the inherited one was driven** (C-R251: resetting a new token to `0` would tie with the earlier record's entry on the same child session and read as a fork).
26
+ - Resume admission carries `parkedBy` verbatim and adds one to `generation`, at the one site that establishes the inherited set (a recorded entry whose empty token or session another key cache completes keeps its pair; a half it does name that contradicts that cache is now a broken binding, `workflow.park_binding_broken`, where 7.43.x took the other cache's coordinate silently) — so an entry inherited and not reached names the run that parked it, and along a chain the record holding the highest generation on a session is the tail. Two records holding the same generation are a fork (one record resumed twice in turn); the engine admits both resumes (concurrent resumes are already excluded by the resume claim) and leaves the refusal to the consumer.
27
+ - The two members travel together. An entry written before they existed — an older engine's list, or keys derived from the journal / agent rows of a record with no list — carries neither after inheritance (nothing writes them onto it) and proves no holder; the new engine reaching such a park writes its own line at `generation: 0`.
28
+ - A recorded entry whose `parkedBy` is not a non-empty string, whose `generation` is not a non-negative integer, or that carries one without the other is a malformed entry: the resume is refused before anything is dispatched (`workflow.park_binding_broken`).
29
+ - **Who is hurt.** A record written by an older engine carries neither member: a consumer that routes by the new reading proves no holder for it (the serving layer answers its existing `decide.workflow_parks_unreadable` and drains such records before upgrading — its half, S-266). A store or tool that rewrites a `parks` entry and mangles a member (a column store turning `generation` into text) now has the next resume refused instead of carried. A probe that pinned the exact entry shape (the four established members) sees two more. A fork whose one branch drove the token while the other stood on it (R1 resumed as R2 and R2′; R2′ re-parks on T, R2 drives T and its child pauses on T′): both hold an entry on the session at generation 1, so the consumer's fork refusal answers the new card until one branch is resumed.
30
+ - **Compensation.** A reader that ignores unknown members needs nothing. A store that projects the record by column carries the two members verbatim (they are part of the `parks` entry, whose projection obligation is unchanged); dropping them turns the record into an older engine's, never into a wrong holder.
31
+ - **Consumers.** server (S-266) — route and redeem from `parks[]` alone: the rows holding a session are the entries with `parkedBy === record.id`; the highest `generation` held by exactly one record routes, a tie is the fork 409, a record with no holder entry for the session answers as today; its design's "a new token's first park is 0" reads "a call's first park in its chain is 0; a new token at a call whose entry the run inherited keeps that generation" (C-R251); the parallel and stream lanes write the same entry shape as the sequential one (one line); `agents[]` routing retires. client-core — mirror the two optional members on the run record's `parks` entry. test — five cells (G1–G5: a fresh park on each lane; the chain — inherited not reached, re-park, drive to a new token, the tail; a lost journal append; a fork admitted; an older record and a malformed member).
32
+ - Pins: `test/backlog1319-park-holder-generation.test.ts` (① lanes and the same-write snapshot, ② inheritance and the chain's tail, ③ the lost append, ④ the fork, ④b both-or-neither on every entry written, ⑤ older records, ⑤b a recorded entry completed by the journal keeps its holder and a contradicting half refuses, ⑥ malformed members; real `runWorkflow` over a runner stub and a checkpoint seat); re-pinned the exact entry shape in `backlog757-parks-persist-order`, `backlog652-park-truth-from-store`, `backlog755-resume-migration`, `backlog642-workflow-park-lane`.
33
+
34
+ ### A proxy that refuses the tunnel is a definite answer: short ladder, a plain status line, a sentence chosen by the proxy's status (#1157; behavior; +1 export) (@server @cli @client-core @test)
35
+
36
+ - **Before (v7.43.x).** An outbound proxy that answered the tunnel request (`CONNECT`) with 403 / 407 reached the engine as a `fetch failed` whose cause carried no code the connect judge knew, so it was read as indeterminate and took the full transport ladder: 11 attempts, 185 s on the production backoff (measured on a fake clock against a fake `fetch`: 11 calls, 185 155 ms), every wait frame saying `connection lost, reconnecting` (n/10), and a terminal failure `[network] fetch failed (…)` that never said a proxy had refused (test 7.103.0 acceptance, `sema -p` 188.8 s with no output).
37
+ - **Now.** `PROXY_TUNNEL_REFUSED` (`"ERR_PROXY_TUNNEL_REFUSED"`, exported at the package root) is a member of the same table as `ECONNREFUSED` / `ENOTFOUND`, read by the same one judge (`readConnectFailure`, one walk of the `cause` / `errors` graph; `classifyConnectFailure` is its verdict). A graph whose proxy-refusal node sits anywhere in it, under code-less wrappers, is a definite connect answer: the short lane (2 retries by default, ≤1 s backoff ceiling; 3 calls, 1 584 ms on the same fake clock) with the wait frames reading `proxy refused the tunnel, retrying briefly` (`phase` stays `reconnecting`, `errClass: "connect_refused"`; no status on the frame) and a terminal failure that begins with one sentence chosen by the status the proxy stated on that node (`statusCode`, an integer 100–599 naming a failure): 407 `proxy authentication required (HTTP 407)`, 403 `proxy forbids this destination (HTTP 403)`, any other `proxy refused the tunnel (HTTP <n>)`, none readable `proxy refused the tunnel`; the cause chain follows after ` — `. The unanimity rule is unchanged: a foreign code anywhere in the graph (a reset above or below the node, an aggregate leg that timed out) keeps the long ladder. `deterministicConnectRetries` at or above `maxRetries` turns the short lane off for it too (long ladder, frame `proxy refused the tunnel, retrying`).
38
+ - **The fetch-throw gate reads the same table.** A `TypeError` the runtime's shell does not name and that carries no `cause` used to be read as "the request was never built" (`invalid_request`, no retry); one whose own node states a definite connect answer (`PROXY_TUNNEL_REFUSED`, and by the same table `ECONNREFUSED` / `ENOTFOUND`) is now transport evidence — the short lane and the proxy sentence apply to it too (a hand-written `fetchImpl` that forwards the dispatcher's stamped error without a wrapper). A causeless `TypeError` with any other code, or none, is unchanged.
39
+ - **The proxy's status is not published as the provider's.** `terminal.detail` (`provider_rejected`), `TaskResult.apiFailure.status`, `BrainError.status` and the retry frame's `errorStatus` stay absent for this failure: they say what the PROVIDER answered, and a 407 from a proxy read there would send a consumer to "change the request and resend" (pinned; one mutation of it reds).
40
+ - **Who is hurt.** A deployment whose proxy refuses the tunnel for a short while and then recovers (a credential rotation, a policy push taking tens of seconds) rode the long ladder to success; it now fails after two retries (~2 s). A consumer matching the terminal text `[network] fetch failed (…)` for this failure sees the sentence first. Nothing else moves: a silent proxy (dial deadline), a reset and every undecoded `OutboundProxyError` keep the transport ladder.
41
+ - **Compensation.** `deterministicConnectRetries` ≥ `maxRetries` restores the long ladder (with the proxy wording on the frames). A host that must branch on 407 / 403 reads the sentence's `(HTTP n)` today; a structural seat for the status (a new `terminal.detail` kind) is not in this version.
42
+ - **Consumers.** server (S-803) — in the dispatcher's non-200 `CONNECT` branch, mint the error with `code: PROXY_TUNNEL_REFUSED` (import it from `@sema-agent/core`) and the proxy's status as a numeric `statusCode` on the same node; dial failures keep wrapping the original errno error with no code of their own (the errno below decides, as today). The node's own cause must carry no code: a stock undici `ProxyAgent` spells this refusal with `UND_ERR_ABORTED` beneath `fetch failed`, and a stamp placed over that coded error is read as indeterminate (long ladder; measured with undici 7.30.0 against a listener answering `CONNECT` with 407). Core has not verified that a custom code on that node leaves undici's connection-phase recovery alone (the dispatcher's own note ties "no code" to that); that check is the server's. cli — nothing to render specially: the failure text and the frame detail carry the words. client-core — `BrainRetryErrClass` unchanged; if a mirror lists the root exports, add `PROXY_TUNNEL_REFUSED` (a string). test — acceptance cell: a fake `fetch` rejecting `TypeError("fetch failed", { cause: { code: "ERR_PROXY_TUNNEL_REFUSED", statusCode: 407 } })` ends in ~2 s with the 407 sentence; the same error without the code ends after 11 attempts.
43
+ - **Known limits.** KL-1157-a (Bun hands a proxy's CONNECT 403 back as a Response, not a throw: read as `auth` / "gateway HTTP 403", no code to carry; no text sniffing). KL-1157-b (the fetch-throw gate reads the connect judge's unanimous verdict as transport evidence: a hand-built causeless `TypeError` stamped with a table code over a mixed aggregate still reads unbuildable, as before; a plain object copying an invalid-URL cause under a proxy stamp reads transport). The MCP / A2A failure table reads an `ERR_*` code as unknown (no `delivered: no` for a refused tunnel) — separate seat, not this version.
44
+ - Pins: `test/backlog1157-proxy-tunnel-refused.test.ts` (36 cells: ladder × {407, 403, 502} on a fake clock with the production backoff, the code-less control and a foreign-coded wrapper on the long ladder, lane-off wording, recovery, three sentences + seven unreadable statuses + chain evidence, a Runner end to end, the provider-status seats absent, a stamped causeless `TypeError` through the fetch-throw gate and the engine, the node at the top / under the shell / mid-chain / sole aggregate leg, three unanimity vetoes, a cyclic and a hostile graph, the exported constant); `test/export-surface.snapshot.json` (+1 name); smoke decision row. Base v7.43.x: 17 of the first 22 cells red (11 attempts, 185 s); the stamped-`TypeError` cells were red before the fetch-throw gate read the table.
45
+
46
+ ### A scope home that is not a real directory is refused with its own code, and a coded store refusal's sentence says no host path (#1368; behavior) (@server @client-core @test)
47
+
48
+ - **Before (v7.43.0 and earlier).** A memory scope home (`<memoryDir>/<label>`) replaced by a symbolic link, a regular file or any other non-directory was refused with a `ControlPlaneCorruptError` that carried no `code` and no structured member — `scope home <path> is a symbolic link — a scope home must be a real directory (fail-closed)` — the class the control plane raises for damaged ledgers, so a host could not tell an unmet layout precondition from damage, and a host that maps coded memory refusals to a conflict answer and everything else to an internal error answered this swap with the latter (a 500, on the server's black-box report) while the same swap one level down (a topic directory replaced by a link out) answered the coded `memory.target_outside_store` (a 409). That code's sentences named host paths (the act's target and where it resolved, the store root and where it now resolves, and on a journal replay the journal's own path) and the error carried no `detail`.
49
+ - **Now.** `memory.scope_home_not_directory` — a plain `Error` with `.code` and `.detail: { path, form }`, `form` one of `"symlink" | "file" | "other"` judged by `lstat` (a link is never followed; a dangling one is a link, not an absent home) — on every face that reaches a home: registration (`registerScope`, an engine mount, the file backend's reads and its commit planning), the layout adoption's owner-home pre-checks, and `ensureScopeHome`'s own creation (a `recursive` mkdir over a link to a directory reports success and the check after it refuses it; a mkdir that fails because a file, FIFO or dangling link stands there re-judges the form and refuses with the code; nothing is registered). One minter in `layout.ts` (`storeFormRefusal`) with three seats — an act refused, a root that moved, a home that is not a directory — and `memory.target_outside_store` is minted by the same function: the code leads the sentence, the sentence names no path, `detail` is `{ path }` (the act's target, or the memory dir's spelling for a moved root). The hand-written sentences in `engine.ts` / `file-backend.ts` are gone. The verdict `landsOutsideStore` answers a boolean (its reason string was where two of the paths came from). **Error class:** the home arm moves from `ControlPlaneCorruptError` (`name: "ControlPlaneCorruptError"`, no `code`) to a plain `Error` (`name: "Error"`) with `code`; `memory.target_outside_store` stays a plain `Error` with `code` and gains `detail`.
50
+ - **Who is hurt.** A host or log script that matched the old sentence text (`scope home … is a symbolic link`, `… resolves to <dir>, outside the store root <root>`, the path inside any of them) or tested `instanceof ControlPlaneCorruptError` for a scope home that is not a directory. Every line built from the sentence loses the path with it: the harvest's `incident.detail` and `warnings` text, the batch planning's per-patch conflict `reason` (`io error: …`).
51
+ - **Compensation.** Branch on `code`; read `detail.path` / `detail.form` for the path and the form. `instanceof ControlPlaneCorruptError` still holds for every real control-plane damage arm; only the home-form arm left it.
52
+ - **Consumers.** server (S-806) — the `targetOutsideStoreOf` family reads one more code (`memory.scope_home_not_directory`), and a flat store and a nested store with a swapped root now answer the same conflict; an operator leg that relays the sentence relays no path (it is `detail.path`). client-core — the memory-code mirror +1. test — a swapped-root black-box in both layouts (entries directly in the home / under a topic directory).
53
+ - **Known limits.** A scope home swapped for a file, FIFO or dangling link in the window between a face's form check and one of the four `mkdirSync(dirname(<target>), { recursive: true })` calls that create an entry's directory (the backend's staging of a flat entry, the engine's missing-file restore, its index write and its projection write) still surfaces the file system's own error (`EEXIST` / `ENOENT`, host path in its text, no code) there; the refusal stands and nothing is written outside, only the code is missing. Not reachable without a concurrent actor swapping the home inside that window (pin ① swaps before the face runs and every face answers the code). A TOPIC directory replaced by a regular file (not a link) answers the same native `EEXIST` on a write under it, on this version and the last. Both are #1378 (one directory-creation seat that answers the code); the commit planning's `conflicts[]` entry still carries the refusal as text only (the code leads the sentence, no `code` member) — #1378 too. Coded memory refusals outside the store-form family that still spell a host path (`config.memory_scope_placement`, `config.memory_project_marker_nested`) and the layout preconditions still reported as control-plane damage are #1379.
54
+ - Pins: `test/backlog1368-scope-home-not-directory.test.ts` — ① both layouts × symlink / file / FIFO on registration, the file backend's read, an engine mount and the commit planning, ② `ensureScopeHome` (link to a directory, a link / file / FIFO / dangling link racing in before the mkdir, a dangling link, the layout adoption's owner-home check, controls), ③ the four `memory.target_outside_store` arms path-free with `detail.path`, ④ one mint site and the closed form set; re-pinned `backlog1094` ① / ⑭a (the sentence names no path; `detail.path` does) and `memory-layout-migration` P2 (a home that is a link / a file answers the code).
55
+
56
+ ### The two undrained families name the cause from one set under one name (#1362; hard-breaking surface) (@server @client-core @test)
57
+
58
+ - **Before (v7.43.x).** `task.user_steer_undrained` carried `detail.cause` (closed `USER_STEER_UNDRAINED_CAUSES`: `leg_end` minted here, `crashed` minted by a serving layer for steers the dead process never confirmed consumed); `task.user_followup_undrained` carried none. A `later` steer rides the follow-up lane, so the same loss was `task.user_followup_undrained` with no cause when the leg ended, and — counted by a serving layer after a crash — a steer-family row with `cause: "crashed"`: the code changed with who reported it.
59
+ - **Now.** One set, one name: `USER_INPUT_UNDRAINED_CAUSES` (`src/core/engine-notice.ts`), the `detail.cause` of BOTH families. `undrainedUserInputNotices` stamps one value for both rows — the leg's own and a parent's copy (`{ followUp, cause: "leg_end", taskId?, sessionId?, runId? }`; the copy `{ followUp, cause: "leg_end", taskId?, sessionId, childSessionId }`). Codes stay two and follow the LANE the input was queued on (steer: `next` / `now`; follow-up: `later`); the `NOTICE_MINTER` member-level exception now covers both codes — their `crashed` arm is the serving layer's, choosing the family by lane, not by verb. No new code; `ENGINE_NOTICE_CODES`, `NOTICE_AUDIENCE` and `NOTICE_MINTER` rows unchanged.
60
+ - **Who is hurt.** See the hard-breaking line (the rename). A consumer that pinned the follow-up row's `detail` exactly sees one more key (`cause`). A mirror of the set downstream renames with it.
61
+ - **Compensation.** Import `USER_INPUT_UNDRAINED_CAUSES` / `UserInputUndrainedCause`; read `cause` on either family the same way.
62
+ - **Consumers.** server (S-1122) — the crash arm picks the code by the lane the lost input was queued on (`later` ⇒ `task.user_followup_undrained` with `cause: "crashed"`), not by the request verb; rename the mirrored set. client-core — rename the mirror table of the set. test — both families' `cause` on a real leg end and on a parent's copy.
63
+ - Pins: `test/backlog1362-undrained-cause-one-vocabulary.test.ts` (⑦u the factory, ⑦ two real lanes on a child leg — a `later` steer stranded by a halt, a `next` steer cut by interrupt —, ⑧ the package root and the export snapshot, ⑨ CLOSED-SETS and the minter's exception sentence); re-pinned `backlog1233-steer-undrained-cause` (② the follow-up row now carries the cause), `backlog1279-child-undrained-to-parent`, `ref-b6-harness-injection-enqueue`.
64
+
65
+ ### `steering.duplicate_input_id` says which facets differ — one judgement for both seats, and no fresh-key advice for the same instruction (#1374) (@server @client-core @test)
66
+
67
+ - **Before (v7.43.x).** Two comparators wrote the same four-facet test twice — the parked queue's (`appendPendingSteer`) over the caller's text, the live verb's over the delivered payload (the framed bytes, with trust and the speaker label folded in) — and both seats answered every non-replay with one sentence, "re-issue this one with a fresh inputId", and no `detail`. A client that re-sent the SAME instruction at another tier (or trust, or attribution) under its key and followed the sentence queued the instruction twice.
68
+ - **Now.** One function, `steerReplayDiffers` (`src/core/steer-replay.ts`), answers which facets a repeat differs in — closed `STEER_REPLAY_FACETS`: `text`, `trusted`, `priority` (absent reads `next`), `actor` (`id`, `hostAsserted`, `issuer`) — in the set's order; empty is the idempotent replay. Both seats judge what the caller SENT (the live seat remembers the caller's record, the one a durable park carries), and the same answer feeds the decision, the refusal's `detail.differs` (by name only, never the values; always present on this code, never empty) and one sentence mint: with `text` listed, "a different steering instruction is already parked under inputId "<id>" / accepted under this inputId (this repeat differs in: …) — re-issue this one with a fresh inputId …"; without, "the same steering instruction is already … ; this repeat differs only in: …, which cannot change after acceptance — sending it again under another inputId would queue it a second time; to confirm the standing receipt, replay it with its original values". The parked face carries it as `CheckpointError.detail.differs`, the live face on the rejected `Error` (`code` + `detail`). One code for every arm. What counts as a replay is unchanged: the live seat's frame and speaker label are a one-to-one function of the caller's text for a given trust, speaker and run (`frameMidTurnUserInput` / `projectHumanInput` / `mintSystemReminder`), so comparing the text in place of the delivered bytes answers the same for every pair; the delivered-payload rule for what books a key (an untrusted whitespace-only steer queues nothing and reserves nothing; a trusted blank one does) is untouched.
69
+ - **Who is hurt.** A host that matched the old sentence text (the code is the contract; it is unchanged). A host that re-sent an accepted instruction at another tier under a fresh key because the sentence told it to — the sentence now says that queues it twice.
70
+ - **Compensation.** Branch on `detail.differs`: includes `"text"` ⇒ another instruction, take a fresh key; otherwise ⇒ the accepted instruction, replay it with its original values (or accept the standing receipt).
71
+ - **Consumers.** server — the 409 body passes `detail.differs` through from both faces (`STEER_REPLAY_FACETS` is exported for its schema). client-core (CC-374) — two arms by whether `differs` includes `"text"`. test — each facet alone on both seats, and the tier-only sentence.
72
+ - Pins: `test/backlog1374-steer-replay-one-difference.test.ts` (① each facet alone × the parked seat on the in-memory and the file store × the live seat on a real run, ② several facets in the set's order, ③ the same value — absent tier × `next`, a fresh actor object, a trusted one — still the idempotent replay, ③b the booking rule for blank steers, ④ the tier-only sentence, ⑤ both seats answer one `differs`, ⑥ one comparison and one sentence under `src/`); re-pinned `backlog1361-steer-priority-one-reading` (the live cell reads the one function), `steer-input-id` (⑤ names the facets), `steering-pending`.
73
+
74
+ ## 7.43.0 — 2026-10-06
75
+
76
+ **Hard-breaking changes:** one — #1318. **#1318 — What breaks:** the parameter spelling of a Skill rule — `Skill(skill:<name>)`, and `Skill(args:…)` / `Skill(skill:*)` with it — a working but non-reference spelling in a caller policy (`createPermissionRulePolicy`), is refused at construction (`config.invalid_permission_rules`, one `issues[]` entry per rule with `code: "invalid.command_rule"`, the sentence naming `Skill(<name>)` and `Skill(<name>:*)`; under `onInvalidRule: "skip"` it is reported through `onIssue` and dropped, so a deny written that way stops firing until it is respelled); the reference's own spelling `Skill(<name>)` / `Skill(<prefix>:*)`, which 7.42.x refused (`invalid.param_split` in a policy, `unsupported.tool` in the persisted grammar), is now the one spelling, read by both compilers. **Who is hurt:** a host that wrote the parameter spelling — the serving layer that encodes a `permissions.ask` / `permissions.deny` entry of the Skill tool into policy rules, a hand-written policy; a probe that pinned `Skill(<name>)` as refused; a consumer that switches exhaustively over `PersistedRuleMatch`. An organization rule file with a `Skill(…)` row still refuses the WHOLE snapshot (the org grammar is the shell's alone — unchanged). **Compensation:** respell `Skill(skill:deploy)` as `Skill(deploy)`, and a wildcard on the skill (`Skill(skill:deploy*)`) as `Skill(deploy:*)` (a name PREFIX — no word boundary).
77
+
78
+ **Package exports (the whole release):** +5 / −0 — `APPROVAL_REACH`, `ApprovalReach`, `isApprovalReach` (#1354), `BoundSkillGrants` (#1348 / #1357), `abortedSeatDeny` (#1371). The paragraphs below state each ticket's own closed sets and its own share of the exports.
79
+
80
+ **Closed sets (#1318):** `PersistedRuleMatch` +2 (`name`, `name_prefix`); `ruleToolGrammarOf` answers +`"name"` (type-level only); `PermissionRuleIssue.code` unchanged (the retired spelling rides the existing `invalid.command_rule`). **Package exports (#1318):** none added or removed.
81
+
82
+ **Behavior:** a run can now declare that nobody on it can be asked to approve (`TaskSpec.approvalReach: "none"`; absent = unchanged, byte for byte); in auto mode a classification whose reply carried no verdict now goes to the person where the run has an approval seat (C-R238) instead of being refused and counted — and a card the seat's deployment POLICY refused counts on the denial limit, so a shell that refuses every card still reaches the bound (K-14; at the bound the same call is put to the seat again as the denial-limit fallback); the inherited two-frames collapse no longer lets an ancestor's "classifier did not rule" card answer a child's own real-approval question (one more card); a retained child spawned under posture `"interactive"` and woken by a hard-headless or declared-none run now revives (its posture yields) instead of being refused; the headless refusal's bracket and three engine sentences no longer say "no approver is wired" (seat-neutral wording; the way out names the seat); a call to the engine's `Skill` tool now carries the skill and its two lists (`boundGrants`) and their hash (`boundGrantDigest`) on its live ask, an ancestor's ask, the parked row and the inbox projection (the hash was on the parked row alone; the resume still judges the digest alone, its bytes unchanged). **Closed sets:** `AskSeamForm` +1 (`declared_none`); `AskClass` +1 (`classifier_no_ruling`); the `classifier.unavailable` notice's `detail.cause` +1 (`parse_error`); new `APPROVAL_REACH` (`none`); new thrown code `config.approval_reach_invalid` (also a `checkpoint.config_refused` `detail.code`); `CHECKPOINT_CONFIG_REFUSAL_CODES` +1 and `HeadlessApproverSeat` +1 (engine-internal). **Package exports:** +4 — `APPROVAL_REACH`, `ApprovalReach`, `isApprovalReach`, `BoundSkillGrants` (type); `ClassifierUnavailableInfo.cause` widens to `AutoModeFallbackCause`; new optional members `TaskSpec.approvalReach`, `ToolExecuteContext.approvalReach`, `AskRequest.classifierNoRuling`, the `PermissionResult` ask arm's `classifierNoRuling`, `PendingAction.tool_approval.classifierNoRuling`, `CheckpointSummary.classifierNoRuling`, `AskCarry.classifierNoRuling`, `ToolGateInput.askSeatForm`, `AskRequest.boundGrantDigest`, `AskRequest.boundGrants`, `PendingAction.tool_approval.boundGrants`, `CheckpointSummary.boundGrantDigest`, `CheckpointSummary.boundGrants`; `resolveAskSeamForm`'s spec reads `approvalReach` too.
83
+
84
+ **Behavior narrowing (#1372):** a skill whose `disallowed-tools` holds an entry the list grammar reads as nothing (a surrounding space, a retired tool name, a case twin of a mounted tool, `*`, `mcp__*`, `WebFetch(domain:…)`, `Skill(<name>)`, any other content form outside the shell and path grammars) no longer activates — the model's `Skill` call naming it is refused where the run's skill deny rows are read (`tool_end.gate.disposition` denied / `policy`; no card, no park, whatever the approver or mode), and a person's `userInvokedSkills` refuses the run (`config.skill_deny_unreadable`) — and a park's frozen deny record, or a parent's captured one, that reads as nothing against the leg's roster refuses the leg; `config.user_invoked_skill_unknown` is refused before a resume's claim. **Closed sets:** new thrown code `config.skill_deny_unreadable`; `CHECKPOINT_CONFIG_REFUSAL_CODES` +2 (`config.user_invoked_skill_unknown`, `config.skill_deny_unreadable`). **Package exports:** none added or removed; new optional member `SkillGrantHit.readsAsNothing` (on a skill lane's refusal: the named skill's deny entries that read as nothing).
85
+
86
+ **Behavior (#1355 (b), #1358):** a rule's tool name whose peer segment no mint spells (`mcp__github.com__*`, where the mounted tools are `mcp__github_com__…`) is now a stale name: the prepare audit refuses to start the run (`config.legacy_tool_name`; it was an advisory) and the session store refuses the write, both naming the spelling to write; and the allow lists — `TaskSpec.allowedTools`, `RunnerDeps.allowedTools`, a skill's `allowed-tools` / `disallowed-tools` — read an entry's tool name against the run's roster: a stale name, or a name that differs from a mounted tool's only by case, is announced (`permission.allowed_tool_not_granted`) and read as nothing; a name the run does not mount stays silent. **Closed sets:** `RULE_TOOL_NAME_VERDICTS` +1 (`unmintable_segment`; `RuleToolNameVerdict`, `SessionRuleNameRefusal.verdict` follow); `ALLOW_ENTRY_INERT_REASONS` +2 (`tool_name`, `near_miss`). **Package exports:** none added or removed; `compileAllowEntry` / `compileAllowList` take an optional third argument (the roster), and a `compileAllowList` inert item may carry `verdict` and `currentName`.
87
+
88
+ ### Release tooling: the wire manifest reads the keys of an array's element type (#1369; tooling, no package behavior)
89
+
90
+ - **Before.** `scripts/wire-changes.mjs` read `T[]` / `Array<T>` as leaves: a key added or removed inside an array's element shape (`WorkflowRun.parks[].parkedBy`) never reached `typeKeys`, so a removal there shipped with no BREAKING line.
91
+ - **Now.** The element type is read under a `[]` hop (`parks[].parkedBy`); the added-parent rule, the move reconciliation through a referenced type and the optional-member claim token all understand the hop; a member that existed and now points into a type the other ref lacks has that type's keys compared under it (recursively), while a brand-new member typed as a new type stays one added key. Pins: `test/backlog1369-wire-changes-array-element-keys.test.ts` ①–⑪ (baseline 3 red / 1 control).
92
+ - **Who is hurt.** Nobody at runtime; release posts from this version on may list more `typeKeys` rows than before (keys that were invisible).
93
+ ### A Skill rule has one spelling — `Skill(<name>)` / `Skill(<prefix>:*)` — and the activation card offers it (#1318; behavior + hard-breaking surface) (@server @cli @client-core @test)
94
+
95
+ - **Before (v7.42.x).** The persisted rule grammar answered `unsupported.tool` to every Skill rule; the caller policy's compiler answered `invalid.param_split` ("param-level ruleContent must be `param:pattern`") to the reference's `Skill(s1)` and took the non-reference parameter spelling `Skill(skill:s1)` instead; a server that encoded `settings.permissions.ask` into policy rules had `Skill(s1)` refused and listed in `config.invalid_permission_rules`. The skill activation card carried no "don't ask again" offer (the offer lane spoke for the shell tool alone), so a shell wrote the whole tool `Skill` as the remembered rule — over a person's own ask rule.
96
+ - **Now.** One reader for every consumer: `Skill(<name>)` is the exact name, `Skill(<prefix>:*)` (a trailing ` *` reads the same) a name PREFIX with no word boundary — the reference's matcher (`Y()`, cli280.js 19681196: `n === name`, else `name.startsWith(prefix)`); one leading `/` is read off; a name may carry a colon (`Skill(apps/web:deploy)`). The caller policy compiles it as a deny / ask (the call's `skill` argument is the string it is read against — the one the `Skill` tool resolves a call by); the persisted grammar takes it under `deny`, `ask` and `allow` (`tool: "Skill"`, `match: "name" | "name_prefix"`, `command: <name or prefix>`) and the personal rule lane reads such rows on every `Skill` call through the one precedence walk (deny > ask > allow; live, this tool, scope covers the call): a stored deny refuses (`deniedBy: "persisted_rule"`), an ask asks (`askClass: "ask_rule"`), an allow clears the activation card for the skills it names and no other. The activation card (an `unstated` ask) carries ONE offer — `ruleOffers: [{ kind: "single", rule: "Skill(<name>)", match: "name", command: "<name>" }]`, the reference's "don't ask again for <skill> in <cwd>" row — when a rule lane is armed and a stored allow could clear it (`ruleOffersAbsence` `"shadowed"` when an ask row names the skill, `"lane_cannot_speak"` when no rule spells exactly that name); `prepareCardApproval({ toolName: "Skill", command: <the name> })` mints its record and `confirmRuleApproval` / `redeemRuleTicket` write the row (a skill card has no edit row). The retired parameter spelling is refused loudly by both compilers (policy: `invalid.command_rule`; persisted: `invalid.grammar`), the sentence naming `Skill(<name>)` / `Skill(<name>:*)`. Stricter than the reference on three points, all toward asking: `Skill(:*)` (its empty prefix reaches every skill) and a persisted whole-tool `Skill` rule have no spelling; a spelling whose canonical form would not read back as the same rule is refused (a name beginning with a slash, a prefix that is itself a parameter word — `Skill(skill *)`); the card does not mint the reference's second row (the first word of a name containing a space as a prefix).
97
+ - **Who is hurt.** See the hard-breaking line. A rule file that listed `Skill(<name>)` in a store import (CC settings) used to be reported `unsupported.tool` and is now imported; an engine of this version reading a store written by 7.42.x finds no such row (nothing was ever stored); the other way round, a 7.42.x replica refuses a stored `Skill(…)` row it syncs in (KL-1318-d: a stored deny does not fire there until it is upgraded). A stored `Skill(<prefix>:*)` allow reaches every skill whose name begins with the prefix, `deploy-prod` and `deployment` included — the reference's reading.
98
+ - **Compensation.** Respell as above. To keep a deny that must fire whatever the spelling, stay on the caller policy's bare `Skill` (the whole tool) or list every name.
99
+ - **Consumers.** server — `permissions.ask` / `permissions.deny` entries of the Skill tool go to `createPermissionRulePolicy` in the CC form (the `config.invalid_permission_rules` listing of a `Skill(s1)` entry ends); cli — render the card's offer from `ruleOffers` (a `single` with `match: "name"` is a skill name, not a command; the shell's "commands" wording does not apply) and redeem it through the consent door; never write the whole tool `Skill` as the remembered rule; client-core — `PersistedRuleMatch` +2 words in the closed-set mirror; test — the Skill card's offer and the deny / ask / allow rows are wire-observable (`ruleOffers`, `askClass`, `tool_end.gate`).
100
+ - **Known limits.** KL-1318-a (the allow layer's entry lists — `TaskSpec.allowedTools`, `RunnerDeps.allowedTools`, a skill's `allowed-tools` — still read a `Skill(<name>)` entry as one that grants nothing; the store and the policy read it) · b (the card's second reference row is not minted) · c (`Skill(:*)`, a prefix that is a parameter word and a name beginning with a slash have no spelling) · d (a 7.42.x replica refuses a stored Skill row).
101
+ - Pins: `test/backlog1318-skill-rule-cc-form.test.ts` — ① the persisted grammar and a stored row read by the lane (allow clears only that skill, ask asks, deny refuses), ② the caller policy, ③ one rule × name table through both compilers against the reference's matcher (prefix without a boundary, ` *`, slash, colon names), ④ the retired spelling refused by each compiler, ⑤ the card's offer, the consent round trip (the next activation asks nothing) and no edit row, ⑥ deny through the real Runner lane (policy and stored), H1–H4 (no shell leak, org unchanged, allow-entry boundary, round-trip law, the table row equal to the real tool's schema).
102
+
103
+ ### "Is there a person on this run who can answer an approval?" has one reading, and a run can say "nobody" (#1354) (@server @cli @client-core @sdk @test)
104
+
105
+ - **Before (v7.42.x).** One prepare read the approval seat four times, two ways: the manifest's form and the posture door judged presence (`spec.onAsk !== undefined`, a `null` refused), the approver lane and the inherited gate took `spec.onAsk ?? deps.onAsk` (a `null` fell through to the deployment) — a stateful accessor on `spec.onAsk` was read 4 times in one run (measured on 7.42.0). Nothing could say "nobody on this run can answer": a `-p` lane whose deployment wires its coordinator as `RunnerDeps.onAsk` read as `human_reachable`, its cards were shown to nobody and refused by the shell in the deployment's name, and the denial limit's stop said "no approver is wired to fall back to" while one was wired. A retained child spawned under posture `"interactive"` and woken by a hard-headless sender (`interactiveTools: false`) was refused at its own door (`config.interaction_posture`, measured on 7.42.0). In `combinePolicies`, copying the denial-limit fallback onto a winning bare real-approval ask dropped that ask's `policy_rra`, so the folded class set depended on the composition order (measured: `[denial_limit_fallback]` vs `[denial_limit_fallback, policy_rra]`).
106
+ - **Now.** `resolveAskSeat(spec, deps)` is the one read — at prepare's config doors, before anything is acquired, frozen and handed down to every phase; the seat is the spec's when its key is PRESENT (a spec `null` is refused, `config.interaction_wiring`, never a fall-through). `TaskSpec.approvalReach: "none"` is one sentence in it: a callback (or no seat) becomes `ask.form: "declared_none"` with no value — so the run never calls the deployment's (or its own) approver — and a blanket `"allow"` / `"deny"` stays a policy. With a park facility the ask parks for a person (`park_only`, the run ends `paused`); without one it is refused as nobody's (`auto_deny`, the CC headless sentence, a way out naming the declaration); under `dontAsk` the mode refuses first and nothing parks.
107
+ - **Inherited by every engine-built child** (a tighten-only clamp seat — `CHILD_CLAMP_SEATS` +1): the delegation lanes, the observer sidecar, the verifier / fix and helper legs, a workflow agent (the auto-mount carries it), a retained child woken by a declared-none sender. A deployment-pinned workflow `base.onAsk` beside it is set aside the same way (not refused). A declared-none leg asks no ancestor's live approver either: a function on the frozen chain it sees is emptied (that ask resolves as nobody's — a durable mandate still parks it); a blanket on the chain stays.
108
+ - **Not checkpointed:** a resumed leg holds it only when its taskConfig supplies it again.
109
+ - **Refused when unreadable** — `"None"`, `""`, `true`, an object, any other word — at prepare, by `describeStaticWiring`, and by a resume's pre-claim screen (`checkpoint.config_refused`, `detail.code: "config.approval_reach_invalid"`, `detail.field: "taskConfig"`, the row untouched).
110
+ - **`interactionPosture: "interactive"` beside it is refused** (`config.interaction_posture`, naming the declaration). An inherited interactive posture (a workflow baseline's, a retained child's spawn) yields to an inherited clamp (`interactiveTools: false` or `approvalReach: "none"`) — it becomes `"headless"`, stated, so a deployment default of `"interactive"` does not come back through the leg's own resolution — instead of refusing the leg (`postureUnderClamps`, one rule for both lanes).
111
+ - **Seat-neutral sentences:** the headless refusal reads "(nobody can be asked to approve "<tool>" in this run — headless auto-deny)"; the `delegation.ask_unresolvable` clause "nobody in this run can be asked to answer it"; the durable-gate-unavailable notice "nobody in this run can be asked to approve them"; the denial-limit stop "nobody in this run can be asked to answer it". The seat's own words stay in the way out: wire an approver (no seat), make a person reachable through it (`unavailable`), or — a declared-none run — run it where a person can be asked, or arm a durable park.
112
+ - `combinePolicies`: when an explaining member (the denial-limit fallback, the no-ruling member below) is copied onto the winning ask, the winner's own classes join the folded union — one class set in every order.
113
+ - **Who is hurt.** A `-p` shell that declares `"none"` (cli 1.0.143 onward) and runs on a deployment with a park facility (the cli's local engine by default): the first gated call parks, the process exits 1 with its pending-approval line, and the call is answered in the REPL or the approvals API — where 7.42.x refused it and the model went on. A host that pinned the old headless refusal bracket "(no approver is wired for …)", the old `delegation.ask_unresolvable` clause or the old stop sentence in logs or probes. A deployment whose request sets `interactionPosture: "interactive"` (spec, deps or a parent's posture) and `approvalReach: "none"` together: refused at the door. A host that read a spec `null` seat as "use the deployment's": refused (it already was, later in prepare, for the manifest).
114
+ - **Compensation.** "Refuse and go on" whatever the deployment's park facility says: `permissionMode: "dontAsk"`. A run that should be asked: omit `approvalReach`. The seat's form is on the manifest (`ask.form`); `resolveAskSeamForm(spec, deps)` takes the request's `approvalReach` beside the seat.
115
+ - **Consumers.** server (S-1106) — pass the request body's `approvalReach` into `TaskSpec.approvalReach` untranslated; hand it to `resolveAskSeamForm` in the per-leg interactive judgment (else the leg is refused at core's door); re-supply it on an unattended resume; project `ask.form: "declared_none"`; a `checkpoint.config_refused` can carry `detail.code: "config.approval_reach_invalid"`. cli (L-1058) — declare `"none"` on the `-p` lane; a declared run never receives an approval frame (the shell's "print mode has no approval channel" refusal retires for it); a parked first gated call exits 1. client-core (CC-365) — assemble the request field; mirror `APPROVAL_REACH` and the `AskSeamForm` word. sdk — the request member. test — UX-7 print-missing splits into `nopark` / `park` (K-12).
116
+ - **Known limits.** KL-1354-a (a declared-none run's unanswerable content question keeps the generic way out — the question channel is `onQuestion`'s, not the declaration's).
117
+ - Pins: `test/backlog1354-approval-reach.test.ts` (①–⑨, ⑰, ⑱, ⑳–㉒, ③c; real Runner); re-pinned the seat-neutral sentences (1019 P5, 1137, 548 inherited arms, 709, 7340rg, core-toolgate-coverage, irreversibility), the clamp carrier (777), the phase fixtures (prepare-wiring-manifest / -inherited-gate / -caps-and-workflow).
118
+
119
+ ### A classification that did not rule has one arm, at both classifier stations; the card says why (#1353; behavior) (@server @cli @client-core @test)
120
+
121
+ - **Before (v7.42.x).** The leg's gate and the inherited station each had two arms: an `unavailable` classification forked on the approval seat (with one: a card whose class was `policy_rra`, the "why" only in the second message paragraph; without one: the `x1t` refusal, uncounted), while a reply with no verdict was refused and counted like a block wherever a person was — so a TUI user whose classifier answered in prose saw two refusals and a third "denial limit" card. The `classifier.unavailable` notice said nothing about a reply with no verdict.
122
+ - **Now.** One predicate (`rungDidNotRule`) and one exit (`classifierNoRulingArm`) at both stations. With an approval seat (a live approver, or a durable park) the call goes to the person on a card carrying `classifierNoRuling` (`error` / `timeout` / `parse_error`), `requiresRealApproval`, class `classifier_no_ruling` (`classes[0]`) and a second paragraph saying why ("… replied without a verdict, so auto mode could not judge this call …" for `parse_error`; the unavailable paragraph byte for byte); the park row and its summary carry the member. Never counted — **except** a card THIS station handed on that the seat's deployment POLICY refused (settlement `policy_refused`), which counts like a ruling refusal (K-14; a policy's own `classifierNoRuling`, or a card another station minted, counts nothing): at the bound the same call goes to the same seat again as the denial-limit fallback, and a deny nobody judged there stops the run through the existing headless arm. Without a seat: `error` / `timeout` refused with `x1t`, uncounted; `parse_error` refused with its 7.42.0 sentence and counted (the bound falls back to a person; with nobody there the run stops). A reply that ruled or contradicted itself is unchanged. Every such classification raises `classifier.unavailable` (`detail.cause` gains `parse_error`; "the classifier's reply carried no verdict").
123
+ - Class `classifier_no_ruling` carries the denial-limit fallback's arbitration row (bypass-immune, never put to the classifier again, no sandbox admission, no allow rule clears it) and explains the real-approval bit, so the card no longer reads `policy_rra`.
124
+ - **Who is hurt.** Under the same class the inherited two-frames collapse no longer merges the ancestor's "classifier did not rule" card with the child's own real-approval question on the same call: one more card. A shell on core 7.43.0 that has not yet declared `approvalReach` (cli ≤1.0.142 on `-p`) and refuses every card by policy: each reply with no verdict now shows a card (refused), and the third such call shows a SECOND refused card (the denial-limit fallback) before the run stops — 7.42.x refused the first two silently and stopped on the third's fallback. A host that answers or deduplicates cards by class: a new class word; a host whose card identity hashes the class set sees a new identity for this card (inferred). A probe that pinned "a reply with no verdict is refused and counted" with a live approver.
125
+ - **Compensation.** Read `classifierNoRuling` (or `classes[0] === "classifier_no_ruling"`) to render the cause; the second paragraph stays for clients that do not. A deployment that wants the CC route (refuse, never ask) for this case runs that leg with no approval seat.
126
+ - **Consumers.** server — the member on `tool_approval` frames, the park row and the summary; the class word in its frame projection and key-set guard; `classifier.unavailable` `detail.cause` +`parse_error` (mirror). cli (L-1059) — render the cause from the member / class; the shell writes no sentence of its own. client-core — `AskClass` +1, `AskRequest.classifierNoRuling`, the notice cause +1. test — UX-7 tui-missing flips on the engine side (the card face waits on L-1059); `c1b-auto-cls-unparsable` re-cores.
127
+ - **Known limits.** A durable decide made by the deployment's policy on a PARKED no-ruling card is settled on the resumed leg and counts nothing (K-14 counts at the synchronous seat). KL-1353-a (the park wiring self-check's stub does not yet carry the new twin).
128
+ - Pins: `test/backlog1353-classifier-no-ruling-arm.test.ts` (⑩–⑯, ⑲, K-14; real Runner + the inherited station); re-pinned to the one arm — a cell whose subject is the seat's route moves to the person (661 four-call cell, design276 E4, 1214 F1, 1111's explainer list), a cell whose subject is the classifier's verdict runs with no seat and keeps its assertion (618, 661 ⑤ cells, 742, 805); the arm's rename (1125); the inherited arms' source scan (permission-rule-wiring).
129
+
130
+ ### A tool name that can never match is refused by one judgment — a peer segment no mint spells, and every allow list's entries (#1355 (b), #1358; behavior) (@server @cli @client-core @test)
131
+
132
+ - **Before (v7.42.x).** `judgeRuleToolName` knew three words (`current`, `retired`, `unprefixed_namespace`) and answered `current` for `mcp__github.com__*`: rules compare peer segments exactly and every mounted tool carries the minted spelling (`github.com` → `github_com`), so the rule reached nothing, ever. A caller policy with such a deny ran with only the advisory `config.toolpolicy.unmatched_names`; `putRules({ toolDeny: ["mcp__github.com__*"] })` was accepted. The three allow lists never put an entry's tool name to any judgment: `bash`, `github__x`, `mcp__github.com__*` and `write` (the run mounts `Write`) each read as an entry that grants nothing and nothing said so — the person who listed it believed the tool was allowed, and the card came anyway.
133
+ - **Now.** The judgment's fourth word is `unmintable_segment`: a name with a protocol prefix has its peer segment re-minted (`mintNamespacePrefix`, the mint's own arithmetic); a segment the mint spells differently is unmintable, and `currentName` is the whole name rewritten with the minted segment (`mcp__github.com__*` → `mcp__github_com__*`; the tool segment is carried verbatim, never judged). An empty peer (`mcp____x`) is unmintable with no `currentName`. Its readers render it beside the other two stale words — the prepare audit (every caller policy, a session's stored rows and an ancestor's rules feed it) fails the run with `config.legacy_tool_name`; the session store's `putRules` refuses with `legacy_tool_name` and `detail.verdict: "unmintable_segment"`; removing such a stored `toolDeny` entry is not a loosening. One decomposition of a namespaced name (`splitNamespacedName`) is read by the judgment, the covering matcher and the allow-entry grammar.
134
+ - **The allow lists** read an entry's tool name (a bare name, or the `Tool(` part of a content entry) against the roster of the run: a stale name reads as `tool_name` (`detail.entries[].verdict` + `currentName` when there is one), a name the run mounts only under another case as `near_miss` (`currentName` = the mounted spelling); both read as nothing and are announced. Only the near miss is reported, never the merely unmounted name (C-R245): a name the run simply does not mount (`Wrte`, a real tool of another scenario, a server this run has no connection to) is SILENT — a list shared across runs is not wrong because one run lacks a tool. A retired name the deployment mounts as its own tool (`Task`) stays an ordinary entry (the roster clears it). `Skill(<name>)` is not judged: a skill's name is not a tool name. The Skill card names only the entries that read as rows.
135
+ - `compileAllowEntry(raw, behavior, roster?)` / `compileAllowList(entries, behavior, roster?)`: absent, the grammar alone is read (a resume's pre-claim screen, where no roster exists yet; a carried record is read against the leg's roster when it is seeded — #1372). The deployment's grammar readings are still announced once at construction; its name readings (a fact about a run's roster) once per run.
136
+ - **Who is hurt.** A deployment that wrote a deny / ask (caller policy, a settings file the serving layer compiles, a stored session row) naming a peer segment the mint never produces — a server called `github.com`, `my server`, a long name written out in full: today it runs with a mute rule (and an advisory); from this version the run refuses to start with the sentence naming the whole rule name to write, and the store refuses the write (an older release's stored row can be removed by its owner). A consumer that switches exhaustively over `RuleToolNameVerdict["kind"]`, `SessionRuleNameRefusal.verdict` or `AllowEntryInertReason`. A user whose allow entry is a case twin or a retired name: one more notice at the start of a run (audience `user`); the entry granted nothing before and still grants nothing, and the card still comes. A deployment whose `RunnerDeps.allowedTools` holds a stale name: one notice per run for it (not only one at construction).
137
+ - **Compensation.** Respell the rule with the `currentName` in the sentence (or build the spelling with `mintNamespacePrefix`); spell an allow entry as the tool is mounted. A retired name the deployment mounts as its own tool needs nothing. To keep a list shared across runs quiet, nothing is needed: an unmounted name is silent.
138
+ - **Consumers.** server (S-1113) — `unmintable_segment` replaces any comparison of a rule's segment with `mintNamespacePrefix`'s answer (exported in 7.41.1); mirror the word in the `legacy_tool_name` detail projection; a config-time `compileAllowList` passes the run's mounted names when it knows them (else the grammar alone is read); the notice `entries[]` gain `verdict?` / `currentName?`. client-core — `RuleToolNameVerdict` +1, `AllowEntryInertReason` +2. cli — render a `tool_name` / `near_miss` entry from `detail.entries[]` (`verdict`, `currentName`); the shell writes no sentence of its own. test — a case twin in an allow list still asks, with a notice.
139
+ - **Known limits.** A namespaced name's TOOL segment is never judged (an unminted literal tool segment stays the audit's advisory); a covering allow entry whose peer differs only by case (`mcp__GitHub__*`) is silent — a covering name has no mounted spelling to compare with; `near_miss` compares with the tools mounted when the list is read (the `Skill` tool, the memory pair and `ToolSearch` mount after it, and a server that connects later is not seen — a case twin of one of those is silent; a name of an engine tool is never a near miss, so a deployment's own `SKILL` beside the engine's `Skill` costs no exact entry; and a protocol tool's name — `mcp__…`, `a2a__…` — is never a near miss either: a refresh mounts and unmounts those while the run goes on, so a case twin (`mcp__srv__delete` beside a mounted `mcp__srv__Delete`) stays an ordinary row and is silent); an exact name the run does not mount stays silent by design.
140
+ - Pins: `test/backlog1355b-unmintable-segment-judge.test.ts` (①–⑪: the three spellings, the law over a peer table, empty peer, clamped peer, the store's write, `loosenReasons`, the prepare audit through a real Runner — both policy factories, a stored row, a second protocol — and the minted spelling / exact unknown name / Skill name as positive controls); `test/backlog1358-allow-entry-tool-names.test.ts` (①–⑫: one reading per entry shape with and without a roster, the list detail, the deny direction, the closed set, the three sources through a real Runner, the roster reaching every source, the Skill card, the deployment's once-per-run, near miss, `Skill(<name>)`, boundary controls, an activated skill's rows); re-pinned the verdict set (backlog1156).
141
+
142
+ ### A Skill card says what approving it releases — one value on the live ask, an ancestor's ask, the parked row and the inbox projection (#1348 / #1357; behavior) (@server @cli @client-core @test)
143
+
144
+ - **Before (v7.42.x).** The card of a call to the engine's `Skill` tool told the person what approving it releases only on the live ask, and only as a sentence (`Execute skill: <name>` / "Approving also lets these tools be used without asking …" in `AskRequest.message`). The parked row carried a hash of the skill and its two lists (`boundGrantDigest`) and nothing that says what the hash covers, and `CheckpointSummary` projected neither: the approval inbox showed `Skill({"skill":"sa"})` and an approver could not see what the click releases. The live `AskRequest` carried no binding at all, so a server could not reconcile a live Skill card against its parked twin, and its "allow this session" for a Skill card could only be refused (fail-closed — stricter than the reference, which remembers per skill).
145
+ - **Now.** One factory (`skillGrantBindingMembers`) mints the card's binding for a call to the engine's `Skill` tool: `boundGrants: { skill, allowedTools, disallowedTools }` (the skill as the table spells it, `null` when the call names no loaded skill; the two lists in the rule grammar's canonical spelling, de-duplicated, sorted) and `boundGrantDigest`, the hash of exactly that value. Every ask-mint site spreads it (`grantBindingOf`, five sites): the live `AskRequest` of the leg's own gate, the three inherited stations' (an ancestor's frozen approver), and the parked row; the inbox projection echoes the row's two members (the digest as a string; the lists read whole or not at all, as a fresh copy, and only when they hash to that digest — a stored pair that disagrees, or lists beside no digest, publish the digest alone). The binding is a property of the CALL — whoever asked and however the question was worded (a policy's or a hook's `message`, the engine's unstated sentence) — and every other card carries neither member. The digest's bytes are unchanged (the same skill and lists hash to the same value as in 7.42.x), the resume still judges the digest alone and never reads `boundGrants`, and `AskRequest.message` keeps its sentence. `skillCallGrantDigest` (internal) is retired; its one reader asks the factory.
146
+ - **Who is hurt.** Nobody who read the old members: the additions are optional and every existing member is byte-identical. A consumer that keys an answer on the TOOL NAME alone for a Skill card (a session-scoped "allow this skill" keyed on `Skill`) can now key on "tool + `boundGrantDigest`" — a skill whose lists change has a new digest and is asked again; a consumer that does not read the new members behaves exactly as before. A client that renders `boundGrants` as "lets these be used without asking" overstates: the sentence in `message` names only entries that release something in the leg's mode.
147
+ - **Compensation.** None needed. A reader that wants the sentence's exact reading runs each entry through `compileAllowEntry(entry, "allow")` (an inert entry answers a reason word, and in `auto` mode an entry the mode strips releases nothing); otherwise render the list as what the skill declares.
148
+ - **Consumers.** server (S-1115) — pass the new members through the `tool_approval` frame and the `GET /v1/approvals` projection from `CheckpointSummary.boundGrantDigest` / `boundGrants`, and key the "allow this session" answer for a Skill card on "tool + `boundGrantDigest`" (no digest ⇒ not a Skill activation); cli — render the inbox card's "approving releases" list from `boundGrants` (the declared lists the live card's sentence is a mode-narrowed rendering of; the deployment's text, with contextual escaping, capped as the live sentence caps it); client-core — mirror `BoundSkillGrants` and the five optional members on `AskRequest` / `PendingAction.tool_approval` / `CheckpointSummary`; test — reconcile a live Skill card against its parked twin by `boundGrantDigest`.
149
+ - **Known limits.** None. (The member is the skill's declared lists whole — a skill that declares very long lists grows its row and every summary by that much; capping it would stop the member being the value its digest hashes.)
150
+ - Pins: `test/backlog1348-card-grant-disclosure.test.ts` ①–⑫ (real Runner; the in-memory and the file stores; the inherited lane at each of its three stations, presence and absence; the projection's digest check; the factory's unit face, the digest's bytes frozen at the 7.42.x value) — baseline 24 red / 5 green controls (⑤, ⑤b, ⑧a, ⑧b, ⑨d); mutation table `M1`–`M11` (the inherited lane per station), every mutation turns at least one fixture red.
151
+
152
+ ### A skill's `disallowed-tools` entry that reads as nothing refuses what it guards — the activation, or the leg that carries it (#1372; behavior narrowing) (@server @cli @client-core @test)
153
+
154
+ - **Before (v7.42.x).** An entry the list grammar reads as nothing was one grant less on `allowed-tools` (the safe direction) and one refusal less on `disallowed-tools`, with the same treatment: a notice (audience `user`) and the skill went on. A skill with `disallowed-tools: ["Bash "]` (a surrounding space; 7.41.x read it as `Bash`), `["bash"]` (a retired name, 7.43.0's #1358) or `["probe"]` (the run mounts `Probe`) activated on the model's `Skill` call, on an approved parked activation and on `userInvokedSkills`, and the tool it named ran. A resumed leg re-supplied with such a skill ran the tool too (the server's stored body of a task parked on 7.41.x). A park's frozen deny record that the grammar alone could read but the leg's roster could not (`bash`, `probe` — a row written before the names were judged) was seeded as a row that matched nothing; a parent's captured record that read as nothing was skipped. A frozen record the grammar could not read (`"Bash "`) was already refused before the claim (`checkpoint.invalid_outcome`, `reason: "skill_grants_damaged"`) with a "corruption guard" sentence. A resume whose `userInvokedSkills` named a skill the config did not carry was refused by prepare AFTER the claim (`config.user_invoked_skill_unknown`; the row reopened).
155
+ - **Now.** One reading decides it — `compileAllowList(entries, "deny", roster)`'s inert half, the notice's own (`unreadDenyOf`) — at the run's skill deny read and at the table's two writers. A call to the engine's `Skill` tool naming such a skill is refused where the run's skill deny rows are read (the lane's `denying`: every gate decision, the approved-edit round, the resumed execution boundary): `tool_end.isError: true`, `gate.disposition: { kind: "denied", deniedBy: "policy" }`, `decisionReason: "skill"`, the sentence "Skill "<name>" was not activated: its disallowed-tools entry "Bash " (grammar) reads as nothing — … nothing it lists is granted"; no card is asked and nothing parks, with an approver, with none, under `bypassPermissions`, with a durable approval park; a person who edits a card onto such a skill is refused on the edited bytes; a Skill card parked before the upgrade (its deny entry read then) is refused at the execution boundary ("The approved tool call "Skill" was not executed: …"; the approval is spent). `SkillGrantTable.activate` keeps the same refusal for every activation that reaches the table and writes no row: a person's invocation refuses the run before any call (`config.skill_deny_unreadable` on `terminal.code`). `SkillGrantTable.seed` reads every carried record against the leg's roster: a deny record that reads as nothing refuses the leg with the same code (the sentence says a parked task an earlier release wrote can be finished on that release's worker); an allow record that reads as nothing is not held (it matched no mounted tool — `activeSkillGrants()` is one row shorter, no call's answer moves). A resume's pre-claim screen seeds the re-supplied invoked skills with prepare's own function and no roster, so what no roster changes (a name the config does not carry; a deny entry the grammar alone reads as nothing — `grammar`, `wildcard_all`, `namespace_wildcard`, `path_pattern`, `domain`, `content_unsupported`) is refused before the claim (`checkpoint.config_refused`, `detail.code` = prepare's code, `detail.field: "taskConfig"`, the row untouched; the corrected config resumes the same token); a name only the leg's roster reads as nothing (a retired name — a deployment may mount its own tool under it — or a case twin) is refused by prepare after the claim and the row reopens for a retry of the recorded decision. The frozen-section refusal now names the earlier-release case. The notice and the refusal render an entry one way (quoted as written, so a surrounding space shows); an allow-only notice is byte-identical, a notice naming a `disallowed-tools` entry gains "Such an entry in a disallowed-tools list keeps its Skill from activating".
156
+ - **Who is hurt.** A task parked on an earlier release whose frozen grant rows carry a deny entry this release reads as nothing — a 7.42.x row naming a retired tool (`bash`) or a case twin of a mounted tool, or (refused since 7.42.0) a 7.41.x row with a surrounding space — cannot be resumed here at all: every resume is refused and the row stays pending. The `config.user_invoked_skill_unknown` door, refused after the claim since 7.41.0, is now refused before it and the row is not touched. A skill whose `disallowed-tools` lists a form sema cannot enforce no longer activates (it used to activate with that refusal missing): `*` (deny every tool), `mcp__*`, `WebFetch(domain:…)`, `Skill(<name>)`, a content form of any tool outside the shell and path grammars (`WebSearch(…)`, `mcp__srv__x(…)`), a path pattern the path grammar refuses, an entry with a surrounding space, a retired tool name, a case twin of a mounted tool. A delegated child whose roster reads one of the parent's captured deny records as nothing (it mounts a case twin, or not the deployment's own tool a retired name named) does not start. A run that invokes such a skill by name ends `failed` before any call.
157
+ - **Compensation.** Finish a parked task on a worker of the release that parked it, or expire it and submit the task again with the entry corrected. Spell each `disallowed-tools` entry as the tool is mounted; deny a whole tool by name (`WebFetch`, `mcp__<server>`) where the content form has no sema reading; a deny that must cover every tool belongs in a caller policy or a stored rule, not a skill's list.
158
+ - **Consumers.** server — the model's `Skill` call for such a skill arrives as `tool_end.gate.disposition` `{ kind: "denied", deniedBy: "policy" }` with no card and no park (it never reaches the approvals inbox); the request door already refuses an unreadable deny entry with 400 (7.117.0) and its resume replay judges the stored body the same way; a `checkpoint.config_refused` can now carry `detail.code` `config.user_invoked_skill_unknown` / `config.skill_deny_unreadable` (correct `taskConfig` and resume the same token); a `failed` resumed leg with `config.skill_deny_unreadable` whose sentence names "grant rows carried" cannot be resumed on this release (drain on the release that parked it, or expire). cli — a refused `Skill` call is an ordinary refusal (`denied` / `policy`, the sentence names the entry and the reason): no card was asked, nothing special to render; a `-p` run invoking such a skill fails before any call. client-core — `CheckpointConfigRefusalCode` mirror +2; the new terminal code. test — the three entries (model route, user route, resume) × the three readings (grammar, retired name, case twin), the cross-release frozen row, and the allow side unchanged, all wire-observable (`tool_end.gate` / `isError` / `output`, no `AskRequest`, `terminal.code`, `CheckpointError.detail`).
159
+ - **Known limits.** KL-1372-a (the deny forms sema cannot yet enforce on a skill's list — the skill does not activate) · KL-1372-b (a name-level refusal of a carried record happens after the claim: the leg's roster does not exist before it).
160
+ - Pins: `test/backlog1372-skill-deny-unreadable.test.ts` ①–⑩ (real Runner; the in-memory checkpoint store; a store view rewritten to the row an earlier release wrote; ⑩ the gate refusal × approver / none / bypass / durable park / approved edit, with controls) — baseline 37 red / 10 green controls on the release base (`d30b88d4f`; 12 red / 35 green on the first fix `0b26be89e`: the ⑩ gate cells and ④); mutation table `.gate-logs/criteria-7430-mutations-1372.md` M1–M9 (model route at the table / user route / carried record / allow side refused / name layer uncounted / no pre-claim screen / the table never refuses / the deny read never refuses / both — the two layers each red on their own fixtures); re-pinned 1303 ⑬a′ (a deny list holding `*` refuses the activation) and 1358 ⑫ (a stale deny name refuses it); the engine's `Skill` tool is built over the run's grant table only (no table-less construction — six test files pass one).
161
+
162
+ ### The allow tables dedupe through a set — seeding is linear in the list (#1370; performance, no behavior change) (@server)
163
+
164
+ - **Before (v7.42.x).** `AllowEntryTable.seed` checked every incoming entry against every held one (`held.some(...)`), and the skill grant table's `hold` did the same per row: 1 000 entries 153 ms, 10 000 2.1 s, 20 000 6.4 s, synchronously on the event loop (server's reading on the installed 7.42.0).
165
+ - **Now.** Both tables keep a key set beside their rows (the entry as written; the skill row's skill / origin / behavior / entry). The rows held, their order, the entries returned as inert and every notice are unchanged. The engine still sets no bound on the length of `TaskSpec.allowedTools` — a bound is the host's to set.
166
+ - Pins: `test/backlog1370-allow-table-seed-linear.test.ts` (dedupe and order unchanged across re-seeding; neither method's source scans the held rows per entry).
167
+
168
+ ### `abortedSeatDeny` is exported from the package root (#1371; surface) (@server)
169
+
170
+ - **Before.** The `task_aborted`-attested deny that every engine policy factory answers under an already-aborted signal lived in `src/core/tool-policy.ts` only: a deployment's own policy closure that wanted to follow the cancellation contract could not reach it, and a deny it built by hand was read by the synthetic-Read judge as a refusal (the other half of KL-1311a-a).
171
+ - **Now.** `import { abortedSeatDeny } from "@sema-agent/core"` — `abortedSeatDeny(req, message, origin)`; INTEGRATION §4.6j states the calling shape. Exports +1.
172
+ - Pins: `test/backlog1371-aborted-seat-deny-export.test.ts`; `test/export-surface.snapshot.json` (+1 name).
173
+
1
174
  ## 7.42.1 — 2026-10-05
2
175
 
3
176
  **Hard-breaking changes:** none. **Closed sets:** none added. **Package exports:** +1 / −0 — `steerPriorityOf`. **Behavior narrowing:** none (one reading replaces two; a replay that spelled the default is no longer refused).
@@ -1040,6 +1040,7 @@ export function createSendMessageTool(opts) {
1040
1040
  ...(ctx.writeFace === "roots" ? { writeFaceRoots: true } : {}),
1041
1041
  ...(ctx.readDenyPatterns !== undefined ? { readDenyPatterns: ctx.readDenyPatterns } : {}),
1042
1042
  ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
1043
+ ...(ctx.approvalReach === "none" ? { approvalReach: "none" } : {}),
1043
1044
  ...(ctx.oneShot === true ? { oneShot: true } : {}),
1044
1045
  ...(ctx.memoryPersistenceCapable === false ? { memoryPersistenceCapable: false } : {}),
1045
1046
  ...(ctx.parentSpecSeat?.memoryOff === true ? { memoryOff: true } : {}),
@@ -361,7 +361,8 @@ export interface SubagentSteerHandle {
361
361
  * contract, value domain and typed refusals are that verb's; absent ⇒ byte-identical to every
362
362
  * pre-existing call). ⚠️ Same deliberate arm gap as the workflow twin: each call mints a FRESH
363
363
  * correlation marker into the framing, so a retry under the same id is a same-id-DIFFERENT-
364
- * instruction call and refuses typed `steering.duplicate_input_id` — the key buys AT-MOST-ONCE
364
+ * instruction call and refuses typed `steering.duplicate_input_id` (`detail.differs` always names `text`
365
+ * here) — the key buys AT-MOST-ONCE
365
366
  * (loud refusal), never silent idempotent replay. */
366
367
  steer: (content: string, opts?: {
367
368
  inputId?: string;
@@ -444,6 +445,7 @@ export declare function createSubagentResume(deps: {
444
445
  currentClamps?: {
445
446
  handsReadOnly?: true;
446
447
  interactiveTools?: false;
448
+ approvalReach?: "none";
447
449
  oneShot?: true;
448
450
  memoryPersistenceCapable?: false;
449
451
  memoryOff?: true;
@@ -1,7 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { applyParentSpecSeat, memoryTurnedOff, parentSpecSeatOfCtx } from "../core/parent-spec-seat.js";
4
- import { childClampSeats, engineHeadlessLegSeats } from "../core/child-clamp-seats.js";
4
+ import { childClampSeats, engineHeadlessLegSeats, postureUnderClamps } from "../core/child-clamp-seats.js";
5
5
  import { stripSpawnTurnQuestionFace } from "../core/ask-question.js";
6
6
  import { withDelegationProvenance } from "../core/tool-policy.js";
7
7
  import { errorResult } from "../core/tools.js";
@@ -546,6 +546,7 @@ export function createSubagentResume(deps) {
546
546
  ...childClampSeats({
547
547
  handsReadOnly: entry.specSnapshot.handsReadOnly === true || deps.currentClamps?.handsReadOnly === true,
548
548
  interactiveTools: entry.specSnapshot.interactiveTools === false || deps.currentClamps?.interactiveTools === false ? false : undefined,
549
+ approvalReach: entry.specSnapshot.approvalReach === "none" || deps.currentClamps?.approvalReach === "none" ? "none" : undefined,
549
550
  readFace: entry.specSnapshot.readFace === "roots" || deps.currentClamps?.readFaceRoots === true ? "roots" : entry.specSnapshot.readFace,
550
551
  writeFace: entry.specSnapshot.writeFace === "roots" || deps.currentClamps?.writeFaceRoots === true ? "roots" : entry.specSnapshot.writeFace,
551
552
  readDenyPatterns: [...(entry.specSnapshot.readDenyPatterns ?? []), ...(deps.currentClamps?.readDenyPatterns ?? [])],
@@ -628,6 +629,7 @@ export function createSubagentResume(deps) {
628
629
  stream = childRunner.runTaskStream(resumeSpec, undefined, {
629
630
  ...entry.internalsSnapshot,
630
631
  cycleSeq: entry.cycleSeq,
632
+ parentInteractionPosture: postureUnderClamps(entry.internalsSnapshot.parentInteractionPosture, { interactiveTools: resumeSpec.interactiveTools, approvalReach: resumeSpec.approvalReach }),
631
633
  ...(true
632
634
  ? {
633
635
  onForwardEvent: (e) => {
@@ -1906,7 +1908,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1906
1908
  ...(ctx.restoreGatedTools !== undefined ? { restoreGatedTools: ctx.restoreGatedTools === true ? true : [...ctx.restoreGatedTools] } : {}),
1907
1909
  ...(ctx.promptProfile !== undefined ? { promptProfile: ctx.promptProfile } : {}),
1908
1910
  ...engineHeadlessLegSeats(),
1909
- ...childClampSeats({ handsReadOnly: ctx.handsReadOnly, interactiveTools: ctx.interactiveTools, readFace: ctx.readFace, writeFace: ctx.writeFace, readDenyPatterns: ctx.readDenyPatterns }),
1911
+ ...childClampSeats({ handsReadOnly: ctx.handsReadOnly, interactiveTools: ctx.interactiveTools, approvalReach: ctx.approvalReach, readFace: ctx.readFace, writeFace: ctx.writeFace, readDenyPatterns: ctx.readDenyPatterns }),
1910
1912
  enableBlockedReport: false,
1911
1913
  limits: {
1912
1914
  maxTurns: typeof observerDef.maxTurns === "number" && Number.isFinite(observerDef.maxTurns) && observerDef.maxTurns > 0 ? observerDef.maxTurns : 8,
@@ -2179,7 +2181,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2179
2181
  ...(def?.memoryPersistenceCapable !== undefined ? { memoryPersistenceCapable: def.memoryPersistenceCapable } : {}),
2180
2182
  ...(ctx.checkpointStoreForChildren !== undefined ? { checkpointStore: ctx.checkpointStoreForChildren } : {}),
2181
2183
  ...(childOnAsk !== undefined ? { onAsk: childOnAsk } : {}),
2182
- ...childClampSeats({ handsReadOnly: ctx.handsReadOnly, interactiveTools: ctx.interactiveTools, readFace: ctx.readFace, writeFace: ctx.writeFace, readDenyPatterns: ctx.readDenyPatterns }),
2184
+ ...childClampSeats({ handsReadOnly: ctx.handsReadOnly, interactiveTools: ctx.interactiveTools, approvalReach: ctx.approvalReach, readFace: ctx.readFace, writeFace: ctx.writeFace, readDenyPatterns: ctx.readDenyPatterns }),
2183
2185
  ...(ctx.excludeTools !== undefined ? { excludeTools: [...ctx.excludeTools] } : {}),
2184
2186
  ...(ctx.deferTools !== undefined ? { deferTools: [...ctx.deferTools] } : {}),
2185
2187
  ...(ctx.alwaysLoadTools !== undefined ? { alwaysLoadTools: [...ctx.alwaysLoadTools] } : {}),
@@ -257,7 +257,7 @@ async function runTeacherCore(runner, studentSpec, teacher) {
257
257
  ...(studentSpec.principal !== undefined ? { principal: studentSpec.principal } : {}),
258
258
  ...(studentSpec.clientContext !== undefined ? { clientContext: studentSpec.clientContext } : {}),
259
259
  ...(studentSpec.promptProfile !== undefined ? { promptProfile: studentSpec.promptProfile } : {}),
260
- ...childClampSeats({ handsReadOnly: studentSpec.handsReadOnly, interactiveTools: studentSpec.interactiveTools, readFace: studentSpec.readFace, writeFace: studentSpec.writeFace, readDenyPatterns: studentSpec.readDenyPatterns }),
260
+ ...childClampSeats({ handsReadOnly: studentSpec.handsReadOnly, interactiveTools: studentSpec.interactiveTools, approvalReach: studentSpec.approvalReach, readFace: studentSpec.readFace, writeFace: studentSpec.writeFace, readDenyPatterns: studentSpec.readDenyPatterns }),
261
261
  ...(studentSpec.excludeTools !== undefined
262
262
  ? { excludeTools: Array.isArray(studentSpec.excludeTools) ? [...studentSpec.excludeTools] : studentSpec.excludeTools }
263
263
  : {}),
@@ -155,6 +155,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
155
155
  ...childClampSeats({
156
156
  handsReadOnly: specBase.handsReadOnly,
157
157
  interactiveTools: specBase.interactiveTools,
158
+ approvalReach: specBase.approvalReach,
158
159
  readFace: foldedRootsFace ? "roots" : undefined,
159
160
  writeFace: foldedRootsWriteFace ? "roots" : undefined,
160
161
  readDenyPatterns: foldedReadDeny,
@@ -261,6 +262,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
261
262
  ...childClampSeats({
262
263
  handsReadOnly: specBase.handsReadOnly,
263
264
  interactiveTools: specBase.interactiveTools,
265
+ approvalReach: specBase.approvalReach,
264
266
  readFace: foldedRootsFace ? "roots" : specBase.readFace,
265
267
  writeFace: foldedRootsWriteFace ? "roots" : specBase.writeFace,
266
268
  readDenyPatterns: foldedReadDeny,
@@ -147,6 +147,14 @@ export declare function extractErrorCode(errorMessage: string | undefined): Brai
147
147
  * human text. Pairs with {@link extractErrorCode} so a caller can lift the code and clean the message
148
148
  * without re-hardcoding the code list (the Runner does both when assembling `TaskResult`). */
149
149
  export declare function stripErrorCodePrefix(errorMessage: string): string;
150
+ /**
151
+ * The code a deployment's outbound dispatcher stamps on the error of a proxy that answered the tunnel request
152
+ * (`CONNECT`) with anything but 200, with the HTTP status the proxy gave in `statusCode` on the same node. Not a
153
+ * runtime code — undici spells that refusal with prose and no stable code — so it is defined here, once, for the
154
+ * deployment to mint and for {@link DETERMINISTIC_CONNECT_CODES} to read. The proxy ANSWERED: re-sending the same
155
+ * request through the same proxy only changes that answer if someone changes the proxy's rules.
156
+ */
157
+ export declare const PROXY_TUNNEL_REFUSED = "ERR_PROXY_TUNNEL_REFUSED";
150
158
  /**
151
159
  * Whether a transport-level connect failure is DETERMINISTIC (see {@link DETERMINISTIC_CONNECT_CODES})
152
160
  * or gives no such verdict. Classification is by `code` along the error graph — never by message text,
@@ -165,7 +173,18 @@ export declare function stripErrorCodePrefix(errorMessage: string): string;
165
173
  * pre-existing behavior.
166
174
  */
167
175
  export type ConnectFailureClass = "deterministic" | "indeterminate";
176
+ /** What one walk of a transport error's graph found: the verdict, and — only with a `deterministic` one — the proxy
177
+ * refusal in it, if any (`status` absent when the node states none readable). One walk answers both, so the verdict
178
+ * and the sentence it licenses cannot disagree about which node spoke. */
179
+ export interface ConnectFailureReading {
180
+ verdict: ConnectFailureClass;
181
+ proxyRefusal?: {
182
+ status: number | undefined;
183
+ };
184
+ }
185
+ export declare function readConnectFailure(e: unknown): ConnectFailureReading;
168
186
  export declare function classifyConnectFailure(e: unknown): ConnectFailureClass;
187
+ export declare function proxyTunnelRefusedSentence(status: number | undefined): string;
169
188
  /** What a throw out of `fetch` was about: the NETWORK (retryable — the historic reading of every throw
170
189
  * here), or the REQUEST ITSELF, which is deterministic: identical arguments reproduce it every time. */
171
190
  export type FetchThrowClass = "transport" | "request_unbuildable";
@@ -174,8 +193,10 @@ export type FetchThrowClass = "transport" | "request_unbuildable";
174
193
  * throws a `TypeError` with no `code` and no `cause`, so the connect classifier answered indeterminate and
175
194
  * the call spent its whole backoff budget on a request the runtime never built).
176
195
  *
177
- * ONE question — is there TRANSPORT EVIDENCE? — in its two forms: the runtime's own shell sentence, or a
178
- * `cause` that is a transport fact rather than a parse verdict. A `TypeError` with neither was never built.
196
+ * ONE question — is there TRANSPORT EVIDENCE? — in its three forms: the runtime's own shell sentence, a
197
+ * `cause` that is a transport fact rather than a parse verdict, or a graph the connect judge reads as a
198
+ * definite answer ({@link classifyConnectFailure}: the same table, the same unanimity). A `TypeError` with none
199
+ * was never built.
179
200
  * Measured over ten real shapes; non-`TypeError` stays on the transport arm and an unreadable value answers
180
201
  * `transport`, the pre-existing behavior. Stated cost: KL-933a. */
181
202
  export declare function classifyFetchThrow(e: unknown): FetchThrowClass;
@@ -138,8 +138,13 @@ function hasStaleConnectionCode(e) {
138
138
  }
139
139
  return false;
140
140
  }
141
- const DETERMINISTIC_CONNECT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND"]);
141
+ export const PROXY_TUNNEL_REFUSED = "ERR_PROXY_TUNNEL_REFUSED";
142
+ const DETERMINISTIC_CONNECT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", PROXY_TUNNEL_REFUSED]);
142
143
  const CONNECT_GRAPH_NODE_BUDGET = 16;
144
+ function proxyStatusOf(node) {
145
+ const status = readMember(node, "statusCode");
146
+ return typeof status === "number" && Number.isInteger(status) && status < 600 && namesTheFailure(status) ? status : undefined;
147
+ }
143
148
  function walkConnectFailure(node, budget) {
144
149
  if (budget.left <= 0)
145
150
  return "indeterminate";
@@ -151,6 +156,8 @@ function walkConnectFailure(node, budget) {
151
156
  if (typeof code === "string") {
152
157
  if (!DETERMINISTIC_CONNECT_CODES.has(code))
153
158
  return "indeterminate";
159
+ if (code === PROXY_TUNNEL_REFUSED && budget.proxy === undefined)
160
+ budget.proxy = { status: proxyStatusOf(node) };
154
161
  verdict = "deterministic";
155
162
  }
156
163
  if (!isErrorLike(node))
@@ -176,8 +183,23 @@ function walkConnectFailure(node, budget) {
176
183
  }
177
184
  return verdict;
178
185
  }
186
+ export function readConnectFailure(e) {
187
+ const walk = { left: CONNECT_GRAPH_NODE_BUDGET };
188
+ if (walkConnectFailure(e, walk) !== "deterministic")
189
+ return { verdict: "indeterminate" };
190
+ return walk.proxy === undefined ? { verdict: "deterministic" } : { verdict: "deterministic", proxyRefusal: walk.proxy };
191
+ }
179
192
  export function classifyConnectFailure(e) {
180
- return walkConnectFailure(e, { left: CONNECT_GRAPH_NODE_BUDGET }) === "deterministic" ? "deterministic" : "indeterminate";
193
+ return readConnectFailure(e).verdict;
194
+ }
195
+ const PROXY_TUNNEL_REFUSED_SENTENCES = new Map([
196
+ [407, "proxy authentication required (HTTP 407)"],
197
+ [403, "proxy forbids this destination (HTTP 403)"],
198
+ ]);
199
+ export function proxyTunnelRefusedSentence(status) {
200
+ if (status === undefined)
201
+ return "proxy refused the tunnel";
202
+ return PROXY_TUNNEL_REFUSED_SENTENCES.get(status) ?? `proxy refused the tunnel (HTTP ${status})`;
181
203
  }
182
204
  const FETCH_TRANSPORT_SHELLS = new Set(["fetch failed", "terminated"]);
183
205
  const REQUEST_ARGUMENT_CODES = new Set(["ERR_INVALID_URL"]);
@@ -187,6 +209,8 @@ export function classifyFetchThrow(e) {
187
209
  const message = readMember(e, "message");
188
210
  if (typeof message === "string" && FETCH_TRANSPORT_SHELLS.has(message))
189
211
  return "transport";
212
+ if (classifyConnectFailure(e) === "deterministic")
213
+ return "transport";
190
214
  const cause = readMember(e, "cause");
191
215
  if (cause === undefined || cause === null)
192
216
  return "request_unbuildable";
@@ -27,7 +27,8 @@ export interface StreamEngineConfig extends BrainTimeoutConfig {
27
27
  retryDelayMs?: number;
28
28
  /**
29
29
  * Retries allowed for a DETERMINISTIC connect failure — one whose error codes say the target itself
30
- * is not there (`ECONNREFUSED`, or a name with no address; see `errors.ts` `classifyConnectFailure`).
30
+ * is not there (`ECONNREFUSED`, or a name with no address) or that the proxy on the way refused the tunnel
31
+ * (`PROXY_TUNNEL_REFUSED`); see `errors.ts` `readConnectFailure`.
31
32
  * Absent ⇒ {@link DEFAULT_DETERMINISTIC_CONNECT_RETRIES}.
32
33
  *
33
34
  * These retries exist for one case only: a peer that is mid-restart and about to accept. Waiting
@@ -1,7 +1,7 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { STREAM_TORN_WARNING } from "./stream-shared.js";
3
3
  import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
4
- import { abortLikeName, BrainError, brainErrorCodeOf, classifyConnectFailure, classifyFetchThrow, classifyHttp, describeNetworkError, isProviderEchoShape, namesTheFailure, readProviderErrorCode, readProviderRequestId, refusedTheRequest } from "./errors.js";
4
+ import { abortLikeName, BrainError, brainErrorCodeOf, classifyFetchThrow, classifyHttp, describeNetworkError, isProviderEchoShape, namesTheFailure, proxyTunnelRefusedSentence, readConnectFailure, readProviderErrorCode, readProviderRequestId, refusedTheRequest } from "./errors.js";
5
5
  import { stripUrlCredentials } from "./route-adjudicator.js";
6
6
  import { classifyInputTooLong } from "./input-too-long.js";
7
7
  import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
@@ -531,7 +531,8 @@ export function runStreamingBrain(args) {
531
531
  const retryableByStatus = netErr !== undefined ||
532
532
  (r !== undefined && (r.status >= 500 || r.status === 429 || r.status === 408 || r.status === 409));
533
533
  const retryable = shouldRetryHeaderVerdict(r) ?? retryableByStatus;
534
- const deterministicConnect = netErr !== undefined && classifyConnectFailure(netErr) === "deterministic";
534
+ const connectReading = netErr !== undefined ? readConnectFailure(netErr) : undefined;
535
+ const deterministicConnect = connectReading?.verdict === "deterministic";
535
536
  const fastLane = deterministicConnect && deterministicLaneActive;
536
537
  const laneMaxRetries = fastLane ? deterministicConnectBudget : maxRetries;
537
538
  const errClass = netErr !== undefined
@@ -552,11 +553,8 @@ export function runStreamingBrain(args) {
552
553
  ? ` (the requested wait was longer than the cap; honoring ${Math.ceil(waitHint.ms / 1000)}s instead of ${Math.ceil(waitHint.rawMs / 1000)}s)`
553
554
  : "";
554
555
  const statusPhase = r?.status === 429 ? "rate_limited" : netErr !== undefined ? "reconnecting" : "retrying";
555
- const reconnectDetail = errClass !== "connect_refused"
556
- ? "connection lost, reconnecting"
557
- : fastLane
558
- ? "no endpoint answering, retrying briefly"
559
- : "no endpoint answering, retrying";
556
+ const refusedSubject = connectReading?.proxyRefusal !== undefined ? "proxy refused the tunnel" : "no endpoint answering";
557
+ const reconnectDetail = errClass !== "connect_refused" ? "connection lost, reconnecting" : fastLane ? `${refusedSubject}, retrying briefly` : `${refusedSubject}, retrying`;
560
558
  emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect", errClass, nextDelayMs: delayMs });
561
559
  announcedRetry = true;
562
560
  discardResponseBody(r);
@@ -580,8 +578,11 @@ export function runStreamingBrain(args) {
580
578
  attempt++;
581
579
  continue;
582
580
  }
583
- if (netErr)
584
- throw new BrainError("network", describeNetworkError(netErr), undefined, { apiError: true });
581
+ if (netErr) {
582
+ const chain = describeNetworkError(netErr);
583
+ const proxyRefusal = connectReading?.proxyRefusal;
584
+ throw new BrainError("network", proxyRefusal === undefined ? chain : `${proxyTunnelRefusedSentence(proxyRefusal.status)} — ${chain}`, undefined, { apiError: true });
585
+ }
585
586
  const detail = overflowBody ?? (r ? (await readErrorBody(r, signal)).text : "");
586
587
  const status = r?.status ?? 0;
587
588
  const itl = classifyInputTooLong(status, detail);