@sema-agent/core 7.51.2 → 7.53.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 (446) hide show
  1. package/CHANGELOG.md +698 -2
  2. package/README.md +3 -1
  3. package/dist/agents/agent-transcript-tool.d.ts +1 -1
  4. package/dist/agents/cascade.js +4 -0
  5. package/dist/agents/cross-session-envelope.d.ts +8 -0
  6. package/dist/agents/cross-session-envelope.js +6 -0
  7. package/dist/agents/cross-session-judge.d.ts +21 -1
  8. package/dist/agents/cross-session-judge.js +8 -0
  9. package/dist/agents/cross-session-ref.d.ts +5 -0
  10. package/dist/agents/cross-session-ref.js +1 -0
  11. package/dist/agents/cumulative-stats.d.ts +3 -0
  12. package/dist/agents/cumulative-stats.js +3 -0
  13. package/dist/agents/list-agents-tool.d.ts +1 -1
  14. package/dist/agents/org-delivery-gate.d.ts +122 -0
  15. package/dist/agents/org-delivery-gate.js +69 -0
  16. package/dist/agents/peer-held-queue.d.ts +9 -4
  17. package/dist/agents/peer-held-queue.js +6 -5
  18. package/dist/agents/peer-session-drain.d.ts +35 -5
  19. package/dist/agents/peer-session-drain.js +271 -180
  20. package/dist/agents/read-inbox-tool.d.ts +8 -1
  21. package/dist/agents/read-inbox-tool.js +5 -1
  22. package/dist/agents/repair-loop.js +4 -0
  23. package/dist/agents/send-message-tool.d.ts +11 -16
  24. package/dist/agents/send-message-tool.js +11 -13
  25. package/dist/agents/subagent.d.ts +3 -0
  26. package/dist/agents/subagent.js +105 -76
  27. package/dist/agents/tool-filter.d.ts +58 -13
  28. package/dist/agents/tool-filter.js +34 -14
  29. package/dist/bench/metrics.d.ts +1 -0
  30. package/dist/brain/anthropic.d.ts +6 -1
  31. package/dist/brain/anthropic.js +17 -13
  32. package/dist/brain/model-presets.d.ts +4 -5
  33. package/dist/brain/model-presets.js +6 -5
  34. package/dist/brain/open-responses.js +16 -12
  35. package/dist/brain/openai.js +16 -12
  36. package/dist/brain/request-params.d.ts +12 -33
  37. package/dist/brain/request-params.js +5 -2
  38. package/dist/brain/request-route.d.ts +9 -8
  39. package/dist/brain/request-route.js +1 -0
  40. package/dist/brain/route-adjudicator.d.ts +123 -47
  41. package/dist/brain/route-adjudicator.js +171 -42
  42. package/dist/brain/route-conformance.js +2 -2
  43. package/dist/brain/routing.d.ts +24 -1
  44. package/dist/brain/routing.js +54 -19
  45. package/dist/brain/status-sink.d.ts +18 -0
  46. package/dist/brain/status-sink.js +7 -0
  47. package/dist/brain/timeout.js +5 -4
  48. package/dist/core/a2a.d.ts +5 -2
  49. package/dist/core/a2a.js +30 -13
  50. package/dist/core/allow-entry.d.ts +42 -9
  51. package/dist/core/allow-entry.js +25 -18
  52. package/dist/core/auto-compaction.d.ts +80 -20
  53. package/dist/core/auto-compaction.js +65 -18
  54. package/dist/core/auto-promote.d.ts +2 -1
  55. package/dist/core/auto-promote.js +2 -2
  56. package/dist/core/caller-tool-name.d.ts +9 -0
  57. package/dist/core/caller-tool-name.js +13 -0
  58. package/dist/core/checkpoint-requirements.d.ts +7 -1
  59. package/dist/core/checkpoint-requirements.js +2 -0
  60. package/dist/core/checkpoint-store.d.ts +57 -24
  61. package/dist/core/checkpoint-store.js +3 -3
  62. package/dist/core/child-clamp-seats.d.ts +6 -0
  63. package/dist/core/child-clamp-seats.js +2 -1
  64. package/dist/core/compliance.js +3 -1
  65. package/dist/core/credential-vocabulary.d.ts +33 -8
  66. package/dist/core/credential-vocabulary.js +32 -7
  67. package/dist/core/decision-receipt.d.ts +2 -2
  68. package/dist/core/delegation-frames.d.ts +9 -2
  69. package/dist/core/delegation-name.d.ts +18 -0
  70. package/dist/core/delegation-name.js +47 -0
  71. package/dist/core/effective-path-target.d.ts +25 -6
  72. package/dist/core/effective-path-target.js +24 -2
  73. package/dist/core/engine-capabilities.d.ts +8 -1
  74. package/dist/core/engine-capabilities.js +1 -1
  75. package/dist/core/engine-notice.d.ts +49 -21
  76. package/dist/core/frontmatter-fence.d.ts +10 -0
  77. package/dist/core/frontmatter-fence.js +6 -0
  78. package/dist/core/gate-fold.js +8 -25
  79. package/dist/core/gate-lanes.js +358 -274
  80. package/dist/core/gate-outcome.d.ts +11 -6
  81. package/dist/core/governance-codes.d.ts +11 -4
  82. package/dist/core/governance-codes.js +12 -3
  83. package/dist/core/hands-band-screen.d.ts +7 -0
  84. package/dist/core/hands-band-screen.js +32 -0
  85. package/dist/core/hooks.d.ts +283 -739
  86. package/dist/core/hooks.js +100 -193
  87. package/dist/{tools/fs → core}/image-admission.d.ts +21 -3
  88. package/dist/{tools/fs → core}/image-admission.js +24 -6
  89. package/dist/core/locked-config.d.ts +84 -32
  90. package/dist/core/locked-config.js +128 -42
  91. package/dist/core/mcp-connection-pool.d.ts +4 -1
  92. package/dist/core/mcp-connection-pool.js +7 -2
  93. package/dist/core/mcp-probe.d.ts +12 -9
  94. package/dist/core/mcp-probe.js +3 -3
  95. package/dist/core/mcp-server-spec.d.ts +104 -10
  96. package/dist/core/mcp-server-spec.js +183 -33
  97. package/dist/core/mcp.d.ts +31 -20
  98. package/dist/core/mcp.js +66 -56
  99. package/dist/core/memory-engine/data-plane.js +5 -1
  100. package/dist/core/memory-engine/distiller.js +16 -2
  101. package/dist/core/memory-engine/engine.d.ts +22 -30
  102. package/dist/core/memory-engine/engine.js +16 -10
  103. package/dist/core/memory-engine/frontmatter.d.ts +6 -0
  104. package/dist/core/memory-engine/frontmatter.js +19 -16
  105. package/dist/core/memory.d.ts +2 -2
  106. package/dist/core/mode-policy.d.ts +5 -16
  107. package/dist/core/mode-policy.js +7 -19
  108. package/dist/core/model-seat.d.ts +4 -1
  109. package/dist/core/mods/dollar.d.ts +109 -0
  110. package/dist/core/mods/dollar.js +154 -0
  111. package/dist/core/mods/host.d.ts +23 -0
  112. package/dist/core/mods/host.js +21 -0
  113. package/dist/core/mods/kernel/budget.d.ts +43 -0
  114. package/dist/core/mods/kernel/budget.js +83 -0
  115. package/dist/core/mods/kernel/dispatch.d.ts +100 -0
  116. package/dist/core/mods/kernel/dispatch.js +699 -0
  117. package/dist/core/mods/kernel/events.d.ts +245 -0
  118. package/dist/core/mods/kernel/events.js +210 -0
  119. package/dist/core/mods/kernel/index.d.ts +6 -0
  120. package/dist/core/mods/kernel/index.js +5 -0
  121. package/dist/core/mods/kernel/receive.d.ts +94 -0
  122. package/dist/core/mods/kernel/receive.js +188 -0
  123. package/dist/core/mods/kernel/registration.d.ts +131 -0
  124. package/dist/core/mods/kernel/registration.js +102 -0
  125. package/dist/core/mods/kernel/site-rules.d.ts +149 -0
  126. package/dist/core/mods/kernel/site-rules.js +296 -0
  127. package/dist/core/mods/stations.d.ts +344 -0
  128. package/dist/core/mods/stations.js +437 -0
  129. package/dist/core/mounted-mcp.d.ts +57 -0
  130. package/dist/core/mounted-mcp.js +71 -2
  131. package/dist/core/own-property-receipt.d.ts +13 -0
  132. package/dist/core/own-property-receipt.js +13 -23
  133. package/dist/core/parent-spec-seat.d.ts +18 -7
  134. package/dist/core/parent-spec-seat.js +6 -2
  135. package/dist/core/path-identity.js +2 -1
  136. package/dist/core/permission-rule-model.d.ts +58 -9
  137. package/dist/core/permission-rule-model.js +81 -7
  138. package/dist/core/permission-rule-org.d.ts +163 -33
  139. package/dist/core/permission-rule-org.js +205 -71
  140. package/dist/core/permission-rule-syntax.d.ts +15 -0
  141. package/dist/core/permission-rule-syntax.js +24 -16
  142. package/dist/core/permission-rules.d.ts +14 -2
  143. package/dist/core/permission-rules.js +63 -23
  144. package/dist/core/prompt-section-seat.d.ts +15 -27
  145. package/dist/core/prompt-section-seat.js +73 -40
  146. package/dist/core/protocol-http-auth.d.ts +3 -1
  147. package/dist/core/protocol-http-auth.js +2 -0
  148. package/dist/core/reminder-mint.d.ts +2 -2
  149. package/dist/core/remote-env.d.ts +24 -12
  150. package/dist/core/remote-env.js +1 -0
  151. package/dist/core/resume-startup-cause.d.ts +16 -1
  152. package/dist/core/resume-startup-cause.js +4 -1
  153. package/dist/core/retention.js +7 -2
  154. package/dist/core/retired-keys.d.ts +12 -2
  155. package/dist/core/retired-keys.js +15 -1
  156. package/dist/core/roles.js +60 -10
  157. package/dist/core/rule-lane-reads.js +1 -1
  158. package/dist/core/runner/announce-once-ledger.d.ts +31 -1
  159. package/dist/core/runner/announce-once-ledger.js +22 -0
  160. package/dist/core/runner/assemble-result.d.ts +9 -0
  161. package/dist/core/runner/assemble-result.js +3 -1
  162. package/dist/core/runner/call-hook-stations.d.ts +23 -27
  163. package/dist/core/runner/call-hook-stations.js +58 -42
  164. package/dist/core/runner/caller-stop-gate.d.ts +25 -0
  165. package/dist/core/runner/caller-stop-gate.js +7 -0
  166. package/dist/core/runner/compaction-seams.d.ts +8 -22
  167. package/dist/core/runner/compaction-seams.js +28 -53
  168. package/dist/core/runner/construction-doors.js +11 -1
  169. package/dist/core/runner/contracts.d.ts +86 -83
  170. package/dist/core/runner/credential-origin-seat.d.ts +2 -0
  171. package/dist/core/runner/credential-origin-seat.js +19 -0
  172. package/dist/core/runner/extra-body-dropped-seat.d.ts +2 -6
  173. package/dist/core/runner/gate-exit.d.ts +47 -36
  174. package/dist/core/runner/gate-exit.js +12 -5
  175. package/dist/core/runner/image.js +1 -2
  176. package/dist/core/runner/inline-image.js +1 -1
  177. package/dist/core/runner/mcp-redial.d.ts +52 -10
  178. package/dist/core/runner/mcp-redial.js +38 -9
  179. package/dist/core/runner/notice-seat-input.d.ts +11 -0
  180. package/dist/core/runner/notice-seat-input.js +1 -0
  181. package/dist/core/runner/permission-rule-lanes.d.ts +5 -2
  182. package/dist/core/runner/permission-rule-lanes.js +22 -10
  183. package/dist/core/runner/person-path-rows.d.ts +3 -1
  184. package/dist/core/runner/person-path-rows.js +3 -2
  185. package/dist/core/runner/prepare-acquire-reconcile.js +5 -1
  186. package/dist/core/runner/prepare-ask-lane.d.ts +5 -7
  187. package/dist/core/runner/prepare-ask-lane.js +6 -15
  188. package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
  189. package/dist/core/runner/prepare-caps-and-workflow.js +25 -8
  190. package/dist/core/runner/prepare-config-doors.d.ts +52 -1
  191. package/dist/core/runner/prepare-config-doors.js +168 -143
  192. package/dist/core/runner/prepare-defer-classify.js +7 -11
  193. package/dist/core/runner/prepare-delegation-surface.d.ts +2 -2
  194. package/dist/core/runner/prepare-delegation-surface.js +7 -5
  195. package/dist/core/runner/prepare-gate-stations.d.ts +8 -11
  196. package/dist/core/runner/prepare-gate-stations.js +11 -10
  197. package/dist/core/runner/prepare-hands-readface.d.ts +1 -1
  198. package/dist/core/runner/prepare-hands-readface.js +2 -1
  199. package/dist/core/runner/prepare-inherited-gate.d.ts +8 -12
  200. package/dist/core/runner/prepare-inherited-gate.js +10 -23
  201. package/dist/core/runner/prepare-listings.d.ts +1 -1
  202. package/dist/core/runner/prepare-listings.js +4 -2
  203. package/dist/core/runner/prepare-memory-engine-session.d.ts +0 -6
  204. package/dist/core/runner/prepare-memory-engine-session.js +20 -7
  205. package/dist/core/runner/prepare-memory.d.ts +3 -2
  206. package/dist/core/runner/prepare-memory.js +6 -2
  207. package/dist/core/runner/prepare-offload-wrappers.d.ts +4 -4
  208. package/dist/core/runner/prepare-offload-wrappers.js +2 -1
  209. package/dist/core/runner/prepare-park-ask.js +10 -14
  210. package/dist/core/runner/prepare-policy-chain.d.ts +17 -15
  211. package/dist/core/runner/prepare-policy-chain.js +104 -109
  212. package/dist/core/runner/prepare-project-context.d.ts +1 -1
  213. package/dist/core/runner/prepare-project-context.js +11 -12
  214. package/dist/core/runner/prepare-prompt-assembly.d.ts +45 -40
  215. package/dist/core/runner/prepare-prompt-assembly.js +138 -101
  216. package/dist/core/runner/prepare-prompt-inputs.d.ts +28 -23
  217. package/dist/core/runner/prepare-prompt-inputs.js +11 -10
  218. package/dist/core/runner/prepare-protocol-tools.d.ts +11 -7
  219. package/dist/core/runner/prepare-protocol-tools.js +20 -13
  220. package/dist/core/runner/prepare-question-face.d.ts +4 -2
  221. package/dist/core/runner/prepare-question-face.js +4 -3
  222. package/dist/core/runner/prepare-safety-scan.js +6 -10
  223. package/dist/core/runner/prepare-suspend-saga.d.ts +6 -0
  224. package/dist/core/runner/prepare-suspend-saga.js +7 -5
  225. package/dist/core/runner/prepare-task.js +41 -23
  226. package/dist/core/runner/prepare-turn-wiring.d.ts +13 -15
  227. package/dist/core/runner/prepare-turn-wiring.js +29 -27
  228. package/dist/core/runner/prepare-wiring-manifest.d.ts +13 -9
  229. package/dist/core/runner/prepare-wiring-manifest.js +14 -11
  230. package/dist/core/runner/resume-admission.d.ts +4 -1
  231. package/dist/core/runner/resume-admission.js +7 -5
  232. package/dist/core/runner/resume-apply.d.ts +5 -2
  233. package/dist/core/runner/resume-apply.js +24 -42
  234. package/dist/core/runner/resume-checkpoint-screen.d.ts +1 -0
  235. package/dist/core/runner/resume-checkpoint-screen.js +9 -1
  236. package/dist/core/runner/resume-internals-and-config.d.ts +3 -3
  237. package/dist/core/runner/resume-internals-and-config.js +3 -8
  238. package/dist/core/runner/resume-preflight.js +38 -16
  239. package/dist/core/runner/retired-runner-deps.d.ts +5 -6
  240. package/dist/core/runner/retired-runner-deps.js +4 -5
  241. package/dist/core/runner/run-brain-sinks.d.ts +3 -2
  242. package/dist/core/runner/run-brain-sinks.js +5 -1
  243. package/dist/core/runner/run-compaction-machinery.d.ts +7 -1
  244. package/dist/core/runner/run-compaction-machinery.js +7 -33
  245. package/dist/core/runner/run-harness-handlers.js +4 -2
  246. package/dist/core/runner/run-identity-wiring.d.ts +3 -1
  247. package/dist/core/runner/run-identity-wiring.js +3 -2
  248. package/dist/core/runner/run-leg.js +103 -95
  249. package/dist/core/runner/run-notification-lane.js +1 -0
  250. package/dist/core/runner/run-recovery-lanes.d.ts +3 -0
  251. package/dist/core/runner/run-recovery-lanes.js +14 -11
  252. package/dist/core/runner/run-settle-and-teardown.js +9 -28
  253. package/dist/core/runner/run-stop-and-final-verify.js +18 -25
  254. package/dist/core/runner/run-terminal-adoption.d.ts +3 -0
  255. package/dist/core/runner/run-terminal-adoption.js +34 -22
  256. package/dist/core/runner/run-tool-mount-facts.js +5 -3
  257. package/dist/core/runner/run-turn-boundary.js +32 -37
  258. package/dist/core/runner/runtask.d.ts +6 -7
  259. package/dist/core/runner/runtask.js +15 -73
  260. package/dist/core/runner/session-rule-policy.d.ts +5 -5
  261. package/dist/core/runner/session-rule-policy.js +13 -8
  262. package/dist/core/runner/side-call-account.d.ts +68 -0
  263. package/dist/core/runner/side-call-account.js +61 -0
  264. package/dist/core/runner/skills-seat.d.ts +15 -0
  265. package/dist/core/runner/skills-seat.js +57 -0
  266. package/dist/core/runner/stream-halt-verbs.js +12 -17
  267. package/dist/core/runner/stream-lifecycle-verbs.js +8 -5
  268. package/dist/core/runner/stream-steer-verb.d.ts +1 -1
  269. package/dist/core/runner/stream-steer-verb.js +20 -38
  270. package/dist/core/runner/sub-agent-deny-lists.d.ts +124 -23
  271. package/dist/core/runner/sub-agent-deny-lists.js +230 -71
  272. package/dist/core/runner/synthetic-tools.d.ts +5 -2
  273. package/dist/core/runner/synthetic-tools.js +2 -12
  274. package/dist/core/runner/tool-end-body.d.ts +5 -6
  275. package/dist/core/runner/tool-end-body.js +6 -4
  276. package/dist/core/runner/tool-not-executed.d.ts +15 -0
  277. package/dist/core/runner/tool-not-executed.js +30 -0
  278. package/dist/core/runner/tool-occurrence.d.ts +8 -7
  279. package/dist/core/runner/tool-occurrence.js +5 -5
  280. package/dist/core/runner/tool-result-media-seat.d.ts +1 -1
  281. package/dist/core/runner-deps.d.ts +43 -36
  282. package/dist/core/safety-merge-corpus.js +18 -2
  283. package/dist/core/secret-env.d.ts +88 -20
  284. package/dist/core/secret-env.js +192 -38
  285. package/dist/core/session-policy-store.d.ts +71 -16
  286. package/dist/core/session-policy-store.js +138 -28
  287. package/dist/core/session-reconcile.d.ts +4 -0
  288. package/dist/core/session-reconcile.js +2 -1
  289. package/dist/core/shell-read-silence.js +3 -32
  290. package/dist/core/side-query.d.ts +5 -3
  291. package/dist/core/skill-grant.d.ts +3 -2
  292. package/dist/core/skill-grant.js +17 -9
  293. package/dist/core/skills-directory.d.ts +31 -11
  294. package/dist/core/skills-directory.js +655 -104
  295. package/dist/core/store-contracts/mailbox-store-contract.js +13 -0
  296. package/dist/core/task-event.d.ts +49 -3
  297. package/dist/core/task-notification.js +2 -2
  298. package/dist/core/task-result.d.ts +8 -0
  299. package/dist/core/task-spec.d.ts +20 -19
  300. package/dist/core/task-stream.d.ts +14 -4
  301. package/dist/core/tighten-task-spec.d.ts +4 -3
  302. package/dist/core/tighten-task-spec.js +2 -4
  303. package/dist/core/tool-check-reading.d.ts +74 -0
  304. package/dist/core/tool-check-reading.js +80 -0
  305. package/dist/core/tool-policy.d.ts +62 -18
  306. package/dist/core/tool-policy.js +86 -15
  307. package/dist/core/tool-registry.d.ts +20 -0
  308. package/dist/core/tool-registry.js +4 -0
  309. package/dist/core/tool-spec.d.ts +10 -6
  310. package/dist/core/tools.js +2 -0
  311. package/dist/core/trace.d.ts +51 -32
  312. package/dist/core/types.d.ts +2 -2
  313. package/dist/core/types.js +1 -1
  314. package/dist/core/unc-spelling.d.ts +10 -0
  315. package/dist/core/unc-spelling.js +3 -0
  316. package/dist/core/untrusted-egress.d.ts +8 -0
  317. package/dist/core/untrusted-egress.js +4 -1
  318. package/dist/core/untrusted-text.d.ts +16 -0
  319. package/dist/core/untrusted-text.js +7 -2
  320. package/dist/core/user-attachment.d.ts +25 -4
  321. package/dist/core/user-attachment.js +63 -42
  322. package/dist/core/wiring-manifest.d.ts +18 -15
  323. package/dist/core/wiring-manifest.js +2 -2
  324. package/dist/engine/compaction/utils.js +1 -2
  325. package/dist/engine/execution-env/node-execution-env.d.ts +4 -3
  326. package/dist/engine/execution-env/symlink-chain.js +1 -1
  327. package/dist/engine/harness/agent-harness.d.ts +1 -1
  328. package/dist/engine/harness/agent-harness.js +5 -4
  329. package/dist/engine/harness/types.d.ts +20 -16
  330. package/dist/engine/harness/types.js +7 -1
  331. package/dist/engine/llm/types.d.ts +12 -7
  332. package/dist/engine/loop/agent-loop.d.ts +12 -1
  333. package/dist/engine/loop/agent-loop.js +18 -6
  334. package/dist/engine/loop/types.d.ts +8 -2
  335. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -2
  336. package/dist/engine/session/import-validate.js +3 -10
  337. package/dist/engine/session/session.d.ts +4 -4
  338. package/dist/engine/session/session.js +4 -4
  339. package/dist/index.d.ts +33 -23
  340. package/dist/index.js +23 -15
  341. package/dist/internal/harness.d.ts +2 -1
  342. package/dist/internal/harness.js +2 -1
  343. package/dist/orchestration/goal.d.ts +24 -1
  344. package/dist/orchestration/goal.js +3 -2
  345. package/dist/orchestration/run-spec.d.ts +4 -6
  346. package/dist/orchestration/run-spec.js +39 -63
  347. package/dist/orchestration/run-workflow-tool.js +1 -2
  348. package/dist/orchestration/workflow.d.ts +11 -3
  349. package/dist/orchestration/workflow.js +11 -2
  350. package/dist/prompt-assembly/artifact-store.d.ts +72 -56
  351. package/dist/prompt-assembly/artifact-store.js +60 -52
  352. package/dist/prompt-assembly/artifact.d.ts +90 -76
  353. package/dist/prompt-assembly/artifact.js +197 -145
  354. package/dist/prompt-assembly/assemble.d.ts +36 -29
  355. package/dist/prompt-assembly/assemble.js +34 -220
  356. package/dist/prompt-assembly/bands.d.ts +20 -0
  357. package/dist/prompt-assembly/bands.js +18 -0
  358. package/dist/prompt-assembly/catalog/constitution.d.ts +23 -0
  359. package/dist/prompt-assembly/catalog/constitution.js +69 -0
  360. package/dist/prompt-assembly/catalog/core-behavior.d.ts +9 -0
  361. package/dist/prompt-assembly/catalog/core-behavior.js +85 -0
  362. package/dist/prompt-assembly/catalog/index.d.ts +11 -0
  363. package/dist/prompt-assembly/catalog/index.js +60 -0
  364. package/dist/prompt-assembly/catalog/memory.d.ts +13 -0
  365. package/dist/prompt-assembly/catalog/memory.js +6 -0
  366. package/dist/prompt-assembly/catalog/modes.d.ts +15 -0
  367. package/dist/prompt-assembly/catalog/modes.js +137 -0
  368. package/dist/prompt-assembly/catalog/role.d.ts +10 -0
  369. package/dist/prompt-assembly/catalog/role.js +157 -0
  370. package/dist/prompt-assembly/catalog/schema.d.ts +138 -0
  371. package/dist/prompt-assembly/catalog/schema.js +25 -0
  372. package/dist/prompt-assembly/catalog/sections.d.ts +9 -0
  373. package/dist/prompt-assembly/catalog/sections.js +98 -0
  374. package/dist/prompt-assembly/catalog/team.d.ts +4 -0
  375. package/dist/prompt-assembly/catalog/team.js +258 -0
  376. package/dist/prompt-assembly/composer.d.ts +26 -28
  377. package/dist/prompt-assembly/composer.js +124 -46
  378. package/dist/prompt-assembly/epoch.d.ts +89 -79
  379. package/dist/prompt-assembly/epoch.js +115 -142
  380. package/dist/prompt-assembly/explain.d.ts +58 -34
  381. package/dist/prompt-assembly/explain.js +21 -24
  382. package/dist/prompt-assembly/overlay.d.ts +137 -0
  383. package/dist/prompt-assembly/overlay.js +339 -0
  384. package/dist/prompt-assembly/renderers.d.ts +21 -0
  385. package/dist/prompt-assembly/renderers.js +39 -0
  386. package/dist/prompt-assembly/section-usage.d.ts +3 -3
  387. package/dist/prompt-assembly/section-usage.js +1 -1
  388. package/dist/prompt-assembly/select.d.ts +71 -0
  389. package/dist/prompt-assembly/select.js +106 -0
  390. package/dist/prompt-assembly/sugar.d.ts +31 -0
  391. package/dist/prompt-assembly/sugar.js +20 -0
  392. package/dist/prompt-assembly/turn-snapshot.d.ts +3 -2
  393. package/dist/prompt-assembly/types.d.ts +45 -148
  394. package/dist/prompts/coordinator.d.ts +2 -21
  395. package/dist/prompts/coordinator.js +3 -255
  396. package/dist/prompts/cowork.d.ts +27 -16
  397. package/dist/prompts/cowork.js +2 -23
  398. package/dist/prompts/default.d.ts +38 -511
  399. package/dist/prompts/default.js +20 -288
  400. package/dist/prompts/supervisor.d.ts +6 -71
  401. package/dist/prompts/supervisor.js +6 -117
  402. package/dist/stores/file/permission-rule-store.d.ts +1 -1
  403. package/dist/stores/file/permission-rule-store.js +2 -1
  404. package/dist/tools/artifact/artifact-tool.js +4 -3
  405. package/dist/tools/fs/bash-readonly-classifier.d.ts +6 -5
  406. package/dist/tools/fs/dir-entries.d.ts +4 -2
  407. package/dist/tools/fs/dir-entries.js +16 -4
  408. package/dist/tools/fs/fs-bash.d.ts +1 -1
  409. package/dist/tools/fs/fs-bash.js +22 -11
  410. package/dist/tools/fs/fs-pdf.js +17 -13
  411. package/dist/tools/fs/fs-read.js +4 -3
  412. package/dist/tools/fs/fs-shared.d.ts +1 -10
  413. package/dist/tools/fs/fs-shared.js +3 -4
  414. package/dist/tools/fs/fs-write.js +2 -1
  415. package/dist/tools/fs/notebook.js +1 -1
  416. package/dist/tools/fs/pdf.d.ts +20 -10
  417. package/dist/tools/fs/pdf.js +19 -10
  418. package/dist/tools/fs/program-read-model.d.ts +68 -8
  419. package/dist/tools/fs/program-read-model.js +112 -33
  420. package/dist/tools/fs/program-read-model.rows.js +30 -10
  421. package/dist/tools/fs/read-deny-home.d.ts +25 -9
  422. package/dist/tools/fs/read-deny-home.js +28 -27
  423. package/dist/tools/fs/read-deny.d.ts +41 -11
  424. package/dist/tools/fs/read-deny.js +51 -5
  425. package/dist/tools/fs/safety.d.ts +4 -26
  426. package/dist/tools/fs/safety.js +10 -32
  427. package/dist/tools/fs/search.js +15 -10
  428. package/dist/tools/fs/shell-read-argv.d.ts +3 -2
  429. package/dist/tools/fs/shell-read-argv.js +118 -38
  430. package/dist/tools/fs/shell-read-establish.d.ts +5 -5
  431. package/dist/tools/fs/shell-read-establish.js +11 -61
  432. package/dist/tools/fs/shell-read-identity.js +6 -3
  433. package/dist/tools/fs/shell-read-judge.d.ts +13 -15
  434. package/dist/tools/fs/shell-read-judge.js +18 -4
  435. package/dist/tools/fs/shell-read-plan.d.ts +2 -2
  436. package/dist/tools/web.js +1 -1
  437. package/package.json +13 -1
  438. package/test/export-surface.snapshot.json +306 -103
  439. package/wire-changes/core@7.52.0.json +5859 -0
  440. package/wire-changes/core@7.53.0.json +1946 -0
  441. package/dist/core/hook-seats.d.ts +0 -66
  442. package/dist/core/hook-seats.js +0 -37
  443. package/dist/prompt-assembly/packs/sema-default.d.ts +0 -2
  444. package/dist/prompt-assembly/packs/sema-default.js +0 -147
  445. package/dist/prompts/simple-sections.d.ts +0 -58
  446. package/dist/prompts/simple-sections.js +0 -43
package/CHANGELOG.md CHANGED
@@ -1,3 +1,701 @@
1
+ ## 7.53.0 — 2026-10-17
2
+
3
+ ### Docs and pins: the task-stream thinking arm, the classifier attribution sentence, the TaskEvent roster, one re-anchored pin (#1042, #1961, #2029, #1432)
4
+
5
+ - **#1042 (docs).** The task stream's thinking arm is `reasoning_delta` with no start / end frame — a sidecar that listens for the provider-layer name `thinking_delta` counts nothing (`docs/sdk/05-streaming-and-http-server.md`, one pointer sentence in `README.md`). INTEGRATION-CORE states the two host-side facts beside it: a host that renders a local date passes `EnvironmentFacts.timeZone`; a cache-stable deployment keeps one tool block across modes and refuses at call time rather than varying the block (`KL-1042-a` records the `</think>` leading newline as the template's own).
6
+ - **#1961 (docs, JSDoc only).** `GateDisposition.classifier` is the classification round's model attribution — present whenever the classifier ran, including the ask it answered that a person then settled; who settled is read from the settlement record (`human_allowed` / `human_refused` / …), never from the presence of `classifier` (`src/core/gate-outcome.ts` comment lines; one sentence in INTEGRATION-CORE §gate).
7
+ - **#2029 (docs).** INTEGRATION-CORE §1.0 no longer keeps its own copy of the TaskEvent roster; it references the frozen table in `test/defectscan-task-event-surface.test.ts`, so one list exists.
8
+ - **#1432 (pin re-anchored, no behavior change).** `test/backlog1432-deny-universe.test.ts` ② asserted that an undeclared extraTools product's names appear in the model-facing refusal; since #2051 (this release) the model gets one fixed sentence and the names live in `details.cause`. The pin now asserts exactly that (names absent from the text, present in `details.cause`).
9
+
10
+ ### `brainLaneOf(model, routes, by?)` is the one judge of which mounted line serves a model, exported from the package root; `createRoutingBrain` asks it over the same table (#1842; additive export; behavior narrowing for a model with no route word and for a mounted route whose value is not a brain, open to veto; hard-breaking: none; closed sets: none; exports: +1 function; members: none) (@server)
11
+
12
+ - **Before.** The rule "the model's provider word names a mounted route ⇒ that line, else the fallback (the gateway)" was written inside `createRoutingBrain`; every host that needed the same answer (server `dispatchBrainLane` at `brain.ts` ×2, `model-route-endpoint.ts`, `hooks/hook-llm.ts`) wrote its own.
13
+ - **Now.** `brainLaneOf(model, routes, by = "provider")` answers the route's name, or `undefined` when the fallback serves the model (a word with no route under it, or no string word at all). `routes` is the route TABLE itself — the very object the host hands `createRoutingBrain`, which asks this function for every request — so the judge and the router answer from one reading of one table, whatever its shape. The question is `Object.hasOwn(routes, word)`: it evaluates no entry and lists no keys, so a non-enumerable own route is a route, a table that answers for names it does not list (a lazily keyed `Proxy`) is asked about the one name, and a name the table only inherits (`constructor`, `__proto__`) matches nothing unless a route is mounted under that very name. The router reads the selected entry once; an entry the model did not select is never evaluated, as before. The choice between the provider word and the api word is written once.
14
+ - **Behavior narrowing (open to veto).** (1) **What changed:** a model that carries no string route word (`provider` / `api` absent or not a string) is served by the fallback; a route mounted under the model's word whose value is not a brain fails that request with an error terminal naming the route (`createRoutingBrain: route provider="x" is mounted but its value is not a brain`) where 7.52.0 served it from the fallback, because the judge names that line and a request is never moved off the line the judge named. **The form most likely to meet this is `{ anthropic: cfg ? brain : undefined }`**: the own key is present with the value `undefined`, so 7.52.0 served every request for that word from the fallback and this version fails each of them with that error terminal — the key must be absent, not `undefined`. With no fallback configured, the same request was already an error terminal and stays one; its text changes from `createRoutingBrain: no brain for provider="x"` to `createRoutingBrain: route provider="x" is mounted but its value is not a brain` (the old sentence called a bad table a missing brain). Not changed: a lazily keyed table is routed as in 7.52.0. Also: a route table that throws while it is asked or read (the selected entry's getter, a descriptor trap) fails the request with an error terminal where 7.52.0 threw synchronously out of `stream()`, against the brain contract; so does a `Model` whose `provider` / `api` / `id` getter throws. The router reads the route word once per request and every failure sentence spells that one answer (7.52.0's failure arm read the provider a second time, and a getter that threw there threw out of `stream()`); nothing else of the model is read on the way to a brain, and the words the error terminal names the request by (`api`, `provider`, `id`) are read where the terminal is built, in one guard (a getter that throws leaves them blank and the sentence says so). A route word that cannot be read at all is the error terminal `createRoutingBrain: the model's provider could not be read …` (or `api`), not a move to the fallback. (2) **Who is hurt:** a table with a route literally named `"undefined"` served such a model by string coercion of the missing word; a host whose table holds a non-brain under a word some model carries had those requests silently served by the gateway. Nobody is known to rely on either. (3) **Compensation:** mount the route under the word the model carries; mount a brain, or remove the entry so the fallback serves the word.
15
+ - **How to migrate.** *server*: replace `dispatchBrainLane(provider, anthropicRouteMounted)` with `brainLaneOf({ provider }, routes)` over the route table it mounts (the one it hands `createRoutingBrain`), mapping `undefined` to `"gateway"`; a names list is not a table (the type refuses an array or a `Set`). *any host that builds the table with an optional brain*: write `{ ...(x ? { anthropic: x } : {}) }`, never `{ anthropic: x ? brain : undefined }` — a mounted key whose value is not a brain is a broken line now, where it was the fallback's.
16
+ - **Pins.** `test/backlog1842-brain-lane-judge.test.ts`.
17
+
18
+ ### A `toolAxes` declaration has one reader, `readToolAxes(declared, at)`, and one disposition for an MCP server and an A2A peer alike: a task whose A2A peer declares axes the engine cannot read is refused at the preparation door, as one whose MCP server does (#1835; behavior narrowing, open to veto; **hard-breaking: the public type `McpToolAxesRead` is removed, `ToolAxesRead` is its replacement, no alias** — no consumer seen in server, client-core or cli; closed sets: +1 `ToolAxesSurface` (`mcp` / `a2a`, the `at` of `readToolAxes`); exports: +1 function, −1 type `McpToolAxesRead`, +2 types `ToolAxesRead`, `ToolAxesSurface`) (@server @web-admin)
19
+
20
+ - **Before.** `readMcpToolAxes` judged an MCP server's `toolAxes` at the preparation door (a declaration it cannot read refused the whole prepare); `A2aServerSpec.toolAxes` (the same declaration) was trusted as the type said, and a host that assembled it from JSON kept its own reader (server `src/tool-axis-words.ts`).
21
+ - **Now.** `readToolAxes(declared, at)` with `at` `"mcp"` or `"a2a"` (`ToolAxesSurface`, a closed set: a value outside it throws `config.invalid_argument` naming the two, whatever is declared) is the reader of both. `readMcpToolAxes(declared)` stays as its MCP entry (`readToolAxes(declared, "mcp")`). The answer and every refusal arm are the ones of 7.51.2; the one difference is the noun for the keys in the sentence (`tool` for MCP, `skill` for A2A). One judge per entry asks it for both surfaces, and one function builds the refusal (`config.invalid_argument`, "<subject> declares toolAxes the engine cannot read: …"; subject `MCP server "x"` or `A2A peer "x"`). (a) The preparation door judges every `TaskSpec.mcp` server and every `TaskSpec.a2a` peer: a declaration it cannot read refuses the whole prepare before any server is spawned or any peer is dialed (zero turns), and the task dials the door's frozen copy of each peer (the door reads `spec.a2a` once — a `{ ...spec }` rebinding later in prepare reads it again, KL-1835-b; a `toolAxes` — or any member — carried as an own property the object does not enumerate is read and judged like any other, and one carried on a prototype is refused: neither is read as if it were absent). (b) The materialization entrances — `materializeMcpTools` / `materializeA2aTools` called as a library — ask the same judge per entry as their own guard: there a declaration it cannot read fails that server's or that peer's dial (the A2A peer: never dialed, warning `a2a.peer_unavailable` whose cause is the refusal; a peer the snapshot refuses is held — in its warning, its status and the collision check — under the name its own string `name` data property held when the snapshot read it, which the refusal carries as `declarer`, or by its place when the name is no such property (a getter included); a peer the axes judge refuses is held under the name its snapshot copied; either way the caller's object is never read a second time to name it), its siblings unaffected. An A2A peer's declaration is read and judged once per materialization and is frozen for the run, **to its members**: the entrance copies `headers`, `allowSkills` and `toolAxes` into frozen plain data, so a refresh dials that copy and no longer re-reads the host's object — a declaration refused at the entrance is refused again by every refresh, and a host that rewrites its own object afterwards, by replacing `spec.a2a[i].toolAxes` / `headers` or by editing one in place (a header value, `allowSkills.push`), changes nothing a refresh dials; what a refresh can still fail on is the peer (its card, the network), and there the tools it had are kept, as before. An MCP server's entry is read to the same depth by the same function (`transport` with its `args` / `env` / `headers`, `allowTools`, `toolFaces` are copied too; 7.52.0 copied an MCP entry's top level only), so the dial, a refresh and a re-dial work from the copy. The door and the library entrances are total over a declaration they cannot read: a getter that throws, a Proxy, a nesting past 1 000 levels below the declaration (the engine's one depth for a caller's value — the bus holds a handler's values to the same — said `it is nested past 1000 levels` on every platform, with the member path bounded at the reader as at the door, #2017; one held to no depth would be refused by one platform's stack and judged by another's vocabulary, its path running to thousands of characters), an entry that is not an object, a `TaskSpec.a2a` / `TaskSpec.mcp` that is not an array (a Proxy included) or whose element cannot be read — all are `config.invalid_argument` naming the declarer and the member (`A2A peer "p" declares url the engine cannot read: …`), the same sentence for both surfaces and both entrances (the door refuses the whole prepare; a library entrance refuses the A2A peer alone, the MCP list as a whole call), never the raw error the host's code threw and never read as "no entries". One reading of a declaration, the engine's own: an entry, a `toolAxes` record and a lock declaration are read by the engine's one receipt of a caller's object, in its declaration mode — a Proxy refused at any level before anything is asked of it (whatever length or keys its traps would answer), a record's prototype `Object.prototype` or null, every own string-keyed member read once off its captured descriptor (enumerable or not; a getter called once, as captured — never through a `call` member the getter function carries, for every reader of the receipt), every level copied and frozen; a list is copied from its own index descriptors (an array whose iterator answers otherwise than its indices cannot rewrite an allowlist); an object the entry reaches through two members is read once and both hold the one copy. A symbol-keyed member is not a member of a declaration (JSON cannot produce one and no reader reads one): it is neither read nor copied (7.52.0 carried an MCP entry's enumerable symbol members onto its top-level copy, where nothing read them). On that copy, the declaration's own vocabulary: (c) each axis of a tool or skill is read by its fixed name, once, never assembled from a key listing — a per-tool record whose listing leaves an axis out cannot drop it (such a record is a Proxy, refused), and a value a name answers that the copy does not hold as its own (an axis supplied from `Object.prototype`) is refused as a hidden member; (d) a record's prototype is compared by reference — this realm's `Object.prototype` or null — so a record built in another realm (a `vm` context, a worker), one built on a Proxy of `Object.prototype`, or on a null-rooted object that carries the root's names, is not plain data and is refused; (e) every list a declaration carries is a list of names: an element that is a hole, `undefined`, `null`, a number or a record (`allowTools`, `allowSkills`, `transport.args`, a face's lists, `lockedConfig.keys`) is refused naming its index (`MCP server "s" declares allowTools[1] the engine cannot read: it is a hole, not a string: …`), as is a list's own member that is not an index (`allowTools.extra`, a name spelled as digits quoted — `allowTools."4294967295"`; 7.52.0 carried it and no reader read it), and no member is named `__proto__` (a tool, a face, an env or header name, an axis member — a copy by assignment would turn it into a prototype); and every record a declaration's type writes as names to strings holds strings — an MCP server's `transport.env` and `transport.headers`, an A2A peer's `headers`: `env: 5`, `env: null`, `env: []`, `env: { A: 5 }` or `headers: { A: 5 }` is refused naming the member (`transport.env.A`, `headers.A`), on a fresh run and, before the claim, on a resume (7.52.0 did not judge these values: for `env` the SDK spread a value that is not a record away and handed a number to the process as text); a member holding `undefined` is absent there as everywhere in a declaration; each surface judges only its own paths (an A2A peer's `transport` is a member it does not read). And a declaration's `name` is a string and is there: `name: 5`, `name: {}` or no name is refused naming `name` (7.52.0 did not judge it). A snapshot is copied whole or refused whole: a value the copy cannot carry whole — a Proxy, an object whose prototype is neither `Object.prototype` nor null (a class instance, a `Map`, `process.env`, a record built with `Object.create(defaults)` or in another realm), a function, a bigint, a structure that contains itself — refuses it as `config.invalid_argument` naming the member where reading stopped (`MCP server "s" declares toolFaces.write_file the engine cannot read: it is not plain data (…); copy it into plain data (a spread or a JSON round trip), writing out any member it inherits`). The host's object is never handed on, so a member whose getters have run once is never read again downstream. The lock declaration, the first thing the same preflight reads, is total the same way: a `RunnerDeps.lockedConfig` that is `null`, a list or a primitive, that is not plain data (a Proxy, a class instance, a `keys` that is a `Set` or a Proxy, a getter that throws), whose `keys` is absent or not a list, or whose `keys` holds an element that is not a string or a string that is not a registry name, is `config.locked_registry` naming `RunnerDeps.lockedConfig` and the member, an element by its index (`RunnerDeps.lockedConfig.keys[1] is "bogus", which is not a member of the locked-key registry …`), read once by the same reading — on a prepare, and on a resume's screen before the claim, which enters the same preflight (#1950).
22
+ - **Behavior narrowing (open to veto).** (1) **What changed:** a task whose A2A peer declares `toolAxes` the reader cannot read (an unknown member, a word outside the vocabulary, a string `egress`, a shape that is not a record of per-skill records) is refused at the door with `config.invalid_argument` — the same disposition and the same sentence, subject and noun aside, as an MCP server's. In 7.52.0 such a declaration was folded as written; one class was refused already, by the fold, with another code: an out-of-vocabulary `effect` on a peer the engine marks egress (`config.egress_requires_write_effect`), which now reads `config.invalid_argument`. Also: a refresh no longer re-reads the host's declaration (7.52.0 re-read the host's live object on every refresh and folded whatever axes it then held, unjudged), nor any nested member of it (`headers`, `allowSkills`). Also, on both surfaces: a declaration the engine cannot read is `config.invalid_argument` (7.52.0: the host's raw error, with no code — or, for a `TaskSpec.a2a` that was a record or `null`, no peers at all, silently); a `TaskSpec.a2a` / `TaskSpec.mcp` that is present and not an array is refused (`null` included); and an MCP library entrance (`materializeMcpTools`, the no-run probe) whose entry carries a nested member that throws when read now throws for the whole call, where 7.52.0 failed that server's dial and mounted its siblings (the A2A library entrance still isolates the peer). Also, on both surfaces: a declaration that is not plain data to its depth is refused (`config.invalid_argument`, naming the member) — 7.52.0 handed an MCP entry's nested members on as the host's own objects (a tool face built on a defaults prototype was read through it; `env: process.env` was passed as it was), copied only the entry's own enumerable members (an `allowTools` inherited or not enumerable was dropped, and every tool mounted — now one inherited refuses the entry, and one not enumerable is read and in force), read a `toolAxes` inherited or not enumerable by name (now: not enumerable, read; inherited, refused), and copied nothing of an A2A peer; a `contentOrigin` that is a self-containing value, a record carrying a function or a BigInt now reads `config.invalid_argument` naming the member, where 7.52.0 answered `config.mcp_content_class`. Also, on both surfaces, by the same reading: a Proxy anywhere in a declaration (7.52.0 read through its traps) and a record built in another realm (a `vm` context, a worker; 7.52.0 took it — the axes reader as a root it recognised by its names) are refused; a list element that is not a string is refused naming its index (7.52.0 kept it: `allowTools: ["Read", null]`); a member named `__proto__` is refused (7.52.0 kept it as an ordinary key, a tool so named included); a per-tool axis the record holds as a non-enumerable own member is read (7.52.0's axes reader refused the record); and at an MCP library entrance a `toolAxes` that is not plain data (a `Map`, inherited members) is refused with its entry, the whole call, where 7.52.0 failed that server's dial alone — one rule for every member that is not plain data, while a plain-data `toolAxes` the vocabulary refuses still fails that server alone. Also: a lock declaration that does not parse is `config.locked_registry` — 7.52.0 threw a bare `TypeError` out of the prepare for `null`, a list, a primitive or a missing `keys`, threw the host's own error for a getter that throws, iterated a `Set` of keys and took it (and read a Proxy list or a class instance's `keys` through), and took a member that only converts to a registry name (`{ toString: () => "mcp" }`) while the lock it named was never in force. (2) **Who is hurt:** a host whose task declared such a peer: the task started before, it is refused now; a host that changed a peer's axes mid-run and relied on a refresh to pick them up — a declaration is a run's configuration, change it for the next run; a host that edits a nested object of a server or peer in place and expected a refresh or re-dial to see it; a host that passes `a2a: null` for "no peers" (omit the key, or pass `[]`); a host whose MCP entry has a hostile getter below the top level and relied on its siblings still mounting; a host that declares an entry, or a record in it, as anything but plain data — `env: process.env` is the form most likely to meet this; a class instance, a `Map`, a record built with `Object.create(defaults)`, a Proxy, a declaration built in a `vm` context or received from a worker — and every member is judged, the ones the engine never reads included (a host's own `createdAt: new Date()`, a method, a `Headers` object); a host whose list holds an element that is not a string; a host that names a key `__proto__` (a remote tool literally so named can no longer be given axes or a face); a deployment that passes `lockedConfig.keys` as a `Set` or a Proxy (pass a list). (3) **Compensation:** the refusal names the peer, the skill and the member (an element by its index); fix that one declaration, or ask `readToolAxes(declared, "a2a")` at the host's own door and never submit the bad one; copy a value that is not plain data into plain data, the members it inherits included (`env: { ...process.env }`, `{ ...defaults, ...face }`, `JSON.parse(JSON.stringify(x))` for another realm's record).
23
+ - **BREAKING (type only; exports −1 +2).** `McpToolAxesRead` is removed from the package root (`index.ts`, and the `core/types` re-export); `ToolAxesRead` is the same shape under a name that is true of both surfaces. No alias is kept (one name per fact). Who breaks: a host that imports the type by name. Checked for this entry: `sema-server/src`, `sema-client-core/src`, `sema-cli/src` hold no import of it (server's `test/mcp-tool-axes-deployment.test.ts` names it in a test title only), so no consumer is seen. Migrate: rename the import; a value typed `McpToolAxesRead` is typed `ToolAxesRead`.
24
+ - **Who breaks (behavior).** *server*: `test/mcp-tool-axes-deployment.test.ts` cell ⑧ pins the axes reader's 7.52.0 wording for four shapes; the verdicts are unchanged (refused, one row, no value in the sentence), the wording is: a class instance or a record whose tools are inherited — `toolAxes cannot be read: it is not plain data (…)`; one tool's entry inherited — `the axes declared for tool "fetch_page" do not fit: it is not plain data (…)`; a getter that throws — `the axes declared for tool "fetch_page" do not fit: its getter threw while it was read`. *server*: a request whose A2A peers carry a declaration the reader refuses now fails at the engine's door (`config.invalid_argument`, subject `A2A peer "<name>"`) instead of starting; a test that expected the task to start with such a peer, or that pinned `config.egress_requires_write_effect` for an out-of-vocabulary peer `effect`, re-pins to the door's refusal. A resume whose `taskConfig` carries an unreadable A2A declaration is refused the way an MCP one is: before the claim, `checkpoint.config_refused` carrying `config.invalid_argument` (#1950, below). *any host* (open to veto): an MCP transport declared with `env: process.env` — or any record of an entry that is not plain data — is refused at the door (`MCP server "<name>" declares transport.env the engine cannot read: it is not plain data (…); copy it into plain data (a spread or a JSON round trip), writing out any member it inherits`) where 7.52.0 started the task; write `env: { ...process.env }`. The same for a declaration built in another realm (`vm`, a worker: a JSON round trip), a Proxy, a list element that is not a string, a key named `__proto__`. Checked for this entry: no MCP transport in `sema-server/src`, `sema-cli/src` or `sema-client-core/src` passes `process.env` as a declaration record (server builds its entries as literals, `src/task-mcp.ts`).
25
+ - **How to migrate.** *server*: the A2A request leg calls `readToolAxes(declared, "a2a")` at its own door and retires `tool-axis-words.ts` (the M19 migration reader keeps its own pre-upgrade reading). *web-admin*: nothing.
26
+ - **Pins.** `test/backlog1835-tool-axes-one-judge.test.ts` (the over-deep nesting cell: 50 000 and 1 001 nested lists under `allowSkills` in one sentence, the bound's, and 1 000 read whole); `test/backlog1989-declaration-receipt.test.ts` (E: the declaration mode is held to `MODS_VALUE_DEPTH`); `test/backlog2013-iterative-receipt.test.ts` (C); `test/backlog1763-mcp-tool-axes-judge.test.ts` re-pinned to the renamed type and to the one reading; `test/backlog1842d-plain-data-snapshot.test.ts`; `test/backlog1992-axes-fixed-name.test.ts`; `test/backlog1989-declaration-receipt.test.ts`.
27
+
28
+ ### `isMintableProbeReason(text)` is the length judge of a probe's one-sentence account, exported from the package root (#1883; additive export; hard-breaking: none; closed sets: none; exports: +1 function; members: none) (@server)
29
+
30
+ - **Before.** The engine capped `probeReason` at `PROBE_REASON_MAX` (200 code points) inside `inlineUntrusted`, whose longest result is the cap plus one truncation ellipsis; the cap was not exported, so a relaying host (server `approval-card.ts` `MAX_PROBE_REASON_CODEPOINTS = 201`) hand-copied the number and the "+1".
31
+ - **Now.** `isMintableProbeReason(text)` answers whether `text` is at most the longest sentence the engine mints (code points). The ceiling is `PROBE_REASON_MAX` plus `INLINE_TRUNCATION_MARK_CODEPOINTS` (the width of the engine's one truncation mark, a named constant `inlineUntrusted`'s own re-cap reads too), so the "+1" is written once. `PROBE_REASON_MAX` itself is not exported: no host needs the number, only the question. The judge is total: a value that is not a string is not a sentence the engine mints and answers `false` (never a throw), which is the relay's answer to an over-long one too — drop it whole.
32
+ - **How to migrate.** *server*: `if (!isMintableProbeReason(raw)) return undefined;` and delete `MAX_PROBE_REASON_CODEPOINTS`.
33
+ - **Pins.** `test/backlog1883-probe-reason-envelope.test.ts`.
34
+
35
+ ### The machine-read slot of a rule's text is the original in both notice codes, through one reading: `permission.rule_row_dropped` `detail.rows[].rule` changes from the neutralized spelling to the original string (#1884; consumer-visible value change in a notice detail, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @client-core)
36
+
37
+ - **Before.** `permission.allowed_tool_not_granted` handed `detail.entries[].entry` over as written (`Write ` with its trailing space), unbounded; `permission.rule_row_dropped` handed `detail.rows[].rule` over as `inlineUntrusted(rule, 200)` (`Write`; whitespace folded, ends trimmed, cut at 200): one kind of fact told two ways.
38
+ - **Now.** Both machine-read slots carry the original text through one function (`noticeRuleText`, `src/core/permission-rule-model.ts`): the string as written when it is at most 1 024 code points (`NOTICE_RULE_TEXT_MAX`, twice the grammar's own 512-character bound, so every text a rule can be is carried whole); a longer one is cut to 1 024 and ends in the engine's one truncation mark (`…`, the constant `inlineUntrusted` reads too), 1 025 code points in all. `NOTICE_RULE_TEXT_MAX` is not exported: a consumer reads the slot and never judges its length. Control characters (a NUL, a newline, a tab, U+2028) ride as data, like any other string in a notice `detail`: the detail is not a display surface, a consumer that renders it neutralizes it as it does `entries[].entry`. Key names are unchanged (`entries[].entry`, `rows[].rule`). `scopeRoot` stays neutralized and capped at 200.
39
+ - **The sentences.** They already share one spelling and are unchanged: the allowed-tools sentence writes each entry through `renderListEntry` (quoted, invisible characters written out, capped; the engine's one writer of a list entry into a sentence), and the dropped-rows sentence names no rule at all (its pin keeps the sentence free of a project path, which a rule text can carry), so no sentence spells one rule two ways.
40
+ - **Consumer-visible value change (open to veto).** (1) **What changed:** `rows[].rule` is the rule text as the store served it (the original up to 1 024 code points; a longer one is 1 024 plus the truncation mark, 1 025 in all) and no longer the neutralized spelling cut at 200; `entries[].entry` is bounded the same way (a longer entry was carried whole before; such an entry reads as no rule at either length). (2) **Who is hurt:** a consumer that relied on `rows[].rule` being one line and at most 201 code points, or that compared it with the neutralized form; the rows come from the person's own store (a foreign writer or a hand edit makes them) and the notice carries the first eight. (3) **Compensation:** a consumer that renders the slot neutralizes it, as it does `entries[].entry`.
41
+ - **How to migrate.** *server*: delete the 🚧 pin in `test/notice-entry-presentation.test.ts` (the two slots are now byte-identical) and keep `RULE_TEXT_DETAIL_FIELDS`'s two (array, field) pairs. *client-core*: nothing generated changes (no key added or removed).
42
+ - **Pins.** `test/backlog1884-notice-rule-text-one-reading.test.ts`.
43
+
44
+ ### `ANTHROPIC_DEFAULT_BASE_URL` is the Anthropic API's own request root, exported from the package root; the brain and the `fable5Model` preset read it (#1905; additive export; hard-breaking: none; closed sets: none; exports: +1 constant; members: none) (@server @cli)
45
+
46
+ - **Before.** The Anthropic brain kept the root as a private constant (`ANTHROPIC_LANE_DEFAULT`) and `fable5Model` wrote the same URL as a literal; server `src/brain.ts` and the shell mirrored a third and fourth copy.
47
+ - **Now.** One exported constant, `"https://api.anthropic.com"`, read by the brain (request root, pairing law's lane default) and by the preset's `baseUrl`. The engine's reading of a declared root beside it is unchanged: nothing, an empty string and whitespace read as undeclared (the lane default applies); trailing slashes fold.
48
+ - **How to migrate.** *server* (S-1655) and *cli*: import the constant and drop the mirrored literal.
49
+ - **Pins.** `test/backlog1905-anthropic-default-root.test.ts`.
50
+
51
+ ### A suspended `GoalResult` carries the gate it is parked at: `suspendGate`, on every suspended result and on no other (#1934; additive; hard-breaking: none; closed sets: none — the gate's words are `CheckpointGate`'s; exports: none; members: +1 optional `GoalResult.suspendGate`) (@cli)
52
+
53
+ - **Before.** `status: "suspended"` did not say whether the run was waiting for quota, stopped by the host to be resumed, or waiting for a decision; the gate that says it sat one level down (`result.terminal.gate`) behind an optional `result`.
54
+ - **Now.** `GoalResult.suspendGate?` (a `CheckpointGate`, narrowed to the kinds whose status word is `suspended` — `human`, `irreversible_ask`, `resource_limit`, and `task_done`, the handle a deployment parks, which a run of this engine mints in no case but a custom runner seat may return as a paused terminal; the two review kinds are `needs_review` and never appear: the pause registry's `taskStatus` column is the one table the status word and the type both read), present IFF `status === "suspended"`: the paused terminal's own gate, the same object as `result.terminal.gate`, beside the `checkpointToken` that rides up from the same terminal. Why the run is parked is read from the gate alone, never from the caller's signal: `{ kind: "resource_limit", reason: "usage_window" }` waits for quota (`resumeAfterMs`); `reason: "preempt"` is the host's own resumable stop (`TaskSpec.preemptSignal`); `human` / `irreversible_ask` wait on an approval; another `resource_limit` reason waits on an allowance. A caller's abort that ends a run is `status: "aborted"` (no checkpoint, no key); a park that won the race with it reads that park's gate. `needs_review` and every other status never carry the key.
55
+ - **How to migrate.** *cli* (L-1533): render off the gate — `gate.kind === "resource_limit" && gate.reason === "usage_window"` (waiting for quota), `gate.kind === "resource_limit" && gate.reason === "preempt"` (stopped by the host, resumable; test the kind too — a `human` / `irreversible_ask` gate's `reason` is free text, the approval's own sentence), every other gate by its own words (an approval pending, an allowance spent); read `"suspendGate" in result` or `status === "suspended"`, which are one test.
56
+ - **Pins.** `test/backlog1934-goal-suspend-cause.test.ts`.
57
+
58
+ ### A run with no memory store mounted and nothing that can persist is told so by the engine, in the memory band; `NO_PERSISTENT_MEMORY_NOTICE` leaves the package root (#1920; behavior change — a prompt addition, open to veto; **hard-breaking: the exported constant `NO_PERSISTENT_MEMORY_NOTICE` is removed, no alias**; closed sets: none; exports: −1 constant; members: none; the bundled catalog gains one message, `core/memory.no-persistent-store`, so its digest moves) (@server)
59
+
60
+ - **Before.** The memory disclosure seat had four arms, all for a mounted store (capture opt-out, capture state unknown, read-only in two forms, a declared channel retracting the read-only notice). With no store mounted the engine said nothing; the notice text was exported as `NO_PERSISTENT_MEMORY_NOTICE` for a host to compose in, and a host that did had to judge for itself whether memory was mounted and whether anything on the roster could persist (server's per-request judge: memory face mounted, hands read-only, file plane local, a persisting tool left after the selector).
61
+ - **Now.** The seat's fifth arm: no store configured to mount (no `TaskSpec.memory`, memory disabled, or enabled with no `RunnerDeps.memoryBackend`) **and** the roster-wide persistence verdict false ⇒ the notice is the memory band, at the tail of the prompt after the environment block, in the slot a mounted store's disclosure takes. The verdict is the one the four arms read, `rosterCanPersistMemory`: `TaskSpec.memoryPersistenceCapable` when it is set, otherwise whether a tool the run's tool face leaves mounted (what the tool valve leaves, below) can write a file on this host (a local `Write` / `Edit` / shell; a remote hand band or a read-only one cannot). The text is byte-identical to the retired constant and names no tool. A store that IS configured but failed to mount this run (the fail-open mount arm; the run's `effectiveMemoryScopes` reads `memoryless` / `mount-failed`) is not told this sentence, which is categorical and false once a later mount succeeds: in the same slot, under the same verdict and the same delegation rule, it gets the seat's writes-unavailable-right-now notice, with the failed mount as its cause and no stored notes promised below it. That notice is one template with two typed facts (`what`: the capture record store is unreadable, or the store did not mount; `notesReadable`), and a mounted store's unreadable-capture-state arm reads the same template — its text is byte-for-byte what it was (the internal constant `MEMORY_CAPTURE_INDETERMINATE_NOTICE`, never a package-root export, becomes `memoryUnavailableNotice(facts)`). Said to the run that holds the user's conversation: a root run, and a fork (it continues the parent's conversation); a delegated child (an `Agent` spawn, a workflow leg) is not told — the delegation fact is the one the prompt's `isSubagent` fact and `loadProjectMemory`'s `isSubagent` read. A mounted store's four arms are unchanged byte for byte, except under `excludeAllTools` (below).
62
+ - **Behavior change (open to veto).** (1) **What changed:** a root run or fork with no memory mounted and no persisting tool carries about 80 more tokens of system prompt (the notice). (2) **Who is hurt:** a host whose own memory tool persists through its closure (a tool that is not a local file writer) and that has not declared `memoryPersistenceCapable: true` — the model is now told it cannot store, beside a tool that can; and a deployment that mounts an MCP memory server (a server whose tools write but whose tool entries carry no `pathTarget` face, a knowledge-graph memory server for one): the verdict counts a tool as able to persist only by a known store path (a path-confinable write, or the shell family), so that server's writer does not count, and a run with no store mounted is told it cannot store while the writer sits mounted beside it (before this version it was told nothing). (3) **Compensation:** either of two, per deployment. Declare the channel, `memoryPersistenceCapable: true` (the existing seat; the notice is not said and the declaration means the same thing to the four mounted arms). Or give the server's writer tool a write face, `toolFaces.<tool>.pathTarget` with `access: "create"` on that server's entry (its `param` must name a top-level parameter of the tool, and the face also puts that tool's calls under the path fences, so it fits a tool whose argument names where it writes): the verdict then counts the tool and the notice is not said. The face is the remedy only where the hands are local: under a remote execution environment with hands no tool counts (a remote env's file tools write the sandbox, not the host-side memory root), and only the declaration silences the notice there.
63
+ - **The tool valve has one reading, `valveLeaves(spec, selector)` (behavior change, open to veto).** What `excludeAllTools: true` unmounts (the whole face) was known to the valve and, by a hand-written copy, to the full shell's reachability; the persistence verdict, the memory write channel and the two memory pairs' planning asked only the selector, so they answered for tools the valve then removed. All six now read the valve's one predicate (`src/agents/tool-filter.ts`, beside `compileToolSelector`). (1) **What changed:** with `excludeAllTools: true` — a run with no store mounted gets the notice (it had none, beside tools that were unmounted); **a run with a mounted writable store is no longer taught the write path, it gets the read-only notice** (it was taught to write through a `Write` the valve had unmounted), and the memory pair's retrieval paragraph is no longer taught; the memory pairs' not-mounted advisory now fires, its reason reading "the run's tool face (allowTools / excludeTools / excludeAllTools) does not admit …" (was "the run's tool selector (allowTools / excludeTools) …", for both pairs). (2) **Who is hurt:** an operator matching the advisory's old wording. (3) **Compensation:** none needed; the prompt now says what the run can do.
64
+ - **BREAKING (exports −1).** `NO_PERSISTENT_MEMORY_NOTICE` is removed from the package root (and from `src/prompts/default.ts`); the text is the catalog message `core/memory.no-persistent-store` and the engine composes it. No alias is kept (a host composing it beside the engine would say it twice). Who breaks: a host that imports the constant — `sema-server` composes it per request today; and, with no import involved, a deployment that mounts an MCP memory server (writer tools with no `pathTarget` face), which is newly told it cannot store (Behavior change (2), remedies in (3): `memoryPersistenceCapable: true`, or, with local hands, the writer's `toolFaces.<tool>.pathTarget`). Migrate: delete the import and the judgment that decides when to compose it; the engine says it in exactly those runs.
65
+ - **How to migrate.** *server*: drop the import of `NO_PERSISTENT_MEMORY_NOTICE` and the per-request four-condition judge; a deployment whose memory channel is a custom tool sets `memoryPersistenceCapable: true` on the task; a deployment that mounts an MCP memory server either sets the same flag or, with local hands, declares the writer tool's `toolFaces.<tool>.pathTarget` (`access: "create"`) on that server's entry (Behavior change (3)). *cli*, *client-core*: nothing.
66
+ - **Pins.** `test/backlog1920-memory-not-mounted-arm.test.ts`; `test/prompt-catalog-m0.test.ts` (catalog digest refrozen), `test/tool-name-literal-gate.test.ts` (the notice's "remember" moved from `src/prompts/default.ts` to `src/prompt-assembly/catalog/memory.ts`), `test/memory-consolidation.test.ts`, `test/prompt-lock-text-survey.test.ts` re-pinned to the catalog text; `test/backlog1797-memory-advisory-per-runner.test.ts`, `test/backlog560-memory-advisory-once.test.ts`, `test/prepare-project-context-transcript.test.ts` re-pinned to the advisory's new reason wording; `test/resume-at.test.ts` and `test/prompt-prefix-stability.test.ts` recalibrated against the notice in the prompt (the reserve that applies at a 3000-token window is 900, declared as such; no fixture declares a channel only to keep its numbers).
67
+ ### `Artifact(action:<word>)` is a rule — deny, ask and allow, stored, listed, offered on the card; a path / name / host ALLOW carrying `<` or `>` is refused (#740, #520 merged; behavior: an Artifact card now carries a rule offer, open to veto; behavior narrowing ×5, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@cli @server)
68
+
69
+ - **Before.** The persisted rule store, the allow-layer lists and the card's "don't ask again" had no form for a rule on one action of the `Artifact` tool: `Artifact(action:read)` was `unsupported.tool` in the store, `content_unsupported` in a list, and an Artifact approval card carried neither `ruleOffers` nor `ruleOffersAbsence`. The caller policy refused it as a primary field unless `primaryFieldGeneric: "allow-lexical"`, and then read it as a lexical glob. Path rules took `<` and `>` (`Read(//tmp/o>/dev/null/**)`) while the cd mint floor refused them.
70
+ - **Now.** `Artifact(action:<word>)` is the name grammar's KEYED spelling (the name grammar = a rule on the value of one parameter; `Skill(<name>)` is its bare spelling): `<word>` is one of the tool's declared actions (`ARTIFACT_ACTIONS`), canonical text as written, `match: "name"`, `command: <word>`, every behavior. One reader everywhere: the store's lane (deny refuses with `deniedBy: "persisted_rule"`, ask is `askClass: "ask_rule"`, allow clears an ordinary ask for that action and never a mandated one), the allow-layer lists, the caller policy (now WITHOUT the lexical opt-in; the tool's other arguments, `Artifact(url:…)`, keep the generic `param:pattern` lane), and the card. CC 2.1.296 reads the same spelling (the key and the value each trimmed, as here: `Artifact( action: publish )` is `Artifact(action:publish)`) and mints `Artifact(action:reply)` itself, but reads a keyed allow for `reply` only. Against it ours is stricter on two points (exact word from the declared set, no `*` glob) and wider on one (an allow may name any of the five declared actions, `publish` included, and the card offers each of them; bounded: an allow clears the ordinary question for that one action and never a mandated one). All three are registered divergences. `RuleGrammar`, `PersistedRuleMatch`, `RuleRejectCode` and `PermissionRuleIssue.code` are unchanged. A call of a keyed tool that still names no `action` after the tool's own reading (`prepareArguments`) — an approver's edit, the `updatedInput` of a hook or of a caller `ToolPolicy` verdict, a preparer that leaves it out, or the model's call to a caller tool that has no preparer (the first-party tool fills its default before any judge) — would run an action nobody judged, so every `Artifact(action:…)` deny or ask refuses it on every path (`deniedBy: "persisted_rule"` / `"policy"`, no question is asked) and no action allow grants it; a judge handed a request that carries no stamped reading (a policy that rebuilds the request, a `runToolGate` host that passes no `event.readArguments`) refuses the same way (#1974). One byte table (`<` `>`) is refused in every path, name and host ALLOW (`invalid.grammar`) and is the base of the cd mint floor's screen; a deny / ask may name the bytes and keeps its meaning.
71
+ - **Behavior (open to veto) — the Artifact card speaks.** (1) **What changed:** an Artifact ask on a leg with an armed rule lane carries `ruleOffers: [{ kind: "single", rule: "Artifact(action:<word>)", match: "name", command: "<word>" }]`, or a `ruleOffersAbsence` word under the shell card's doors (`mandated`, `ask_rule_matched`, …); before, both seats were absent. `prepareCardApproval({ toolName: "Artifact", command: <the action word> })` mints the record. (2) **Who is hurt:** a surface that treated an Artifact card as offer-less. (3) **Compensation:** none needed beyond rendering the offer the shell and Skill cards already render.
72
+ - **Behavior (open to veto) — the byte screen's polarity.** (1) **What changed:** against 7.52.0, which screened no rule's bytes (a path rule took `<` / `>` under every behavior; only the cd mint floor refused them), the one change is on ALLOW rows — narrowing (a) below; the screen does not touch a deny / ask row, as 7.52.0 touched none. (2) **Who is hurt:** nobody beyond narrowing (a). (3) **Compensation:** none beyond narrowing (a).
73
+ - **Behavior narrowing ×5 (open to veto).** (1) **What changed:** (a) a path, Skill or host ALLOW carrying `<` or `>` is refused (`invalid.grammar`) — a stored one is dropped by the next read that re-parses it (the sync path; a plain durable read serves stored rows as stored); deny and ask rows are not screened, so no standing tightening is lost; (b) a caller policy `Artifact(action:<glob>)` / `Artifact(action:<word outside the set>)` is refused (`invalid.command_rule`) where `primaryFieldGeneric: "allow-lexical"` used to compile it as a lexical glob; (c) a call that names no `action` as the tool reads it — a caller's tool mounted as `Artifact`, or answering to it, with no argument preparation of its own (the built-in tool's reading names `publish`) — is refused by every `Artifact(action:…)` deny or ask rule, no question asked (`deniedBy: "persisted_rule"` / `"policy"`), and granted by no action allow, where 7.52.0 had no action rule that reached it (the keyless-call sentence of **Now** above; #1974); (d) the persisted lane now speaks for `Artifact`: when the person's rule partition cannot be read, EVERY Artifact call — the read-only `list` included — is a real-approval question (`askClass: "rule_unreadable"`, no rule clears it), and a deployment that answers every ordinary ask `allow` has it refused, where the tool ran before (the law `Skill` and `WebFetch` already follow); (e) an org row the layer cannot read — a row of another grammar than the shell's (`Skill(x)`, `Artifact(action:publish)`, `Read(…)`), or a shell row the rule validator refuses (`Bash(ls > x)`) — in a resolution a store hands over without screening it (the store interface is public) is no longer skipped, which let the published deny never fire: it stands at its strictest reading over the calls it may reach (a deny row refuses them, an ask row asks a person; the refusal names the row by place and code), every readable row holds as published — one law for every store, the in-tree partition included (the next entry, #1986). (2) **Who is hurt:** a person with an allow on a directory whose name contains `<` / `>`; a deployment with a globbed action rule; a caller's tool mounted as or answering to `Artifact` with no argument preparation that sends no `action` (the built-in tool's reading names `publish` for a missing `action`, so an approver's edit cannot remove it); anyone whose rule store cannot be read — a person at a prompt meets a question even on a `list`, and a headless `onAsk: "allow"` deployment has every Artifact call refused until the store reads; a deployment whose own org store hands over a row of another grammar (the calls that row may reach are then refused, or asked for an ask row). (3) **Compensation:** (a)–(c) the refusal names the reason; write the action rules out one word each, or the bare `Artifact` entry for the whole tool; an edit that keeps `action` is judged as before; (d) mend the store, or declare an `onAsk` that answers the question — there is no other way past it; (e) publish org rows in the shell's grammar only.
74
+ - **How to migrate.** *cli*: render the Artifact card's offer like the Skill card's; redeem with `command` = the offer's `command`. *server*: accept `Artifact(action:<word>)` in settings deny / ask / allow lists (no closed-set change); publish org rules in the shell's grammar only (`Bash(…)` — any other row stands at its strictest reading over the calls it may reach, on every store; #1986).
75
+ - **Pins.** `test/backlog740-artifact-action-rule.test.ts`, `test/backlog1982-org-lane-reads-one-grammar.test.ts`.
76
+
77
+ ### One org snapshot, one answer, whichever store delivers it: a row the org layer cannot read stands at its strictest reading over the calls it may reach — a deny row refuses them, an ask row asks a person — and every readable row holds; the snapshot is installed and served on its envelope, degraded and named, never refused whole for a row (#1986, #1983; P2; security — a published deny became an approvable question; behavior change — a narrowing on the calls an unreadable row reaches and a loosening on the calls none reaches, open to veto; hard-breaking: none; closed sets: none; exports: none on the package root; members: `orgRuleVerdictFor`'s call widens additively (`tool` may be the call's list of rule words, `command` optional, `face?` the face of its path target); `OrgGateVerdict.verdict.unreadable` may ride a `deny` too (additive meaning: the row is not one the org layer reads); `unenforceableOrgRules` also reports a row with no text or with a behavior other than `deny` / `ask`) (@server @cli @client-core @test)
78
+
79
+ - **Before.** Two readings of one fact. The in-tree org partition refused a whole snapshot that carried one row it could not read: with a last-known-good inside the bound the older snapshot kept serving (the newer one's denies never applied), and with none the resolution was unavailable — every terminal allow became a real-approval question a person could APPROVE, so under `[Bash(touch:*) deny, Skill(x) deny]` `touch` ran after one approval. A store of the host's own handing over the same rows got the lane's reading (7.53.0 #740 (e), #1982): the readable deny refused `touch`, every other call was unavailable. The lane also parsed every org row twice per call (its verdict and a separate screen) and named one row where the partition named twenty.
80
+ - **Now.** One reader of org rows (`readOrgRules`, `src/core/permission-rule-org.ts`), each row read once per adjudication, for the partition, the lane, `orgRuleVerdictFor`, `unenforceableOrgRules` and the shadow view alike. A row is read when its behavior is `deny` / `ask` and its text is a shell command rule under it; any other row (another grammar's rule, a shell rule the validator refuses, no text, a field that cannot be read, a behavior other than `deny` / `ask`) stands at its strictest reading — `ask` only for a row that says `ask`, else a refusal (`deniedBy: "org"`) — over the calls of the tool it names (a `Read` / `Edit` row also over the calls on that tool's path faces; a row naming no tool the layer can place, every call). The refusal and the question name the row: `…, which could not be read against it (row 2 of the organization snapshot is not a rule this layer reads (unsupported.tool); republish it in the shell tool's command grammar)`. A snapshot installs and serves on its envelope (revision, observation time, a rules list) and the anti-rollback fence; the last-known-good is the last snapshot whose envelope was sound, never kept in force because a newer one carries an unreadable row. A snapshot with unreadable rows is served DEGRADED (`fresh` / `last-known-good`, every row in `rules`) with one line — count, the first twenty by place and code, `and N more` — on the resolution's `disclosures` and, from the lane, on the run's `permission.rule_store_unreadable` trace on every adjudication. An unreadable deny row shadows every personal rule of a tool it reaches. Whether `rules` is a list is judged in one place for the partition's envelope and for a resolution a store of the host's own hands over: an array is a list (`Array.isArray` — an Array subclass's instance and another realm's array included, #2011; P2), and rules that are not one (`{}`, a string, `null`, nothing, a Proxy whose traps would answer for the list's length) are unavailable governance at the org lane too — the non-dismissable real-approval question — never a policy of zero rows that let every call run unasked (#2004; P1); `orgRuleVerdictFor` / `unenforceableOrgRules` / `orgRuleShadows`, which have no unavailable word, throw a `TypeError` on them instead of reading zero rows, and a store's `revision` that cannot be read drops the ask's revision evidence, never the verdict. The list is received in ONE receipt of the engine's (each accessor run once per reading; an object two rows share — one row's `metadata` standing as another row — is read once, where reading it per row let its getter answer `rm` to one copy and `echo` to the other and no rm deny stood, #2012; P1), and a row is held and judged as its policy — its own `rule` and `behavior` that hold a JSON scalar, nothing else (`ORG_ROW_POLICY_MEMBERS`): a member that cannot be received, or that holds a structure, is one the row does not have — no `behavior` ⇒ the row's strictest reading over the calls of the tool its text names (`invalid.grammar: no behavior`), no `rule` ⇒ every call, never part of a row applied —, any other member (a `Date`, a bigint, a typed array, an entity object) is never judged, held, served or persisted, and a class instance, another realm's row or a list carrying the members as its own reads the same on every seat (#2015; P2). The receipt reads a row's own members once and never walks a structure a member holds (a getter inside it never runs, its size costs nothing — #2049; P2), so a row whose other members fan out through shared references costs nothing and no non-policy member is serialized (#2043; P1) — and a list-shaped row is the same row on the lane and in the partition (#2044; P2) — while a list element whose read throws, or a row that cannot be received at all (a Proxy, a function), is a row with nothing in it (no rule text — a refusal over every call, named by its place), the readable rows beside it keeping their effect, on the lane and on the partition alike — the partition no longer refuses such a snapshot whole as a source failure, which with no last-known-good made a matching published deny an approvable question (#2005; P1). The partition holds a snapshot it fetched and one it reads back from its persistence in that one form — its envelope's `revision` / `fetchedAtMs` / `signature` holding a JSON scalar and each row its policy; nothing else the source or the persistence carries is read or persisted (#2049), and a persisted state whose snapshot is missing keeps its high-water mark and serves nothing — and "the same revision, the same content" is one test (`sameOrgPolicyContent`: the policy members, row for row) for its guard and for the file backend's install, so a source re-serving an installed revision with class-instance or `Date`-carrying rows renews instead of aging into the approvable unavailable question (#2014; P2) — except on the upgrade path: a row 7.52.0 persisted through a JSON backend from an array-shaped row (`rule` / `behavior` on a list, which only an in-process source hands over and JSON spells `[]`) holds no policy members, so the same revision re-fetched with the row carrying them reads as DIFFERENT policy content, the swap is refused, and the kept snapshot's empty row refuses every call while it stays inside the staleness bound (7.52.0: unavailable, an approvable question) — until the org revision moves (KL-1986-c, #2064).
81
+ - **Behavior (open to veto).** (1) **What changed:** a call an unreadable deny row reaches is refused (was: an approvable real-approval question on the in-tree seat; unavailable on the lane); a call an unreadable ask row reaches is a real-approval question (as before on both seats, now with the row named); a call no unreadable row reaches is judged by the readable rows alone (was: a real-approval question for every call on both seats — e.g. every `Read` under a `Skill(x)` row); a newer snapshot carrying an unreadable row replaces the last-known-good; an allow row in an org snapshot refuses the calls of its tool (was: the whole snapshot refused); rules handed over as an Array subclass's instance or another realm's array are read as a list (was: unavailable — an approvable question); a row whose `behavior` cannot be read refuses the calls of the tool its text names only, and a row carrying a member no judgment reads, or built as a class instance or in another realm, is read as published (was: every call refused), a list-shaped row carrying `rule` / `behavior` as its own members too in the partition (was: held as `[]`, every call refused there); the file backend installs a fresher observation of an installed revision whose rows differ only outside their policy members (was: refused as different content). (2) **Who is hurt:** a deployment whose org snapshot carries a row naming no tool the layer can place (`(((`, a misspelled tool) — every governed call is refused until it republishes (KL-1986-a); a consumer fixture that pins an org `deny` verdict without `unreadable`, or the org refusal sentence without its clause; a store of the host's own handing over its rules as a Proxy over a list carrying a deny — 7.52.0 refused the matching call by chance (the traps answered as a list), now the rules are unavailable governance, a real-approval question a person may approve (#2004, #2011); @server: org rows served by the in-tree partition (`effective().org.rules`, and the persisted state) carry only `rule` / `behavior` from 7.53.0, and a persisted snapshot only `revision` / `fetchedAtMs` / `signature` beside them — a consumer that read `id`, `note` or any other decoration off them takes it from its own store (the 7.52.0 `OrgPermissionRule` / `OrgRuleSnapshot` types already typed only these members; #2043, #2044, #2049). (3) **Compensation:** republish the named rows in the shell's grammar; the degraded line and every refusal name each row by place and code.
82
+ - **Behavior narrowing (open to veto) — the org rules list is bounded by its length (#2062).** (1) **What changed:** an org rules list longer than the rule compiler's own bound (`maxRules`, 256) is refused whole — by its `length`, before any place of it is read or any row built: a fetched snapshot is not installed (the previous one keeps serving; with none the resolution is unavailable), a persisted snapshot read back is unavailable, the org lane answers unavailable, and `unenforceableOrgRules`, `orgRuleVerdictFor`, `orgRuleShadows` and `compileOrgShadowPredicate` throw a `TypeError` in the same sentence (`rules list has N entries; the cap is 256 — refused whole (not truncated)`); nothing is truncated (was: any length was received and one policy object was built per place, a hole included, so `rules: new Array(1e8)` exhausted the process before any check — 1e6 places cost about 61 MiB; 7.52.0 refused at the first hole). (2) **Who is hurt:** a deployment whose org source serves more than 256 rows — the whole snapshot is refused: with a last-known-good the previous snapshot keeps serving (the newer denies never apply; the refusal line names the count), with none the lane reads unavailable (every terminal allow becomes a real-approval question; never zero rows); a caller of the four helpers above with more than 256 rows, which were read before. (3) **Compensation:** split or trim the org policy at its source to at most 256 rows; nothing in the engine truncates.
83
+ - **How to migrate.** *server*: an org snapshot with a row of another grammar now serves (degraded) instead of being refused — render `effective().org.disclosures` on an available resolution too (the degraded line rides there); a verdict reader treats `verdict.unreadable` as optional on either behavior. *cli* / *client-core*: an org refusal may carry `, which could not be read against it (…)` — render the sentence as given. *test*: black-box — one snapshot through the in-tree partition and through a host store gives one answer per call. an org source serving more than 256 rows is refused whole — trim at the source (the refusal rides the same `disclosures`)
84
+ - **Known limits.** KL-1986-a (a row naming no tool the layer can place reaches every call), KL-1986-b (the degraded state has no engine notice; a call no unreadable row reaches carries no signal of it), KL-1986-c (a 7.52.0 JSON-persisted array-shaped row blocks a same-revision renewal on the upgrade; #2064).
85
+ - **Pins.** `test/backlog1986-org-one-reading.test.ts`, `test/backlog1986-org-rows-parsed-once.test.ts`, `test/backlog2004-org-lane-rules-envelope.test.ts`, `test/backlog2005-org-row-read-guard.test.ts`, `test/backlog2011-org-rules-list-judgment.test.ts`, `test/backlog2012-org-one-receipt.test.ts`; re-pinned: `test/backlog1982-org-lane-reads-one-grammar.test.ts`, `test/permission-rule-org.test.ts` (six cells), `test/backlog1318-skill-rule-cc-form.test.ts` H2 (wording), `test/backlog2062-org-rules-list-bound.test.ts`.
86
+
87
+ ### What a judge reads is what runs: every rewrite of a call — an approver's edit, a hook's or a policy's `updatedInput`, a resumed decision's edit — is read the tool's way (its argument preparation, then the schema's coercions) before it is judged, and the loop runs exactly the gate's output (#1974; P1; security — a stored deny was bypassable; behavior narrowing, open to veto; hard-breaking: none for a Runner deployment — an embedder that drives the loop with its own `beforeToolCall` and returns an `updatedInput` the tool's reading would still move is now refused, open to veto; closed sets: none; exports: none; members: on the package root, `runToolGate`'s `ToolGateInput.event` +1 optional `readArguments` (the loop's reading of the call; a host driving the gate without it has rewrites judged as written, and keyed rules refuse one that drops the key); off the root, the engine's `BeforeToolCallContext` and the harness `ToolCallEvent` gain optional `readArguments`) (@server @cli)
88
+
89
+ - **Before.** The loop read the model's call through the tool's `prepareArguments` before the gate, but a rewrite was judged as written and prepared only at execution (`defineTool`'s `execute`, the tool body's own default). A tool whose preparation fills a default or folds an alias could be steered past a stored deny by dropping the key: from an `Artifact` `action: "list"` call, an approved edit to `{ file_path }` and an approved re-ask ran a publish under an `Artifact(action:publish)` deny (a persisted row or a caller policy alike); a hook rewrite did the same with no question at all; an edit to `subagent_type: "general-purpose"` was judged as a typed delegation and ran as the plain one. The previous entry's fail-closed reading of a keyless call only turned the deny into an approvable ask.
90
+ - **Now.** One reading of a call's arguments, owned by the loop: its preparation then the schema's coercions (`readToolCallArguments`). The loop hands it to the gate (`readArguments`), the gate stamps it on the call's face, and ONE reader (`argsAsTheToolReads`) takes every rewrite through it where it is adopted — the PreToolUse rewrite, the policy rewrite, each round of an approved edit (and the hook / re-check rewrites inside it), the gate's exit, the composition's rewrite cascade, the chain's final-form re-check, an inherited approver's edit — and `withArgsReading`, the one throat for "this request with these arguments", reads through it too. The resumed call's decided args are read through the live tool before its belts and its execution, and every face a belt request carries holds the same reading (a belt's composed policy reads each rewrite it cascades; a belt rewrite is compared as read). The loop takes `updatedInput` once (one snapshot of the member and its values), reads it as it reads the model's call (a throwing preparation refuses it, as it refuses a model call), and refuses one its reading would move (it was not judged in the form that runs); `defineTool`'s execute-time preparation is then identity on every engine path. A person's yes to an edit covers the edit as the tool reads it (the record keeps its settlement). The keyless-call arm of the previous entry stays, as a REFUSAL: a call that still names no `action` after the tool's reading — a caller's tool mounted as `Artifact`, or answering to it, with no argument preparation of its own, its default filled in inside `execute` — is refused by every `Artifact(action:…)` deny or ask rule (an ask nobody can read is not put to a person, and an approvable ask let a deny be approved past) and granted by no allow: one predicate (`keyedValueAbsentReason`) in the persisted walk, the caller policy's deny tier and the allow layer's lists. The predicate is on the value the judge holds, so it also covers every judge no reading reaches: a deployment policy that rebuilds or copies the request before its own composition (the stamped reading is lost, a layer's rewrite cascades as written), and a host driving `runToolGate` without `event.readArguments` (rewrites are judged as written) — a keyless value there is refused, never passed. A host driving `runToolGate` must pass `event.readArguments` (the loop's reading of the call) for rewrites to be judged in the form that runs; without it, keyed rules refuse a rewrite that drops the key. The built-in `Artifact` never meets the refusal through a Runner — its reading names `publish` — and the `Artifact` default lives in one function both seats read. A generic `param:value` rule of the caller policy meets a call through the same reading: a call (as read) without the parameter is reached by a rule whose exact value the tool's reading folds away — `Agent(subagent_type:general-purpose)` reaches the delegation tool's `general-purpose` call (read as the plain delegation), an approver's edit to it, and the plain delegation itself (the same call as read); a `*` pattern names no one value to read and matches spelled values only. On a resumed call a deny reads nothing; an approved call whose arguments the tool cannot read (the decision's edit, or the resumed PreToolUse re-screen's rewrite, makes its preparation throw) is not run and is SETTLED as the loop settles such a call — an error result naming the failure with the thrown text and marks, `notExecuted` on the frame and the row, the row resolved — so the model re-issues it and a fresh decision runs; a belt policy (the deployment's re-check, the narrowing rules) that fails while judging — a throw, or a rewrite its composition cascades that the tool cannot read — refuses in the policy-throw words the live gate uses (`failedJudgmentDeny`), and a belt rewrite the tool cannot read is not the approved value. Affected tools: `Artifact` (absent `action` ⇒ `publish`), the delegation tool (`general-purpose` ⇒ the plain delegation), `Write` (`path` / `file_text` / `file_content` ⇒ `file_path` / `content`), and any caller `ToolSpec.prepareArguments`.
91
+ - **Behavior narrowing (open to veto).** (1) **What changed:** a stored deny refuses a rewrite whose reading it reaches (where it was an approvable ask, or ran unasked from a hook); the re-ask card shows the read arguments (`action: "publish"` visible on an edit that dropped it); a Write edit spelled with an alias is repaired and judged, where it used to be refused by the schema after the approval; a hand-rolled `beforeToolCall` returning arguments in a form the tool would re-read is refused with "the arguments cleared for … are not the ones it would run"; a call naming no `action` of a caller's tool named or aliased `Artifact` is refused by an action deny or ask rule (it ran its default unasked), as is a bare request naming none judged outside a Runner (it was reached by no rule), a rewrite dropping `action` judged by a policy that rebuilt its request (it published unasked), and an edit dropping `action` through a `runToolGate` host passing no `readArguments` (it was cleared); `Agent(subagent_type:general-purpose)` refuses the `general-purpose` call in every form and the plain delegation (it reached none: the reading folds the value away); a resumed approval whose edit the tool cannot read settles as a not-executed result where it reopened the checkpoint as `env_failed` and refused every corrected retry (`checkpoint.reopen_revote`); a resumed call's belt policy that throws is that belt's refusal (`deniedBy: "policy"`, row resolved) where the throw escaped the leg into the same `env_failed` / `reopen_revote` trap (a defect the previous release shared, fixed here); a resumed edit that drops `action` is refused before the claim (`resume.constraint_rejected`, the row still decidable) where it was refused after it. (2) **Who is hurt:** an approver who relied on dropping a key to get past a deny (the point); an embedder of the bare loop whose gate rewrites without reading; a deployment that mounts its own `Artifact` without argument preparation and leans on its execute-time default under an action rule; a `runToolGate` host that does not pass `readArguments`; a deployment that denies the spelled `general-purpose` but wants plain delegations; an approver who, under a keyed deny or ask for ANOTHER action (`Artifact(action:read)`), edits a parked call's arguments without the key — refused before the claim, the row still decidable (KL-1974-a; #1988 has the pre-claim check read through the live tool). (3) **Compensation:** none for the first; the second reads its rewrite through `readArguments` (on the context it already receives); the third gives its tool a `prepareArguments` that fills the default (its rules then read the action it runs), or has the call name its action; the fourth passes `event.readArguments`; the fifth has nothing to tell apart — the two spellings run the same delegation; the sixth re-decides with the key spelled out (`action: "publish"`).
92
+ - **Known limit.** The pre-claim judgment of a resumed decision's edit has no tool objects yet and judges the edit as written; the post-claim belt judges, and execution runs, the read form — the belt is the authority (KL-1974-a). With no reading there, an edit naming no `action` is refused before the claim by any `Artifact(action:…)` deny or ask — also one for an action the tool's reading would not run (an over-refusal: the row stays pending, and re-deciding with the action spelled goes through; #1988).
93
+ - **How to migrate.** *server*: nothing to change; an approval edit that crosses a stored deny now ends as that deny's refusal (`deniedBy: "persisted_rule"` / `"policy"`), not a second card; a resumed approval the tool cannot read now ends as a resolved row with a `tool_end` that is `isError` + `notExecuted: true` under the decided `allowed` record (no reopen, no `resume.reopened_unstarted` terminal) — the run continues. *cli*: the post-edit refusal is the deny's own sentence; an edit's re-ask card may carry a key the person did not type (the tool's default) — render it as given; render the settled unreadable approval as not executed (its sentence says why).
94
+ - **Pins.** `test/backlog1974-judge-what-runs.test.ts`; `test/backlog1974b-keyless-and-unreadable-resume.test.ts` (the caller tool and its alias under stored / caller deny and ask, the built-in tool still asked, the three readers' one predicate, the resumed edit / deny / re-screen rewrite / belt cascade, a policy that rebuilds its request, a `runToolGate` host without `readArguments`, `Agent(subagent_type:general-purpose)` in model, edit and plain form); `test/backlog740-artifact-action-rule.test.ts` ⑧ (the bare keyless request: refused by the deny / ask rows, granted by no allow), `test/artifact-surface.test.ts` (the default-action cell: the bare keyless request is refused), `test/backlog1328-parked-skill-rescreen-swap.test.ts` ⑨ / ⑨b (a throwing belt policy: the belt's named refusal, no longer an interrupted run) and `test/backlog1059-unknown-tool-sentences.test.ts` follow — the #1059 cell "an approver's edit carrying a Write alias is refused" now reads: repaired before it is judged, re-put canonically, run as that.
95
+
96
+ ### A person's halt ends a running foreground shell command, armed for auto-backgrounding or not — documented against CC 2.1.296's four background causes (#745, C-R514; no behavior change; hard-breaking: none; closed sets: none; exports: none; members: none) (@cli)
97
+
98
+ - **Before.** The ticket read CC's `backgroundedByTurnAbort` as the person's halt and planned "halt + auto-background-armed ⇒ moved to the background".
99
+ - **Now.** CC 2.1.296 ends a running foreground command on the person's stop (Esc = `user-cancel`, the SDK's plain interrupt: "running tools are ended") and moves it to the background only for a person's detach, its own timeout, a queued message, or a plugin's turn abort / a permission stop that ends the turn. `halt()` is the person's stop: the command is ended, armed or not, the turn reads cut (`task.turn_interrupted{cause:"user_halt"}`), nothing is registered in the background. The map is in `docs/INTEGRATION-CORE.md` §4.6y; no seat exists here for the plugin turn-abort cause.
100
+ - **How to migrate.** *cli* (L-262): render a halted turn's shell command as interrupted, never as "moved to the background".
101
+ - **Pins.** `test/backlog745-halt-foreground-shell.test.ts`.
102
+ ### The binding is a prompt selector: an overlay of any class (center, deploy, task) applies to a scenario by `when.binding`, and `prompt.compose` carries the binding; the center-only `scope.binding` retires with center artifact `schemaVersion` 3 (#1919; minor; **hard-breaking, open to veto**: a v2 center artifact is refused, an overlay carrying `scope` is refused, an artifact pairing an unconditioned `replace` with a binding-conditioned `replace` of one target no longer verifies; behavior change, open to veto: `prompt.compose` pins `catalog` and `selectors` beside `model`, and every pinned key of every mods station is compared by value (a copy of the engine's fact is taken, and the handler below reads the engine's own member — for anything inside it too; a copy nested past 1 000 levels is refused as too deep to compare; 7.52.0 compared by reference) with `KERNEL_CONTRACT_VERSION` 1 → 2 (no host reads it today); behavior narrowing, open to veto: every value a handler gives the bus — a member it hands on, its answer, a `$` call's arguments — nested past those same 1 000 levels is refused by its receipt (7.52.0 took it up to the platform's stack); behavior loosening, open to veto: the engine's own event is received at any depth, so a model's tool input too deep for the platform's stack no longer denies the call at a PreToolUse screening, and an observe-only handler receives the same event whole (#2013, #2065); an artifact digest is over each overlay's `when` in normal form; every character bound of the artifact verifier counts Unicode code points (`PROMPT_BINDING_MAX_CHARS`, `sourceRevision`, `evidence.intent`, a reference; looser); a trait-conditioned deploy `drop` of an entry absent this run is one `superseded` record and one `onError` line `(absent this run)` on every such run (7.52.0 silent); a stored copy the file store cannot read is told apart from a missing one; closed sets: the `when` member set `traits, host, facts` +1 `binding`; exports: +2 constants `PROMPT_BINDING_MAX_CHARS` (128) and `PROMPT_BINDING_LIST_MAX` (64), +2 types `StoredArtifactRefusal` and `OnArtifactRefused`, −0; `PromptVariantWhen` gains `binding?`, `PromptOverlay` loses `scope`, `PromptArtifactPayload.schemaVersion` is `3`; members: `prompt.compose` `e.selectors.binding?`; `CenterPromptSource` option `onError?` (the `RunnerDeps.onError` shape); `PromptArtifactStore.get` / `PromptArtifactSource.get` take an optional second argument `onRefused` (`StoredArtifactRefusal.code`: a verifier code or `unreadable`); `KERNEL_CONTRACT_VERSION` 1 → 2) (@server @cli @client-core @web-admin)
103
+
104
+ - **Before.** One fact — which scenario this run is (`TaskSpec.promptBinding`) — had two readings. A center artifact's overlays were filtered by `scope.binding` exact equality before the composition; a deploy or task overlay could name no binding at all (`scope` outside the center was refused), and `prompt.compose`'s `e.selectors` was a second, hand-built view of the selector values without the binding. So a deployment's brand line that belongs to two scenarios (`default`, `code`) and not to a third with the same host role (`code-review`) had no way to say so on a Runner shared across scenarios.
105
+ - **Now.** The binding is the selector's fourth dimension: minted once with the others (`selectorsOf`), projected once to the plain record (`selectorRecordOf`) that the manifest's `selectors` shows, the identity's `binding` element digests, and `prompt.compose`'s `e.selectors` carries. `when.binding` is `{ anyOf: [...] }` or `{ noneOf: [...] }` — exactly one list, naming at least one and at most `PROMPT_BINDING_LIST_MAX` (64) bindings, none twice, each non-blank and at most `PROMPT_BINDING_MAX_CHARS` (128) characters — **Unicode code points** (`[...s].length`), not UTF-16 code units: the one unit of every character bound of the artifact verifier (a binding, an overlay id, `sourceRevision`, `evidence.intent`, a reference — the last three counted UTF-16 units in 7.52.0), so a character outside the BMP (an emoji, a rare ideograph) counts one, not two; this loosens each of those counts and nothing else (a name of 65–128 astral characters, refused before, is taken; so is a `sourceRevision` or a reference of 251–500 astral characters and an `evidence.intent` of 1 001–2 000; nothing taken before is refused; the overlay text budgets stay UTF-8 bytes) — the bound `TaskSpec.promptBinding` is held to; both are exported from the package root, the one binding the engine reads too, so a host screens with the engine's numbers and unit. Any other value refuses the overlay by name, the sentence naming the bound (`"when.binding.noneOf" names 65 bindings (at most 64)`). `anyOf` holds when the run's binding is listed; `noneOf` holds when the run has no binding or it is not listed — a run with no binding is outside every `anyOf` (fail-closed) and inside every `noneOf`. The composer's one `whenHolds` decides for every class; `centerOverlaysOf` returns every overlay of the adopted artifact (its filter is gone). `requires.bindings` is every binding a `when.binding` names, sorted.
106
+ - **`prompt.compose` pins every fact it carries (behavior change, open to veto).** (1) **What changed:** `model`, `catalog` and `selectors` are all pinned on the event (the rule `prompt.section` already follows for its three facts); a handler that passes `next` an event whose `catalog` or `selectors` differs in value from the one it received is skipped and named on `onError` (`next() changed the pinned key "selectors"`) — before, only `model` was pinned. **The pin compares by value, for every pinned key of every station** (`prompt.section`'s `mountedTools`, `classic.PreToolUse`'s and `tool.check`'s `input`, `tool.check`'s `ceiling`, `plugin.register`'s `uses`, …): a copy of the engine's fact — `{ ...e }`, `structuredClone(e)`, a JSON round trip, a rebuilt list — has changed nothing and is taken (7.52.0 compared by reference, so a handler that passed on a copy of `mountedTools` or `input` was skipped and named "changed the pinned key" though it had changed no value; a loosening, open to veto); a record's member order and an `undefined` member are no difference, an array's order is, an array is its elements and every other own member it carries (a method hung on a copied array — an own `includes` that answers no — is a difference), only populated members are walked (a sparse array of length 2^32 − 1 costs its members), and a function or an object that is not plain data is itself only. **The comparison decides only whether to refuse; what the handler below reads for a pinned key is the engine's own member** — the same object, whatever copy was passed — so the forms the comparison does not count (an `undefined` member, a member order, a −0) reach no handler below: under a forwarder that hands on `selectors: { ...e.selectors, binding: undefined }` on a run with no binding, the handler below still sees no `binding` member (`binding` is present exactly when the run has `TaskSpec.promptBinding`). The handed-on payload keeps the shape it was received in: a reference inside it to the payload itself is a reference to what is handed on (a cycle stays a cycle), and **one to any object inside a copy handed on under a pinned key — the copy itself, a member, a list element, at any depth — is the engine's object that copy was proven the same as**: the comparison records each container's pairing and the rebuild moves every alias by it, once each (a forwarder that hands on `input: structuredClone(e.input)` with `chosen: input.items[0]` gives the handler below `chosen === e.input.items[0]`, so `indexOf` reads 0, not −1; a shared reference stays shared; the engine's own objects are never moved — an object of the engine's event a forwarder points to, at any depth, is that object below, where 7.52.0 handed down a copy of it). **One depth for the bus, 1 000 levels**: what a handler hands on, its answer and a `$` call's arguments are received held to it (a shared subtree measured again wherever it sits), so a value nested deeper is refused by the first walk that meets it — under a pinned key in the comparison's words (`next() handed on a copy of the pinned key "input" that is too deep to compare (nested past 1000 levels); a copy is taken only when proven the same`), never "changed"; elsewhere naming the bound and the member (`next() takes the event object: the payload holds a value nested past 1000 levels under "extra"`, `answered a value nested past 1000 levels under "value"`) — the same sentence and the same handler at 1 001 levels and at 3 000, on every platform and however deep the caller already stands: the receipt, the comparison and the declaration vocabulary judge each keep their own stack, one frame per container (#2013; before, past a depth the platform's stack set — about 3 000 levels inside a handler on one platform, more on another, fewer under a caller already deep in its own — the receipt said "could not be copied whole" instead); the comparison is held to the same depth. "Could not be copied whole" (`uncopyable`) is left for two causes, neither of them depth: reading the value threw where none of its getters runs (a Proxy's trap, on a receipt given no Proxy probe — what it threw is never read, so a thrown value whose members throw, or one shaped like a refusal, is that member's fault and never escapes the receipt or forges another kind), or the value holds more objects than one receipt can index. **The engine's own event is received at any depth**: a model's tool input 8 000 levels deep reaches the PreToolUse screening whole, a screening that passes it on allows the call, and an observe-only handler receives the same event whole — its detached copy is made by the same receipt, so an audit mod sees every call that runs (#2065; the observer's copy was a structured clone with a recursive fallback, which ran out of stack near 2 500 levels and skipped the observer with one report line) (a loosening, open to veto — 7.52.0 failed that dispatch as "could not be copied whole" and the lane denied the call wherever a judging mod was mounted; a screening that answers a copy of such an input is refused naming the bound). **Its objects handed back are kept by reference and never walked, wherever they come back** — through `next`, in an answer, in a `$` call's arguments (a method's `enter` shaping of them included) (#2025; an answer echoing `e.input` carries the engine's own object, where 7.52.0 carried a copy of it) — and a container of the engine's is one level where it sits, whatever it holds. Those objects — the containers the bus received, never an object handed as it is (a class instance stays its caller's, and nothing it holds is taken for the engine's, even after its prototype moves) — are found once per dispatch, the first time a handler gives the bus a value of its own, and every receipt of the dispatch consults that one set (#2016: a large unchanged member forwarded through many handlers is not walked again at each). A leaf that is not a record or an array (a `Date`, a function) is no level. **A container is one level wherever it is met** — copied, met again as a shared reference, still being walked (a cycle), or the engine's own — in the receipt and in the comparison alike (#2024), so the verdict does not depend on member order for a value without a cycle (an engine container inside a shared copy container reaching the 1 001st level is refused in both orders), and a cycle closed at the 1 001st level is refused where it sits — under a pinned key as too deep to compare. Through a cycle the depth is still measured on the walk's way, so two member orders of one cyclic value can be judged apart (#2007; the integration guide's member-order sentences carry the same "for a value without a cycle", #2026). **`KERNEL_CONTRACT_VERSION` moves 1 → 2**: its law is "the chain semantics, the registration form, the event closed set (names and `side`) or the failure columns (failure, abort, pinned keys)", and how a pinned key is judged is the failure column's reading, so a kernel copy that judged by reference must not sit beside this one under the same number. **No host reads the number today**: server, cli and client-core load no kernel copy, and `@sema-agent/mods-kernel` is a private workspace package, not on the registry; the move records the change for a future copy (open to veto). (2) **Who is hurt:** a mod that changed the value of the selector record, the catalog view, or any other pinned fact for the handlers below it; nobody is known to. A mod that forwards a copy is not hurt — it was, by reference, before. Without the pin, a task-seat handler could forward a forged `binding` and a deployment handler below it branching on `e.selectors.binding` composed another scenario's text, with no line anywhere (base and this tree before the pin, a real Runner: `PRIVATE-TEXT.` composed under `promptBinding: "public"`). (3) **Compensation:** none needed — pass `next(e)` (or a spread or a copy of it); an overlay meant for some scenarios carries `when.binding`.
107
+ - **An observer's copy is the one receipt's (#2065).** An `observe: true` handler (and its `.catch`) is handed a detached copy made by the kernel's receipt, with its own stack and no depth bound: every record and ordinary array is copied unfrozen at any depth, cycles and shared references keep their shape; every other object the copy holds — a tool result's `Map`, `Date` or typed array in `classic.PostToolUse`'s `output.details` — is detached by a structured clone of that object alone (one clone per object, so a shared one stays shared), and one the clone refuses (a function, a handle, a class instance holding one) is handed as it is. The structured clone of the whole event with a recursive fallback is retired, and so is the unused `detachedArgsOf` (not exported from the package). Before, an observer on an event nested past about 2 500 levels (macOS; more elsewhere) was skipped with `an observe handler failed (Maximum call stack size exceeded)` while the event's dispatch went on. Pins: `test/backlog2013-iterative-receipt.test.ts` F (a PreToolUse event 8 000 levels deep — the observer runs on a complete detached copy, no report line, the screen reads the engine's event; 20 000 levels with a `.catch`; a PostToolUse `details` Map / Date / typed array edited by an observer and its `.catch` reaches neither the judging handler after them nor the original result).
108
+ - **`e.selectors` wire order.** `e.selectors.traits` is now sorted (`["fable_5_mitigations", "lean_prompt"]` for a fable-family model on the lean profile; it was insertion order, `["lean_prompt", "fable_5_mitigations"]`). Same members; a reader that compared the array by position sees the new order.
109
+ - **Unchanged bytes.** The manifest's `selectors` and the identity's `binding` element are byte-identical for the same inputs (base vs this tree, the same run: `sha256:c6e53aed…` with `promptBinding: "code"`, `sha256:c921d75c…` without), so no session records a `selection_change` for this release. One exception, by construction: a deploy or task overlay whose `when` was written with unsorted lists, a repeat or an empty conjunct is hashed in its normal form, so its layer's digest differs from 7.52.0's and a session records one `code_change` at its first run (the prompt text is the same).
110
+ - **Two replaces of one target.** At publish, two `replace`s of one target conflict (`overlay`) when the rest of their `when` is equal and their binding conditions can hold in one run: both absent, one absent, two `anyOf`s sharing a binding, an `anyOf` naming a binding the `noneOf` does not, any two `noneOf`s (a run with no binding satisfies both). The conditions are read in their **normal form** (`canonicalWhen`, the overlay door's output for every class): lists sorted and without repeats, a conjunct that asks nothing (`host: {}`, `traits: {}`, `facts: {}`, an empty list) left out, a condition left with none no condition — so an empty conjunct, a reordered `allOf` or a repeat does not make two conditions that hold in the same runs look different, and the verdict does not depend on which of the two overlays comes first. The artifact digest is over that form too (one meaning, one digest); `buildPromptArtifact` hashes it, and a payload a publisher hashed over another spelling is refused `digest`. v2 compared `scope.binding` for equality, so an unscoped `replace` beside a scoped one of the same target verified, and under that scenario the later one in artifact order won (named superseded at run time, never refused at publish). At run time the fold is unchanged and never silent: when two `replace`s of one target both apply (across classes — `task < center < deploy` — or within one), the later wins and the earlier is recorded in `superseded`, marked in the section's `chain` and named on `onError`. **A scenario override is written with exclusive conditions:** the general `replace` carries `when: { binding: { noneOf: ["x"] } }`, the scenario's `when: { binding: { anyOf: ["x"] } }`.
111
+ - **A deploy `drop` of a lower entry that is not there this run.** Before, a center `add` scoped to another binding was filtered out before the deploy answers were judged, so a deploy `drop` naming it made that whole deploy answer unacceptable under every other binding (skipped and named — the answer's other overlays lost with it). Now every center overlay is a lower entry by declaration (as a trait-conditioned one already was), the answer is taken, and the drop — whose target its `when` left out of this run — changes nothing and is recorded `superseded` `(absent this run)` and named on `onError`, like a `replace` of a message the selector left out (base vs this tree, a deploy answer `[drop acme/code, add DEPLOY-MARK]` under `chat`: the mark was missing; now it composes, and one line names the drop). **The trait-conditioned form is the same law** (it was silent in 7.52.0, and is now one record and one line on every run the condition leaves the entry out): a deploy `drop` of a center `add` carrying `when: { traits: { allOf: ["fable_5_mitigations"] } }`, on a run without that trait, changes nothing, lands on `superseded` as `(absent this run)` and is named on `onError` (base vs this tree: no record and no line before; one record and one line per run now; on a run the trait holds for, the drop removes the entry as before). The line is per run, not per process, on purpose: one rule — every overlay that changed nothing this run is named, in the same words — rather than a second, process-wide memory for this one case; the manifest row is per run in any event. A drop meant for some runs only carries the entry's own `when`: it is skipped where the entry is not, and says nothing there (pinned). The `(absent this run)` line's words name the causes alike — the selector chose no variant of the target (or of the message it takes its text from), or the lower entry it drops was never added (its own condition left it out, or the layer that added it is not in force) — where it used to say only that the selector left the target out.
112
+ - **BREAKING (hard; open to veto).** (1) **Who breaks:** a center publisher (web-admin) and the server's center lane holding v2 artifacts; any producer of an overlay carrying `scope`; an artifact pairing an unconditioned `replace` with a `scope`d one of the same target. (2) **How it breaks:** the engine reads `schemaVersion` 3 only — a v2 body is refused `schema` (`unsupported schemaVersion 2 (this engine reads schemaVersion 3 only; republish under schemaVersion 3 — an overlay's "scope": { "binding": b } becomes "when": { "binding": { "anyOf": [b] } })`): `applySnapshot` answers it, a store read misses (and tells an `onRefused` caller why), `restore()` boots `unavailable` and says so on the source's `onError` (`center prompt artifact sha256:… is refused by this engine (schema: … republish under schemaVersion 3 …) — the source boots unavailable…`; a copy the file store cannot read — a directory in its place, a file the process may not open — reads `is unreadable (the store could not read its file: EISDIR)` and reaches `onRefused` as `{ code: "unreadable" }`; no copy at all reads `has no stored copy`), and a session whose record pins a v2 digest fails `prompt.snapshot_unavailable` on its next run, the failure's sentence and one operator line saying the copy is refused and how to republish it (no converter, no dual read). An overlay carrying `scope` — any class — is refused (`an overlay carries "scope" (id, op, target, text, message, when only); an overlay names its binding as "when": { "binding": { "anyOf": [<binding>] } }`); on `prompt.compose` that handler's answer is skipped and named. (3) **How to fix:** republish every artifact as v3 with `scope: { binding: b }` rewritten to `when: { binding: { anyOf: [b] } }`; put a `noneOf` on the general `replace` beside a scenario one. Publish v3 only and never put a v2 artifact into production — a session pinned to a v2 digest has no way back. Pass the Runner's `onError` to `CenterPromptSource` so a boot restore can say what it found — the option takes the Runner's sink in its own shape (`RunnerDeps.onError`, `(error, context)`), the context `{ phase: "prompt-constitution", sessionId: "", classification: "center-source-restore" }` (a boot belongs to no session) — a sink that throws, or returns a rejecting promise, is contained by the engine's one host-callback guard and never fails a boot or a read.
113
+ - **Against Claude Code 2.1.296.** Not a Claude Code behaviour: the overlay / center layer and its selector are this engine's own.
114
+ - **Who breaks / how to migrate.** *server* (② the provider car): the brand line is one deploy `prompt.compose` overlay — `{ op: "add", target: "role", text, when: { binding: { anyOf: ["default", "code"] } } }` — with no per-request branch; a handler may read `e.selectors.binding`. *server* (③ the center artifact car): build and serve v3 only (`buildPromptArtifact` mints it); if your settings-schema `EffectivePromptsWire` major types the inner artifact payload (its `schemaVersion` literal or the overlay shape), it moves to `3` / `when.binding` with this release. *web-admin* (the publisher): publish v3; `scope.binding` → `when.binding.anyOf`; at most 64 bindings per list. *any mod author*: a `prompt.compose` handler that changes the value of `e.selectors` or `e.catalog` before `next` is now skipped — pass `next(e)` (or a copy of it: a copy that equals it is taken, for every pinned key of every station; the handlers below read the engine's own members); a mod that hands on, answers or passes to a `$` method a value nested past 1 000 levels is now refused there, by name. *server, cli, client-core*: nothing for `KERNEL_CONTRACT_VERSION` — none of them loads a kernel copy or reads the number; *a custom `PromptArtifactStore`*: call `onRefused` (typed `OnArtifactRefused`, from the package root) when a copy is there and cannot be used; *a publisher that hashes its own artifact*: hash the overlay door's normal form of each `when` (or call `buildPromptArtifact`); *a host that screens a binding*: count code points. *client-core*: the generated `PromptVariantWhen` / `PromptOverlay` / `PromptArtifactPayload` types follow. *cli*: nothing.
115
+ - **Pins.** `test/backlog1919-mods-pin-by-value.test.ts` (every station's every pinned key from the event table: copies of the whole event or of one fact pass and the handler below reads the engine's value, eleven kinds of difference (a method hung on a copied list among them) are a violation by the station's failure column, `plugin.register`'s judge, the boundaries — a cycle, an `undefined` member, order, a value that is not data, an array's own members, a sparse array, nesting past the walk said as too deep to compare), `test/backlog1984-handon-engine-members.test.ts` (K every station's every pinned key: an `undefined` member, a member order, a −0 and a whole-event clone are taken and the handler below reads the engine's object by identity, a key the engine does not carry stays absent and one it carries as `undefined` stays carried, what the forwarder changed outside the pinned keys travels, a self-reference / an alias of the pinned copy / a nested back-reference keep their shape, a different value is still a violation; R a real Runner's `prompt.compose` with no binding under a forwarder handing on `binding: undefined` — the deployment handler below sees no `binding` member and composes its default branch; L the one depth: 1 000 levels compared, 1 001 not, a value-equal copy at 1 001 and at 3 000 levels refused in the same sentence, by the forwarder, by every station's failure column, a non-pinned member, an answer and `$` arguments past it refused naming the bound at both depths, a shared subtree past the bound refused in both member orders, the engine's own 3 000-level member and a 999-level copy taken, a receipt mode with no `past` still hands a nested member as it is; L9 an engine container inside a shared copy container at the 1 001st level refused in both member orders, at the 1 000th taken in both, the same law in the receipt met directly or in what holds it; L10 the engine's own 50 000-level object kept by reference through `next`, in an answer and in `$` arguments, a value-equal copy refused), `test/backlog2013-iterative-receipt.test.ts` (A a 100 000-level chain of records and of lists in every mode copied whole or refused as too deep, never uncopyable; the L2 form from under a 6 000- and an 8 000-frame caller in the bound's sentence and the 1 000-level copy taken; the comparison at the bound from under 8 000 frames; a model's 8 000-level tool input through the real PreToolUse lane — passed on: allow, from under 6 000 frames too; answered as a copy: refused naming the bound; a 20 000-level event received whole; `uncopyable` for a Proxy trap that throws on a receipt with no probe, `proxy` with one; a thrown value whose members throw or that is shaped like a refusal neither escapes nor forges a kind; a held Proxy never reflected on; B one engine-object index per dispatch over ten handlers and a 10 000-record member, no copy of it into any receipt, none at all when every handler passes the engine's event on — counted, not timed; C a 5 000- and 50 000-level `toolAxes.Read` in one bounded sentence at the door and at the reader, the same from under 6 000 frames, the boundary on both seats, a vocabulary fault 990 levels down found with a bounded path; D a cycle closed at the 1 001st level too deep to compare, at the 1 000th the same value, in the comparison and through the bus; E a method whose `enter` hands the arguments on receives the engine's own object, small or 1 500 levels deep, and a caller's class instance whose prototype moves before the shaping is copied, never handed on by identity), `test/backlog1999-handon-alias-graph.test.ts` (A every station: `chosen: input.items[0]`, an alias to a list of the copy, list elements, a two-level nested alias beside the whole copy, a cycle beside an alias — each the engine's object below, the rebuilt containers frozen, a changed nested value still a violation; P a copy object standing for two of the engine's goes to the first the comparison met, the engine's own objects are never moved, one copy object shared across two pinned keys goes home once), `test/backlog1919-binding-selector.test.ts` (U the four character bounds of the verifier read an astral boundary alike; W a sink in the Runner's shape that reads `context.phase` gets the v2 restore line; F a directory in the copy's place is `unreadable` on the store read, on `restore()` and on a pinned run — the reason carries the error code, never the path — and a missing file still reads as no copy; the store seat's two types come from the package root; S the record: the event deep-equals the manifest's `selectors`, the identity element is its digest, traits sorted; D / T deploy and task overlays through real Runner lanes — `anyOf [default, code]` under code / default / code-review / no binding, `noneOf [code-review]`, the binding as one conjunct, the unconditioned control; C a v3 center overlay under code / chat / no binding and a child under the inherited binding; V thirteen bad values each refused by name (65 names in one list among them), the 128-character and 64-name controls, a skipped deploy answer named on `onError`; S5 / S6 the station row pins all three facts and a task-seat handler forwarding a forged record is skipped and named while the deployment handler below reads the run's own binding (control: a spread passes); X `scope` refused for each class, a v3 artifact carrying it, the v2 refusal sentence, a v2 body on `applySnapshot` / store read / `restore()` / a pinned child run; P twenty-eight pairs of `replace`s, conflict or not, each also run with the two overlays in the other order (the verdict is the same), the empty-conjunct, list-order, repeat and noneOf-first forms among them; N the normal form (the door's output, idempotent and frozen, never a change of which runs it holds for; one meaning one digest; a payload hashed over another spelling refused `digest`); S7 / S8 a task-seat handler forwarding a copy of the event (five forms) is taken and one forwarding a different catalog, record or model is skipped and named; X5–X10 a stored v2 told apart from a missing copy on `restore()`, on a store read and on a pinned run (the sentence, the one line, the generic control; a sink that throws or returns a rejecting promise changes nothing); V the code-point unit (astral, CJK, mixed, lone surrogates, a huge string) for `TaskSpec.promptBinding` and `when.binding`; K the catalog variant type refuses a binding (a compile-time gate) and no bundled variant has one; R4 the trait-conditioned drop; R the run-time fold of two `replace`s that both hold, its control, and a deploy `drop` of a center entry its binding leaves out — named, the rest of the answer applied; Q `requires.bindings`). Re-expressed: `test/prompt-center-m1b.test.ts`, `test/defectscan-center-mounted-surface.test.ts` (the center class's input seat returns every overlay; the binding selects at the composition), `test/hardening-prompt-assembly.test.ts`, `test/center-artifact-fixture.ts` (`behaviorAdd(id, text, when?)`).
116
+ - **Known limits.** KL-1919-a (the publish check reads the binding by satisfiability and the other dimensions by equality; the run-time fold names what it overrides), KL-1919-b (a session pinned to a v2 digest cannot resume), KL-1919-c (a custom store that does not call `onRefused` reads a refused or unreadable copy as a missing one; the engine's own Memory and File stores call it); #2007 (the bus's depth through a cycle is measured on the walk's way, so a cyclic value's verdict can depend on its member order; the walks stay within the depth either way).
117
+ ### A letter from another user of the organization lands in the recipient's principal inbox through one delivery gate, is judged on the principal's own settings (unset ⇒ held), and is read by the one consumer the deployment declares (#972; additive; behavior change on a record carrying `fromScope`, open to veto — see Who is affected; hard-breaking: none; closed sets: `CrossSessionHoldCause` +1 (`cross-principal-default`; 9 → 10), +1 set `OrgDeliveryRefusalCode` (4 words), +1 set `PrincipalInboxTriagePolicy` (3 words), +1 set `PrincipalInboxConsumer["kind"]` (3 words); codes: +1 `config.principal_inbox_consumer_invalid`; exports: +1 interface family `OrgDeliveryGate` / `OrgAuthenticatedSender` / `CrossPrincipalBody` / `OrgDeliveryOutcome` / `OrgDeliveryRefusalCode` / `PrincipalInboxTriagePolicy` / `PrincipalInboxConsumer`, +2 constants `PRINCIPAL_INBOX_HANDLE` / `CROSS_PRINCIPAL_MESSAGE_NOTICE`, +5 functions `mintCrossPrincipalRecord` / `isGateMintedRecord` / `principalInboxConsumerOf` / `principalInboxTriagePolicy` / `principalInboxSideEffectKey`, +1 `principalInboundLayers`, +4 constants `ORG_DELIVERY_REFUSAL_CODES` / `PRINCIPAL_INBOX_TRIAGE_POLICIES` / `PRINCIPAL_INBOX_CONSUMER_INVALID_CODE` / `PRINCIPAL_INBOX_UNMINTED_CAUSE`, +1 `isOrgDeliveryRefusalCode`; members: `RunnerDeps.principalInboxConsumer?`, `ReadInboxToolOptions.coversPrincipalInbox?`, `PeerSessionDrainOptions.principalInbox?` / `markExternalContent?`, `PeerSessionDrain.principalHeld?`, `peerHeldQueueFor` / `peerHeldQueueIfAny` / `peerHeldQueueKey` take an optional box; the mailbox contract's consumption chains 3 → 4) (@server @cli @client-core @test)
118
+
119
+ - **Before.** The core half of the cross-principal lane was not in the tree: a `fromScope` record was judged by mode parity like a same-principal peer's, no box address or record mint existed for a delivery gate, and nothing decided which leg reads a principal's inbox.
120
+ - **Now.** `OrgDeliveryGate.deliverCrossPrincipal(authedFrom, toPrincipal, body)` is the contract a deployment implements (gate-owns-append), minting with `mintCrossPrincipalRecord` (anchors `fromScope` + `gateReceiptId`, kind `peer_message`; no `fromSession` / `fromMode` / `hopChain`) into `(recipientScope, PRINCIPAL_INBOX_HANDLE)`. A record carrying `fromScope` is judged on the principal arm: the principal's layers only (managed, user — never a repository layer), explicit value wins, unset holds `cross-principal-default`, no parity. `RunnerDeps.principalInboxConsumer`: absent = no lane (unchanged); `{ assistant }` = no terminal claims the box; `"terminals"` (each terminal drains the principal inbox as a second lane — own lease / ledger / ack prefix / held queue, one pass budget; the drain machinery ships) is **refused at prepare for now** (`detail.reason: "terminals_unsupported"`: held decisions are not yet shared across terminals). A malformed value fails the prepare (`config.principal_inbox_consumer_invalid`, `detail.reason: "malformed"`); a gate minting in a deployment that declared no consumer is refused with the same code (`"no_consumer"`). `ReadInbox` declares `contentOrigin: "external"` exactly when its drain covers the principal inbox; an INJECTED letter marks the leg's memory session polluted (`observed`), so each reading path is accounted once. A letter's frame carries `CROSS_PRINCIPAL_MESSAGE_NOTICE` (untrusted content, never an authorization) in place of the same-principal teammate block. The principal lane serves only gate-minted records (anything else: `admission_refused`, cause `not_minted_by_delivery_gate`) and routes no receipt.
121
+ - **Who is affected.** With `principalInboxConsumer` absent every drain is still the single-box drain it was, but its judgment of one record changes (behavior change, open to veto): a record whose meta carries `fromScope`, in whichever box the drain reads it from, is judged on the principal arm — the managed and user layers only — and with no `crossSessionInbound` there it is held under `cross-principal-default`, where 7.52.0 judged it by mode parity like a same-principal peer's (a prompting recipient took it) with a repository layer's tightening applied; a repository layer's `hold` / `refuse` no longer reaches it. Who is hurt: a host that writes mailbox records carrying `fromScope` itself rather than through a delivery gate — such a letter now waits in the session's held queue, re-judged at every boundary, until the recipient's managed or user layer sets `crossSessionInbound`; a repository that tightened `crossSessionInbound` to hold such letters. Compensation: set `crossSessionInbound` on the managed or user layer, or leave `fromScope` off a same-principal record. A host that hand-builds `PeerSessionDrain` or reads `CROSS_SESSION_HOLD_CAUSES` as a closed list sees one more cause.
122
+ - **How to migrate.** *server*: implement `OrgDeliveryGate` (membership, SQL quota, mint, append), declare `principalInboxConsumer`, wake the assistant with `createAgentContinuationVerb`, read the settings `crossSessionInbound` managed + user layers for the principal arm. *cli*: a held card for `principalHeld` entries (the new cause sentence). *client-core*: the refusal words, the hold cause, the consumer kinds.
123
+ - **Pins.** `test/design385-slice7-principal-inbox.test.ts`; `test/design385-slice2a-store.test.ts` re-pinned three → four chains; the contract kit's T2 layer gains the principal-inbox case; `test/public-knob-liveness.test.ts` row.
124
+
125
+ ### Every station that asks what the tool valve leaves mounted reads the one valve predicate, `valveLeaves`: a run with `excludeAllTools: true` takes no tool back from an MCP re-dial, and "how many of a server's tools are mounted" is one count read off the roster (#1971, first slice and its close-out; #2000, #2001, #2020, #2054, #2035; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none; members: `materializeMcpTools` +1 optional trailing parameter `dialable`; `McpRefreshResult` +1 optional member `instructions`, present on a `refreshed` entry whose current connection carries instructions) (@server @cli)
126
+
127
+ - **Before.** `valveLeaves(spec, selector)` (the whole-face switch, else the compiled selector) answered six stations; nine more read the compiled selector alone and never saw `excludeAllTools`: the MCP re-dial / `RefreshMcpTools` push, the agent-listing announce, the shell's Monitor hint, the offload reader's reserved-name door, the `ReadInbox` and `ListAgents` mount judgments, the built-in `SendMessage` judgment, the `ToolSearch` admission, and the delegation pool that the list judgment, the policy name audit and the host ceiling read. The re-dial runs AFTER the valve, so on a run told to mount nothing a host's `reconnectMcpServer` put the server's tools back on the roster: the model saw them and, under `bypassPermissions`, called them.
128
+ - **Now.** All of them read `valveLeaves`. On an `excludeAllTools` run a re-dial succeeds as a connection (`outcome: "reconnected"`) and pushes nothing: the model's tools stay `[]`. The `mcp.server_redialed` notice counts what the roster took ("0 tools mounted"; under an `excludeTools` of one of two tools it now says "1 tool mounted" where it said "2"), and so does the host's result (see the next bullet). The same run no longer announces an agent roster behind a delegation tool the valve took out. A direct read of the selector is left in `src` only where no valve reading is owed (the roster is pinned): `valveLeaves` itself, the two single-layer list helpers, a mounted tool's recovery hint, a child's `admitsPeer` at the spawn.
129
+ - **One count, read off the roster (the close-out).** The re-dial notice counted the push taken BEFORE the deployment's `toolFaces` overlay refused a tool, so a roster of one was announced as "2 tools mounted"; `McpReconnectResult.toolCount` was the server's own listing; the `memory.content_class_declared` line and `wiring_manifest.mcp[].toolCount` counted the server's listing too — one run, up to four answers to one question. One reading now answers all of them: the server's tools the roster holds, read before and after the swap. The host re-dial's result (`toolCount`, `added`, `removed`), the `mcp.server_redialed` notice and the counts in its `detail`, the `RefreshMcpTools` receipt (its "refreshed — N tools", its added / removed names and its "excluded by deployment config (not mounted)" line, which now also names a tool the overlay refused) with its `details.results`, the `memory.content_class_declared` line and `wiring_manifest.mcp[].toolCount` all count it. A safety-axis fold refusal, a failed refresh and a refused re-dial (an operator's revocation, one already in flight) assert no roster, so their result counts what the roster still holds of the server — the refused re-dial said `0` while the tools stayed mounted. A name no server is declared under holds nothing: a re-dial of `valve_x` against a declared `valve.x` (one prefix) answers `not_declared` with `toolCount: 0`, where it counted that server's tools. A result that asserts a roster carries the roster's tools as its `tools` (and `axes` cut to them), so `toolCount === tools.length` — a re-dial under an `excludeTools` of one of two tools used to say `toolCount: 1` beside both listed tools. The same reading holds where there is no run: the materialization's own `reconnect` / `refresh` count what its handles hold (a revoked or dead server's mounted tools, where they said `0`), and `probeMcpServers` counts its own dials' tools, read through the same projection as a run's manifest.
130
+ - **The stations that teach a tool read the valve (the close-out).** The skills listing is announced only while the valve leaves the `Skill` tool: an `excludeTools` of it, an `allowTools` without it and `excludeAllTools` used to send the `<skills>` frame teaching a tool that was not there (a continuation of a session that was shown skills gets the removal projection instead). A server's instructions are taught only while the run's roster holds a tool of that server the valve leaves, or a cross-server resource tool the valve leaves (some connected server advertised resources; which one is not recorded, so every server's instructions stay with it — a server's own per-declaration `allowTools` is a separate intake filter this answer does not read), or — for a server that listed no tool — while any resource tool is left. Read off the roster, a tool the deployment's `toolFaces` overlay refused does not count: with no list set and both of a server's tools refused, the instructions used to be taught and the operator's ungated-write warning named the two tools; now neither. The ungated-write warning names only server tools the roster holds: a tool the tool lists (`allowTools` / `excludeTools`) or the overlay left off is no longer named. That answer decides what the model is TAUGHT: the initial block, and what every later roster change adds — a `RefreshMcpTools` refresh or a host re-dial that leaves something of a server the model has not been told queues its instructions once on the opt-in `attachments.mcpInstructions` delta lane, the same queue a re-dial's new text takes (before, a refresh that mounted an allowed tool the start listing lacked handed the model the tool with no guidance), and a change that leaves nothing of a server withdraws an unread add. What the model HOLDS is one ledger beside the lane — the block, plus delivered frames, minus retractions (a queued copy counts once it is delivered) — and it decides the other two questions. A server taught, untaught and taught again across refreshes is taught once (it was taught twice). The disconnect line, "The following MCP servers have disconnected. Their instructions above no longer apply", names exactly the servers whose instructions the model holds: an `excludeAllTools` run whose host re-dialed a server, a run excluding each of a server's tools by name, and a server the block did not teach that died while a sibling was still connecting used to get the line for instructions they were never shown; a server taught in the block whose last allowed tool a refresh then took away got no line when it disconnected, and now does; a queued copy withdrawn before delivery gets none. A refresh entry whose connection closed while the walk was still reading (another server, or a later page) reports `not_connected` and asserts nothing: before, its listing was mounted on the closed connection and, for a server the model had not been told, its instructions were taught with no disconnect line to follow. A stopped teaching while the server stays connected withdraws nothing. The materialization used without a run takes every entry it returns as taught, as before. And a server the valve can leave nothing of is not dialed at the run's start: `excludeAllTools`, or lists that reach none of its tools (`allowTools` naming none of them) AND none of the three cross-server resource tools (`ListMcpResourcesTool` / `ReadMcpResourceTool` / `ReadMcpResourceDirTool`, which aggregate the resources of every dialed server and so keep a connection worth having) — no connection, no stdio child, `wiring_manifest.mcp[]` reads it `skipped` with `toolCount: 0` — while a host re-dial still dials it and pushes it through the valve (a connection, no tool). An `excludeTools` of the whole server alone (`mcp__srv`, `mcp__srv__*`) still dials it while the valve leaves a cross-server resource tool, because whether the server advertises resources is not known before the dial (one that turns out to advertise none is dialed and mounts nothing), and a server whose tools are each excluded by name is dialed and not taught. Every dial decision is taken before any connection opens: a `dialable` seat that throws, or answers anything but a boolean, refuses the whole `materializeMcpTools` call with `config.mcp_dialable_failed` and dials nothing — asked inside the dial loop, a throw for a later server left an earlier server's connection open with no way to release it, and an earlier dial that failed ended the process as an unhandled rejection even though the caller caught the error; an `undefined` answer used to drop the server silently. The question tool's mount flag reads the valve (one fact with the mount; unreachable today).
131
+ - **Behavior narrowing (open to veto).** (1) **What changed:** a host re-dial (and a model refresh) on a run with `excludeAllTools: true` mounts no tool of the server; and, on any run, a server the valve can leave nothing of is no longer dialed at the start, and its instructions and the skills listing are no longer taught for tools the valve unmounts (nor for tools the deployment's overlay refused), nor retracted by a disconnect line for instructions the model never held (and a server whose instructions it holds is retracted when it disconnects, also after a refresh took its last tool away); a `dialable` seat that cannot decide refuses the materialization. (2) **Who:** a host that used a re-dial to put tools on a run it had switched fully off (none is known; the switch's own contract is "the leg mounts nothing; the request carries `tools: []`"); a host that read a `connected` status, or the instructions in the prompt, of a server whose every tool its lists excluded; a consumer that read `McpReconnectResult.toolCount` / `tools` / `wiring_manifest.mcp[].toolCount` as the server's listing, or a materialization's `0` on a revoked or dead server as "nothing mounted"; a library caller whose `dialable` seat answered a non-boolean (the server was dropped; the call now rejects). (3) **Compensation:** leave the switch (or the exclusion) off and bound the face with `allowTools`; read the listing from `probeMcpServers` or the materialization.
132
+ - **Also moved (loosening, open to veto).** On an `excludeAllTools` run, a caller tool named `ReadToolResult` is no longer refused at prepare (the reader it would shadow is not mounted, as under `excludeTools: ["ReadToolResult"]`); a list entry that is a near miss of a name only a delegation tool's pool carries is told as an unknown name instead of refused, as when the delegation tool itself is excluded; and a `toolPolicy` entry that is a case twin of a name only a delegation tool's pool carries is, under the same switch, told by the policy name audit ("matches no tool mounted this run") instead of refused with `config.legacy_tool_name` — an empty roster hands no pool to anyone, so there is no twin to refuse (a refusal became a notice).
133
+ - **Also moved (a side effect of the offload door).** An `excludeAllTools` run mints no tool-result offload store (the reader it feeds is not mounted), so the compaction lanes that persist a cleared tool result to that store — the micro-compaction's offload and the guard replay's — have nothing to persist to on a continued session, and a configured `compaction.staleToolResultOffload` reports itself inert on the operator lane. The pointer they would have written named a reader the run does not mount.
134
+ - **How to migrate.** *server*: `McpReconnectResult.toolCount` / `added` / `removed` of `reconnectMcpServer` are now the roster's reading (what was mounted, never the server's listing); the notice's `detail` carries the same counts, not names — the names are in the result, and its `tools` are the roster's; a refresh's `details.results[]` entry may carry `instructions` (the connection's entry, `author: "host"` when the host replaced the text); the coded refusal of a `dialable` seat that cannot decide is `config.mcp_dialable_failed`. *cli*: `wiring_manifest.mcp[].toolCount` (the "connected · N tools" row of the engine-hosted MCP view) is now the count of the server's tools on the roster, and a server the lists reach none of reads `skipped` with `0`. Other runs move too, in three ways: a tool the deployment's `toolFaces` overlay refuses is not counted, with no list set at all (both of two refused: `2` → `0`, and the server's instructions are no longer taught); an `excludeTools` of one of a server's tools counts one fewer (`2` → `1`); and what the model is taught moves with the valve — a server's instructions go when every one of its tools is excluded by name, the skills listing goes under an `excludeTools` of `Skill` or an `allowTools` without it, and the disconnect line names exactly the servers whose instructions the model holds.
135
+ - **Pins.** `test/backlog1971b-mounted-set-and-valve-readers.test.ts` (the roster reading of one domain — valve, overlay, fold refusal, withdrawal, another server's tools — and the same number on every surface of a real run; the skills listing under each way to take the tool out, on the wire; the instructions block and the first dial as a process fact across thirteen lists and servers — resources-capable, no-tool, excluded whole, every tool excluded, the resource tools alone; the policy-name audit under the switch; the question flag; the offload store); `test/backlog1971a-valve-readers.test.ts` (the re-dial on a real run, each station beside the valve's answer for the same tool, the syntax-tree guard with its negative controls — now also the nineteen spellings a review found it blind to); `test/rescan-a087-fixes.test.ts` D1 re-shaped to the `leaves` predicate; `test/backlog1971b-mounted-set-and-valve-readers.test.ts` ⑧ (the disconnect line under the switch with a host re-dial, under each tool excluded by name, the taught control, and a server that dies while a sibling is still connecting; the undeclared name; the re-dial result's `tools`; the overlay refusing every tool; the materialization's own counts) and the fixture labels made true (a server excluded whole is dialed while the resource tools are left — the fixture advertises no resources, so none mounts); `test/backlog2000-dialable-decisions-first.test.ts` (a throwing seat after an earlier dial that would succeed — no process started, no lease asked — and after one that fails — no unhandled rejection; non-boolean answers; controls); `test/backlog2001-refresh-teaches-once.test.ts` (on real runs: the refresh that first mounts an allowed tool teaches once, its taught control, the instructions-only server gaining a tool, the reverse direction, the block-taught server untaught by a refresh then disconnecting, the frame-taught server untaught and re-taught, the untaught server dying while a sibling connects, the materialization alone; the applier's and the ledger's cells, a queued copy withdrawn before delivery included); `test/backlog874-mcp-liveness.test.ts` / `test/backlog562-manifest-mcp-face.test.ts` hand the projection its roster names; `test/backlog857-mcp-redial.test.ts` (a dead server's refresh counts its two held tools) and `test/backlog1971a-valve-readers.test.ts` (the shared applier is the re-dial's one valve reading) re-pinned.
136
+
137
+ ### A resume runs prepare's config doors before the claim: a bad lock, protocol declaration, limit or deployment seat refuses the resume and the row stays `pending` on any store (#1950; behavior change — refusal moved before the claim and code changed on three arms, open to veto; hard-breaking: none; closed sets: `CHECKPOINT_CONFIG_REFUSAL_CODES` +30 (9 → 39), `CheckpointErrorReason` +2 (`unknown_model_ref`, `not_a_model_ref`), `detail.field` on `checkpoint.config_refused` +1 (`deps`); exports: none; members: none) (@server @cli @client-core @test)
138
+
139
+ - **Before.** The resume's screen before the claim carried the mode / output-style / approval-reach / invoked-skill / attachment doors only. Every other door of prepare's config-door phase — the lock layer, an MCP server's content class, the MCP and A2A `toolAxes`, the limits, the knobs, the deployment's own seats — refused in the resumed leg's prepare, after the claim: the leg ended `failed` with zero model turns, and on a checkpoint store without `reopen` the row stayed `resolved`, so the corrected retry was `checkpoint.already_resolved` and the suspended work was lost (with `reopen` the row came back as a system retry that replays the recorded decision). 7.53.0's A2A door (#1835) sat in the same place. Three of those facts also had a second reading before the claim with another shape: the deps' retired seats and the microCompact knob threw their raw codes, and a task-supplied `basePolicyForResumeEdit` under a locked `toolPolicy` answered `checkpoint.invalid_outcome` where prepare answers `config.locked_key`.
140
+ - **Now.** One rule: every door of the config-door phase that reads only the re-supplied config, the deployment's deps and the row runs before the claim, through the one function prepare calls (`configValueDoors`); the resume enters each through its one refusal channel — `CheckpointError` `checkpoint.config_refused`, `message` = prepare's sentence, `detail.code` = prepare's code, `detail.field` = `"taskConfig"` (correct the config) or `"deps"` (a deployment seat — correct the deployment) — and the row stays `pending` and byte-identical with or without `reopen`; the corrected resume redeems the same token. The MCP servers and A2A peers the leg dials are the frozen entries the screen judged (the caller's entries are read once). The three second readings are retired: one fact, one code. The skills-listing seats' refusals read no model and are judged with the other value doors (the window-dependent budget is still resolved against the model). The retention declaration (`config.retention_policy`, `config.retention_capability`) is judged here too, over the session store the Runner was constructed over (fixed for its life, so the admission rung is handed it) and the checkpoint and tool-result stores the leg would run on. Every one of these doors is total over plain JSON: a `null`, a primitive or a list where a record is declared is refused by the door's own code through the channel — `limits: null`, `resourceSuspend: null` and `retentionPolicy: null` were raw `TypeError`s before the claim (the row pending, but a caller's retry logic reads a non-`CheckpointError` as terminal), and the resume's own config, when it is not an object, is the first door (`config.invalid_argument`). The doors still refusing after the claim do so each for a stated reason (KL-1950-a; INTEGRATION-CORE "A resume's two doors"): the approval seat's value, the interaction posture, the per-task agents, the model doors, and prepare's own stretch after the phase.
141
+ - **Behavior change (open to veto).** (1) **What changed:** a resume whose config or deployment fails one of the moved doors rejects with `checkpoint.config_refused` (the row pending) where it resolved a `failed` stream (the row consumed or replayed); the three retired readings change code — a task-supplied `basePolicyForResumeEdit` under a locked `toolPolicy` from `checkpoint.invalid_outcome` to `checkpoint.config_refused` / `config.locked_key`, a retired deps seat or caller-policy member from a raw `config.deps_retired_key` throw to `checkpoint.config_refused` carrying it, a bad microCompact machine likewise with `config.microcompact_invalid`, and a retired member of a policy re-supplied as an inherited constraint likewise (`detail.field: "deps"`; it was the one place the word left as a raw throw); those refusals now come at admission, before the checkpoint screen, so a resume that is wrong on both counts reports the config first. A fresh run's codes and sentences are unchanged, with these exceptions, each an improvement on an uncoded `TypeError`: a missing or `null` `objective` ends `config.empty_objective`; `limits: null`, `resourceSuspend: null` and `retentionPolicy: null` end `config.limit_invalid`, `config.limit_invalid` and `config.retention_policy`; and the tool-face list refusal (`config.tool_face_control` on `allowTools` / `excludeTools` / `deferTools` / `alwaysLoadTools`) now carries `detail.reason` (`not_a_list` | `not_a_string`); a role seat or tier binding the engine cannot read ends `config.unknown_role` (`detail.reason` `not_a_model_ref` | `unknown_model_ref`) where it ended a raw `TypeError` or an uncoded `Unknown model ref`: a role bound to a number or a boolean (any run), a role's `model` that is a number, a boolean or a list or whose word the catalog does not hold, a `select` whose `needs` is no list, on the two side channels that read a role by its own key (`roles.summarize` for compaction, `roles.consolidate` / `roles.summarize` for the armed auto-consolidation — `autoRunOnRecommendation: true` reads them at the door), and a tier bound to a stale word or to a non-reference at `new Runner`; a catalog entry with nothing behind it is no `select` candidate (it threw a `TypeError`); a list as a role seat is still read as no preset, and `resolveModel` still answers every catalog it answered; a refusal sentence no longer coerces the value it rejects (a record shadowing `toString` made the sentence itself throw a `TypeError` at the limits, resource-suspend, retention, brain-call-guardrail, resume-at-mode and compliance-deny doors); and a `null` checkpoint store seat is refused as the store seat (`config.invalid_checkpoint_store`) rather than read through. On a fresh run that is wrong at two doors the limits / attachments / resource-suspend / governance-window / guardrail doors now answer before the retention, approval-seat and interaction-posture doors, and the skills-listing seats answer before the model doors; when a task-named checkpoint store and a deployment store both declare a retention nobody can parse, the deployment's is reported first (the task's store is judged in its own call after it). (2) **Who is hurt:** a host that read a resume's `failed` terminal as the config error, or branched on `checkpoint.invalid_outcome` for the locked resume-edit seat. (3) **Compensation:** the refusal is the same sentence and the same code (`detail.code`) a fresh run reports; the row waits for the correction.
142
+ - **Closed sets.** `CHECKPOINT_CONFIG_REFUSAL_CODES` +30, each prepare's own word and each one the screen can produce (a recipe for every word is pinned; `resume_at.invalid_mode` is a fresh run's word only — a resume carrying `resumeAt` is refused as `resume_at.conflicts_resume` before its mode is read — so it is not in the set): `config.deps_retired_key`, `config.peer_presence_invalid`, `config.tool_model_gate_invalid`, `config.microcompact_invalid`, `config.prompt_selector_invalid`, `resume_at.conflicts_resume`, `resume_at.mode_without_target`, `config.read_face_invalid`, `config.write_face_invalid`, `config.invalid_argument`, `config.memory_delegation_evidence`, `config.memory_provenance`, `config.memory_capture_policy`, `config.auto_consolidation`, `config.unknown_role`, `config.tool_materialize_invalid`, `config.memory_persistence_invalid`, `config.tool_materialize_unreachable`, `config.locked_registry`, `config.locked_key`, `config.mcp_content_class`, `config.skill_listing_budget_invalid`, `config.limit_unknown_key`, `config.limit_invalid`, `config.attachment_invalid`, `config.usage_window_invalid`, `config.brain_call_guardrail_invalid`, `config.retention_policy`, `config.retention_capability`, `config.invalid_checkpoint_store` (the checkpoint store seat itself — it left admission as a bare coded throw, outside the channel). `CheckpointError.detail.field` +1: `deps`. `CheckpointErrorReason` +2: `unknown_model_ref`, `not_a_model_ref` — the arms of `config.unknown_role` on a role seat or a tier binding the engine cannot read (below); the re-supplied config's tool-face lists carry the existing `not_a_list` / `not_a_string`.
143
+ - **Who breaks.** *server*: a consumer that pinned `CheckpointConfigRefusalCode` / `detail.field` as closed sets (each gains words; open to veto); a test that expected a resume to resolve `failed` with one of these codes, or `checkpoint.invalid_outcome` for the locked resume-edit seat, re-pins to `checkpoint.config_refused`; route `detail.field: "deps"` as the deployment's fault (the row pending, retriable after the fix — the retention declaration and the policies a caller re-supplies as inherited constraints included); a config that is not an object, or a `limits` / `resourceSuspend` / `retentionPolicy` of `null`, is now `checkpoint.config_refused` where it was a raw `TypeError` before the claim, so a resume leg that read a non-`CheckpointError` as terminal no longer strands the approval. *cli*: the rejection's `message` is prepare's sentence, shown as is. *client-core*: the two types widen. *test*: black box — on a store without `reopen`, a resume with an unreadable A2A or MCP declaration rejects `checkpoint.config_refused` and the row stays `pending`; the corrected retry completes.
144
+ - **How to migrate.** Read `checkpoint.config_refused` + `detail.code` for every config error a resume can meet before its claim; `detail.field` routes it.
145
+ - **Pins.** `test/backlog1950-resume-config-doors-preclaim.test.ts` (cells A – E as first written; G the totality sweep over every seat the screen reads × fifteen JSON shapes (a record shadowing `toString` among them) and the config container; H the retention declaration over the Runner's session store, a task-named checkpoint store judged as the task's, the store seat by the channel; I a recipe for each of the 39 words of the set; K the inherited-constraint shape; L the auto-consolidation role seats and tier bindings); `test/backlog1950b-locked-config-total.test.ts` (the lock declaration's totality, pinned beside its reader); `test/backlog1763-mcp-tool-axes-judge.test.ts` E and `test/backlog1835-tool-axes-one-judge.test.ts` F flipped from the known gap; `test/f012-inheritance-decision-chain.test.ts`, `test/retired-doors-7490.test.ts`, `test/retired-hooks-key-one-door.test.ts` re-pinned to the one channel.
146
+
147
+ ### A compaction pass announces its start: `compaction_started` (`trigger`, `pass`, `startedAt`), minted once at the pass's commit point by every lane that compacts; the frame that ends the pass carries the same `pass` (#2008; additive; behavior addition, open to veto — a pass the task's own end cuts at the turn boundary is now ended by a frame; hard-breaking: none; closed sets: `TaskEvent["type"]` +1 arm; exports: +1 type `CompactionPassStart`; members: `TaskEvent` arm `compaction_started` (`trigger`, `pass`, `startedAt`), +1 optional `compacted.pass`, +1 optional `compaction_outcome.pass`, +1 optional `MaybeCompactOptions.onStarted`, +1 optional `pass` on `maybeCompact`'s result) (@server @cli @client-core)
148
+
149
+ - **Before.** Compaction was announced when it ended: `compacted` for a landing, `compaction_outcome` for a pass that did not land. A summary call that waited out a provider's retry schedule (503 × 11, 173 s measured) was a silent wait on the stream: a shell could only spin, and a server could not say what the run was doing.
150
+ - **Now.** `compaction_started` = `{ trigger, pass, startedAt }`, **all always**, beside the identity envelope every content event carries. `trigger` is the vocabulary the pass already hands the `preCompact` hook and its ending frame — **`"auto"` \| `"manual"` \| `"forced"`** (three words, not two: the prompt-too-long recovery, the guard chain's arm B and a trim-pressure boundary pass run as `"forced"`). `pass` is an opaque engine-minted id (uuidv7), unique across runs. `startedAt` is the engine clock in epoch ms. It is minted at the one place a pass commits, inside `maybeCompact` — the trigger gate passed, a valid cut point exists, no `preCompact` handler blocked it — before the summary provider is consulted or the model is called, so the frame is on the stream for the whole of the wait; a retry inside the summary call is the same pass and mints nothing. The commit point is also a public seat: `MaybeCompactOptions.onStarted(start: CompactionPassStart)` (`CompactionPassStart` = `{ pass, trigger, startedAt }`, now exported from the package root like the sibling callbacks' payloads), observe-only — a throw, or the rejection of an `async` callback, is contained at the seat's site and never reaches the pass — and `maybeCompact`'s result names the pass on a landing (`pass`). Three lanes put the frame on the wire from that callback, one frame mint (`mintCompactionStartedEvent`, beside the landing's `mintCompactedEvent`): the turn boundary (`auto`; `manual` for `/compact`; `forced` under trim pressure), the prompt-too-long recovery and the guard chain's arm B (`forced`), and the end-of-task summary (`auto`).
151
+ - **Pairing.** Exactly one frame ends each started pass and carries its `pass`: `compacted` when it lands (the key is new and optional on the type — the engine sets it on every landing), `compaction_outcome{failed}` when it burned its attempt, `compaction_outcome{mooted}` when it was cut while it ran (`reason: "cancelled"` for a caller's stop or withdrawal, `"task_ending"` when the task's own end cut it). `compaction_outcome.pass` is present exactly when a start was announced for the pass the frame ends; an outcome of a pass that never committed — `blocked`, `suppressed`, `disabled`, `noop`, a manual request no boundary served (`task_ending`) — announces no start and carries no `pass`, byte-identical to 7.52.0; so does the rapid-refill `disabled` frame, which follows a landing and reports the state that landing caused. A run with no compaction has no new frame.
152
+ - **Behavior addition (open to veto).** (1) **What changed:** a turn-boundary pass the task's own end cuts while its summary call runs (the walltime limit, a budget or turn limit — an abort the caller did not raise) ended with no frame at all; it is now ended by `compaction_outcome{outcome: "mooted", reason: "task_ending", trigger, pass}` (with its trace pair, `compaction.mooted{reason: "task_ending"}`), so that no started pass is left open. A cut before the pass committed stays as quiet as it was. (2) **Who is hurt:** a consumer that read the absence of any outcome there as "nothing happened" (no such consumer is known; the neighbours' code was not read for this). (3) **Compensation:** none needed; the frame is the one the dying-loop arm of a manual request already used for the same fact. The prompt-too-long recovery lane's reading of the same cut (`failed`) is unchanged.
153
+ - **Against Claude Code 2.1.296.** CC announces the start of a compaction as a status message (`system` / `status`, `status: "compacting"`) and its progress as `compact_progress`; the end is `compact_boundary`. The fact announced is the same; the shape is this engine's own wire — a discrete frame with an id the ending carries, so that a stream that multiplexes several tasks pairs the two by id and not by order.
154
+ - **Who is hurt.** A consumer with an exhaustive switch over `TaskEvent["type"]` (as for `task_started`: server's two arm gates, `src/trace/core-keyset-guard.ts` and `src/trace/model-segment.ts`; client-core's event view) fails to compile until it handles `compaction_started`; a consumer that ignores unknown frames is unchanged. A consumer that keeps a "compacting" state must end it on a frame carrying the same `pass` (or when the stream ends), not on the next `compacted` it sees.
155
+ - **How to migrate.** *server* (S-1732): project the frame as it is — `{ type, trigger, pass, startedAt }` plus the identity keys; add no phase of its own; the run and session ids are the stream's, not keys of the frame. *cli* (L-1568): show "compacting" from `compaction_started` until a `compacted` or `compaction_outcome` with the same `pass` arrives; the elapsed time is `now − startedAt`. *client-core*: add the arm to the event view and the optional `pass` to the two ending frames.
156
+ - **Pins.** `test/backlog2008-compaction-started.test.ts` (each lane's start precedes its summary call and sits on the stream while the provider's three 503s are retried inside the one pass; the landing names it; closed key set; three lanes, three distinct passes, each paired in its own stream; a failed pass, a caller's stop, a withdrawn manual request and the task's own end each end by a frame carrying the pass; a pass that never commits — a `preCompact` block, a stop that lands while the hook runs, a noop, a run with no compaction — announces nothing and its outcome has no `pass` key (the task's own end during the hook leaves no compaction frame at all, as before); the rapid-refill trip carries none; `maybeCompact.onStarted` fires once after `preCompact` and before the first model call, never for a pass that ends before the commit, also on the provider-reuse and forced paths, and a throwing observer is contained); `test/defectscan-task-event-surface.test.ts` (arm table +1).
157
+
158
+ ### A delegation marks the session's memory externally exposed by what entered the transcript: a delegation refused before any child ran marks nothing, and a background launch's reason says it launched (#1940; P2; behavior change — a loosening on refused delegations, a reason sentence on launches, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@cli)
159
+
160
+ - **Before.** The delegation arm judged exposure when the delegation tool was CALLED: an `Agent` call the tool refused before any child ran (an unknown `subagent_type`, an empty prompt, worktree isolation unavailable, the entry caps, a launch the account could not record) still marked `memory.session_polluted` with the reason `tool "Agent" returned the output of a delegated agent whose tool face can reach external content …` — false on its face; the background launch receipt carried the same "returned the output" reason.
161
+ - **Now.** One reading of what the call put into the transcript. The delegation tool's own refusals before any child ran are minted by one helper (`notStartedResult` in `src/agents/subagent.ts`, 38 sites) that brands the result's `details` object; the memory wrap exempts exactly those (`isNotStartedDelegationResult`, the brand only, never the shape): no child ran, so nothing of a child's — no result, no message it sent up — entered: no mark, no evidence gap on the provenance chain, no unattestable account row. The launch receipt marks at launch, with the reason `tool "Agent" launched a background delegated agent whose tool face can reach external content (…) — its output enters this session when it finishes`. Everything else marks exactly as before (the reason byte-identical): a child's report (completed or failed), a parked child's report and a fork whose run threw (a child ran and may have sent a message up before it stopped), any shape the reading does not know. A refusal not routed through the helper, or a result whose `details` was copied, still marks — drift lands on the safe side (a tripwire pin names any unminted refusal site). What a branded refusal quotes to the model (#2045, #2051): never a thrown value — a failed worktree mint's git output, a session store's or task registry's error, the settlement account's — and never a name an extraTools factory produced outside its declaration (the engine reads an undeclared product as external content); it says what failed in a fixed sentence and hands that text to the host as `details.cause`; a worktree whose cleanup check failed is named in a fixed settle line and its error rides `details.worktreeCause` (a child's report, which marks, still appends it). What a branded refusal does still quote: the engine's own sentences, the deployment's configuration (its agent catalog, definition lists, the entry gate's numbers), the model's own call arguments (`subagent_type`, `name`, `model`) and the auto-mode classifier's reason — text already in the session or authored by the deployment. Model-facing wording changes on seven refusals (`isolation.failed`, `fork failed`, `register_failed` ×2, `settlement_write_ahead_failed` ×2, `config.extra_tool_names`) and on the settle line.
162
+ - **Behavior change (open to veto).** (1) a delegation refused before any child ran no longer marks the session; (2) who is hurt: a host that counted refused delegations as exposure; (3) compensation: none needed — nothing a child produced entered.
163
+ - **Who breaks.** *cli*: L-1539's repro (a refused delegation) no longer shows the notice; the launch notice reads "launched". Nobody pins the reason text. *test*: `memory-engine-pollution` re-pinned — an unknown `subagent_type` no longer marks.
164
+ - **Pins.** `test/backlog1940-delegation-exposure-delivered.test.ts` (R1–R3 refused / failed before a child, N1–N2 a report entered, L1 the launch, P1 a parked child marks, U the brand by `details` identity — a copy is not exempt, the shape alone buys nothing — T the tripwire: every refusal result in the delegation tool is minted through the helper, except the three a child ran for, each exactly once; T2 no branded refusal's content, and no settle line, quotes a thrown value; F1 / F2 a worktree mint / cleanup failure carrying outside output: fixed text to the model, the text in `details`; F3 a real non-repository root; F4 an undeclared extraTools product whose name carries an instruction).
165
+
166
+ ### `admitRouteRequest` — the route request gate the first-party brains send through — is exported from the package root, so a host's direct send binds and is refused by the same credential-origin table (#1962; P2; security — a per-model credential sent by a host to origin A then B was sent to both; additive export; hard-breaking: none; closed sets: none; exports: +1 function `admitRouteRequest` (the module-level `isNotStartedDelegationResult` of #1940 is not on the package root); members: none) (@server)
167
+
168
+ - **Before.** The gate that binds a per-model credential to the origin it is first sent to (and refuses it anywhere else) was module-internal; a host that sends a request itself (server's hook direct path) could only read `adjudicateModelRoute`, which binds nothing — so its sends to A and then B both went out, and the protection depended on who sent first.
169
+ - **Now.** The package root exports the brains' own `admitRouteRequest(model, credential, config)` (the same function, not a copy): resolve the credential with `resolveRouteCredential`, call the gate before building, run the admission it returns right before the request goes out; a refusal is the very `BrainError` a first-party brain throws for the same request (`[invalid_request] route.credential_origin_mismatch: …`). `adjudicateModelRoute` stays the read.
170
+ - **Who breaks.** Nobody. *server*: replace the direct path's `adjudicateModelRoute` + own `fetch` with the gate (its admission run immediately before `fetch`), leaving no second reader.
171
+ - **Pins.** `test/backlog1962-host-route-gate.test.ts` (E identity, G1 host A then B with the brain's sentence, G2 / G3 either sender first, V the read binds nothing).
172
+
173
+ ### `config.user_attachment_invalid` carries the field it names: `detail.path` (the field path the sentence opens with) and `detail.index` (the attachment's position, when the sentence names one attachment) (#1968; additive; hard-breaking: none; closed sets: none; exports: none; members: the refusal's `detail` `{ path, index? }`) (@server @sdk @client-core)
174
+
175
+ - **Now.** Every mint of the code — the screen (the seat, an entry, a member, a nested member), the image admission, the no-workspace path refusal, the window refusal — carries `detail: { path, index? }`: `path` is exactly the field the sentence opens with; `index` is 0-based (the `[i]` of `path`) and present exactly when the sentence names one attachment (the seat as a whole — not a list, too many, on a resume, over the window — has none). The sentence is unchanged. A fresh run's `terminal.detail` is `{ kind: "coded", detail: { path, index? } }`; a resume refuses attachments only as a seat (its channel carries `detail.code` and `detail.field` as before).
176
+ - **Who breaks.** Nobody. *server*: passes it through as it does the code; *sdk*: the type gains `detail`; *client-core*: read `index` to point at the attachment.
177
+ - **Pins.** `test/backlog1968-attachment-refusal-detail.test.ts` (every mint family, and the real lane's terminal).
178
+
179
+ ### The earlier-grammar announcement of a session's stored rules is one engine notice, `config.session_rules_earlier_grammar` (audience `user`), with the facts as detail; the `onError` phase it rode is retired (#1970; consumer-visible channel move, open to veto; hard-breaking: none for a deployment — the code word is unchanged, see Who breaks; closed sets: `EngineNoticeCode` +1 `config.session_rules_earlier_grammar`; exports: none; members: none) (@server @cli)
180
+
181
+ - **Before.** A session's stored rules holding entries their earlier rule grammar reads as nothing (C-R493: a `toolDeny` entry no grammar reads refuses every call until rewritten) were announced through `onError` (phase `config`) as an advisory carrying `code: "config.session_rules_earlier_grammar"` — not an `EngineNotice`, so a host's notice routing never reached the person whose calls those rules judge.
182
+ - **Now.** One engine notice per distinct entry set per bucket per rule layer: `message` is the advisory sentence unchanged; `detail: { bucket, storedGrammar, entries: [{ entry, reader }], rewrite: { by: "operator_write_without_rev", stampsGrammar }, rulesSessionId, sessionId }` — every entry (the sentence names the first five), each through the notice rule-text reading and with the reader's word (`no_rule` — this engine's grammar reads it as no rule; `named_no_tool` — a grammar-0 allow entry that named no tool there); `rulesSessionId` is whose rules (an ancestor's on a delegated child), `sessionId` the run that read them. Nothing rides `onError` any more.
183
+ - **Who breaks.** *server*: a consumer reading the code off `onError` (`runner_config_advisory`) now receives it on `onNotice` — register it by the notice derivation; a test that pinned the `onError` phase re-pins to the notice. *cli*: render the notice's `message` as is.
184
+ - **Pins.** `test/backlog1970-earlier-grammar-notice.test.ts` (N1 / N2 the four facts and zero `onError`, C the stamped control, K the registration and the rule-text cut); `test/backlog1761-rule-row-grammar-version.test.ts`, `test/backlog1790-grammar0-star.test.ts`, `test/backlog1866-session-deny-fail-closed.test.ts` re-pinned to the notice (each now also asserts the `onError` channel stays empty).
185
+
186
+ ### Four seats total at their own reader, before any resource: `TaskSpec.skills`, the policy seats and `RunnerDeps.hands` refuse a value they cannot read by name, and a resume refuses it before its claim (#2003; P2; behavior narrowing — open to veto; hard-breaking: none for a well-formed deployment, see Who breaks; closed sets: `CHECKPOINT_CONFIG_REFUSAL_CODES` +1 `config.skill_base_dir_invalid` (39 → 40); exports: none; members: none) (@server @cli @client-core @test)
187
+
188
+ - **Before.** `skills: [5]` / `[{ name: 5 }]` / `5` / `[null]` crashed with a bare `TypeError` (a resume left a non-`CheckpointError` with the row pending, which a caller reads as terminal; `files: 5` consumed the row and failed the leg); a skill's `content: 5` ran; a skill's `baseDir: 5` on a resume left as a raw coded error; `toolPolicy: 5` or `{ check: 5 }` ran with no policy installed; `RunnerDeps.hands` of `5`, `{ x: null }` or `{ commitCoAuthor: 5 }` ran as if absent.
189
+ - **Now.** The skills seat has one reading (`readSkillsSeat`, `src/core/runner/skills-seat.ts`; `normalizeSkills` is its sorted view): total — not a list, an entry not an object, a `name` / `description` / `content` that is not a string, or `files` that is not a list of `{ path, content }` strings refuses `config.invalid_argument` with `detail.field` the member's path (`TaskSpec.skills[0].name`); `baseDir` by its own reader (`config.skill_base_dir_invalid`) — and once: the config doors run it before any resource and hand its plain copy on as the leg's `skills` (fresh and resume) — an absent seat included, installed as the copy's own empty slot — so nothing after the door reads the caller's seat or skill objects again (an attachment's accessor is read once per leg; a `skills` accessor that answers nothing at the door is never read again). The ToolPolicy door every installed policy passes is total: `null` is no policy (the contract: `null` = no policy; any other value that is not an object with a callable `check` is refused), refusing `config.invalid_argument` (`detail.field: "ToolPolicy"`); the resolved seat is the one judged, as before (a task's policy shadows the deployment's, which is not wired). `RunnerDeps.hands` has a screen (`screenHandsBandOptions`, `src/core/hands-band-screen.ts`): not a plain record, a member it does not have, or a member with a value it does not take refuses `config.invalid_argument` (`detail.field` `RunnerDeps.hands` or `RunnerDeps.hands.<member>`; an explicit `undefined` member is absence). All run in the config doors, so a fresh run fails before any resource and a resume rejects `checkpoint.config_refused` (`detail.field` `taskConfig` / `deps`) with the row `pending`.
190
+ - **Behavior change (open to veto).** (1) the shapes above are refused where they crashed or ran; and, a behavior narrowing open to veto, `skills: null` — which 7.52.0 read as no skills (`spec.skills ?? []`) — is refused (`config.invalid_argument`, `detail.field: "TaskSpec.skills"`, "TaskSpec.skills must be a list (got null)"; a resume rejects `checkpoint.config_refused` with the row `pending`): only an absent seat is no skills, where `toolPolicy: null` stays no policy; (2) who is hurt: a deployment passing an extra key on `RunnerDeps.hands`, a policy object without `check`, a skill with a non-string member, or a host that writes `skills: null` for "no skills"; (3) compensation: the refusal names the member; leave `skills` out (or pass `[]`) for no skills.
191
+ - **Closed sets.** `CHECKPOINT_CONFIG_REFUSAL_CODES` +1: `config.skill_base_dir_invalid`.
192
+ - **Who breaks.** *server*: a consumer pinning `CheckpointConfigRefusalCode` gains a word; a settings path that hands `hands` extra keys is refused at the first leg. *test*: black box — a resume with `skills: [5]` rejects `checkpoint.config_refused` and the row stays `pending`.
193
+ - **Pins.** `test/backlog2003-seat-totality.test.ts` (eleven bad shapes × resume and fresh; the read-once cells — an attachment accessor read once on a fresh run and once on a resume, a seat accessor answering nothing at the door and `[null]` later read once; controls including `toolPolicy: null` and an explicit `undefined` hands member); `test/backlog1950-resume-config-doors-preclaim.test.ts` I gains the word's recipe.
194
+
195
+ ## 7.52.0 — 2026-10-17
196
+
197
+ ### Another spelling of a mounted tool's name in a tool list refuses the run before it starts: a deny list that names a case twin or a retired name hid nothing, and the tool it was written for ran (#1930, #1932, #1935, #1937, #1947, #1951, #1953; P1; security — fail-open; behavior narrowing, the reversal of one arm of the 7.51.0 "kept and told" entry — open to veto; **hard-breaking**: a run whose agent definition, delegation tool, `TaskSpec` or policy name lists (a permission rule's tool name included), or a session-rule write, or a host-written workflow `toolCeiling`, name a tool by another spelling of a mounted tool's name is refused where 7.51.x started it, and so is a run whose delegation tool is mounted under another name than the one it declares (`{ ...tool, name: "Delegate" }` on a tool built as `createSubagentTool({ name: "FENCE" })`); codes: `config.agent.unknown_tool` and `config.tool_face_control` gain the `detail.reason` value `near_miss`, `config.tool_face_control` also gains `renamed_mount`, `invalid_record` and `registry_occupied`; `checkpoint.config_refused` carries `config.tool_face_control` as `detail.code` (the row's recorded tool names and a renamed delegation mount, refused before the claim — they were `resume.parent_constraint_mismatch` for the names, in this unreleased window), `config.legacy_tool_name` answers a case twin; closed sets: `REQUIREMENT_WORDS` +1 (`own_tool_lists`; 28 → 29 — an older worker refuses a row carrying it as `version_newer`, the row stays pending), `CHECKPOINT_CONFIG_REFUSAL_CODES` +1 (`config.tool_face_control`; 8 → 9), `CheckpointErrorReason` +7 (`not_a_list`, `not_a_string`, `unknown_name`, `unsupported_pattern`, `invalid_record`, `renamed_mount`, `registry_occupied`; 18 → 25); `detail.reason` is an open string on those codes (new values: `near_miss`, `renamed_mount`, `invalid_record`); exports: none; members: `WorkflowInternals` +1 optional `toolCeiling` (the launching run's ceiling, on the trusted fourth argument of `startWorkflow` / `runWorkflow`; `RunWorkflowOptions.toolCeiling` stays, now the host-written form that is judged); `CheckpointState` +1 (`ownToolLists`, requirement word `own_tool_lists`: optional `{ toolName, definition?, lists }` — the delegation tool that wrote a leg's own lists, the definition it was spawned as and the digest of exactly the lists it wrote, which the record holds for; absent on a row parked before it); `RunInternals` −1 +1 (`ownToolListsWhere` → `ownToolLists`: the engine-minted string label of whose lists a leg's own are becomes the structure `{ toolName, definition?, lists }`; the sentence is minted where it is used)) (@cli @server @client-core @test)
198
+
199
+ - **Before (stage `575ee3f41`, real Runner lanes, a real `/bin/sh` — the marker of an `echo` came back in the model's tool result).** An agent definition's `denyTools: ["BASH"]` (the file key `disallowedTools`), `["bash"]`, the run's own `excludeTools: ["BASH"]`, a workflow agentType's `denyTools: ["BASH"]` and a run's `toolPolicy` deny of `BASH` each started the run, and the child's (or the run's) `Bash` executed — ran 1, no line to the user. The two list judgments kept the entry as written and told the operator one line (`onError` phase `config`; since 7.51.0, reversing 7.49.0 – 7.50.2, which refused it); the policy name audit called a case twin "unmatched" and only advised. A kept entry is matched by exact name, so a denied twin hides nothing. The allowed side read the same way, in the other direction: `allowTools: ["read"]` (or `["bash"]` on an agent) mounted nothing the entry was written for — the run started with no `Bash`, no line to the user.
200
+ - **Now.** One rule: a name the one name reading holds to be another spelling of a tool the run carries — the reading gives the spelling to write (`currentName`: a case twin `BASH` / `bAsh`, a retired name with a successor `bash` / `read_file` / `KillShell`, a protocol peer the mint spells otherwise `mcp__github.com__x` → `mcp__github_com__x`; a rule form of one, `BASH(rm:*)`, included) — reaches no tool and refuses the run before any turn, on either side of a list — on the universe of the list (one sentence below). Three lanes, existing codes: an agent definition's or a delegation tool's lists → `config.agent.unknown_tool`; the run's own `allowTools` / `excludeTools` → `config.tool_face_control`; both with `detail.reason: "near_miss"` and a sentence naming both spellings (`… declares tool "BASH" (near_miss: the mounted spelling is "Bash") in its denied tools, a spelling that reaches no tool a run mounts — it hides nothing, so the tool it was written for stays available to the agent. Write the name as the tool is mounted.`); a policy's name lists (a workflow agentType's lists, a run's `toolPolicy`, an ancestor's, a session's stored rows) → `config.legacy_tool_name`, the code the audit already answers a retired or unmintable name with (`tool policy deny-list entry "BASH" differs from the mounted tool "Bash" only in case … Write "Bash".`). The reading is the entry lists' own (`allow-entry.ts`, now exported as `nearMissSpellingOf` inside the package): an engine tool's exact name and a protocol tool's are never a near miss, a name the roster carries is not one (a caller tool `BASH` beside `Bash`; a deployment's own `Task`). A name nothing answers to and that has no other spelling (`NotMounted`, the typo `Bsh`, a retired name with no successor) stays kept and told to the operator once — a tool of that name may be mounted later; no user-facing line is added for it. Rules: the list judgment gains no criterion of its own — it reads the `currentName` the entry reader already gives (+1 refusal arm, one sentence); the policy audit calls the same reading (+1 arm); the case comparison against a roster stays in one place. The universe, one sentence: a root-facing list (the run's own `allowTools` / `excludeTools`, a policy's names, a session-rule write) is read against the run's roster, the caller's tools, the catalog and the exact names of the pools its reachable delegation tools declare and can list, and another spelling of a name in it is always refused; a child-facing list (a definition's, a delegation tool's) is read against the pool that delegation tool declares and the catalog and — only while that pool cannot be listed — keeps and tells such a name; a layer a run inherits is not asked again. An exact name in a root-facing universe is the caller's own, never a twin (a pool tool `FENCE` beside the caller's `Fence`, named by a `toolPolicy` or a workflow agentType's deny, starts the run); a delegation tool the run's own selector takes out (`excludeTools: ["Agent"]`) counts in no universe, and an unlistable pool (an `extraTools` factory with no declared `extraToolNames`) clears no name. The pool is read in one place (`delegationPoolReading`, after the selector), the root-facing names in one (`rootFacingToolNames`, which the session-rules write gate calls too). The spawned child is handed the lists its spawning run judged, with the evidence of that judgment, and a ceiling it inherits was asked by the run that wrote it: neither is asked again of the child's own poorer universe (the child that did refused a parent naming its own `read` / `GREP` / `Task` tool, and a resumed row's ceiling was asked after the claim — #1935). The persistent rule layer (`permissions.deny` / `ask` rules, `Bash(*)` form included) reaches the same policy-name audit by its name sets, so `BASH(*)`, `BASH` and `BASH(touch:*)` refuse the run too (`config.legacy_tool_name`), while `Bash(*)` still denies the call and a rule naming nothing is still an advisory. A session-rule write (`toolDeny` / `toolAllow`, operator writes included) holding a case twin of an engine tool is refused at the write like a retired name (`inert_entry`, `detail.reason: "near_miss"`). The near-miss sentence says whose spelling it asks for: `the mounted spelling is` for the roster, `a delegation pool carries` for a pool-only tool, `the engine's tool catalog names` for a tool only the catalog carries. (#1937) Three places the rule did not reach the whole way, closed: (a) a child-facing entry the spawning run kept because its delegation tool's pool cannot be listed is no longer waved through the spawned child — the child's lists carry the stamp "judged against a pool that can be listed" only for a pool that can (a delegation tool with an `extraTools` factory and no `extraToolNames` stamps nothing), so the child asks the entry of its own universe — its roster, the catalog, the pools it can list, and the delegation tool that spawned it under its exact mounted name (a name of its lineage, mounted at its depth or not; an exact hit of it comes before any other reading of the same string, so the built-in Explore / Plan's own denied `agent` and a host's `denyTools: ["Task"]` start the child, #1942) — and refuses another spelling of a tool it carries (`config.tool_face_control`, `near_miss`, before any model turn; the spawning run's one line is not told a second time, a name nothing answers to stays kept), and the one line says what happens next (the pool this delegation tool hands its child cannot be listed; the spawned child judges the entry against its own roster: another spelling of a tool it carries refuses the child, an exact name takes effect, a name nothing answers to stays in force for a tool mounted later); the row a parked child leaves records who wrote its lists (`CheckpointState.ownToolLists`), so a bare `resumeStream` asks the same universe — a row parked before the member is judged as before (KL-1930-c); and the same rule closes the other way: a delegation tool named `Task` whose child list holds `TASK` (a case twin of the tool's own name) started its child in 7.51.x and now refuses it before the first turn, as it already did at the spawning run and at any depth where the child mounts the nested copy — the tool's name is a name of the child's lineage, so its twin is a twin; (b) the policy name audit reads the root-facing universe (roster, the caller's tools, the catalog, the listable pools' names) instead of the roster alone, so a session's stored `toolDeny: ["FENCE"]`, a run's `toolPolicy` deny, a workflow agentType's lists and a permission rule refuse a twin of a tool only a listable pool or only the catalog carries (`WEBFETCH` beside the catalog's `WebFetch`) — the same configuration `excludeTools` already refused; (c) a ceiling the host writes (`RunWorkflowOptions.toolCeiling`) is asked of every workflow agent's own tools (the catalog, its caller tools, the listable pools of the delegation tools its own lists leave) before the agent launches, on both spawn legs, and a near miss in it refuses that agent (`config.tool_face_control`, `near_miss`; the first `ctx.agent()` rejects with the sentence) — the ceiling of the prepared run that launched the workflow rides the new `WorkflowInternals.toolCeiling` (the `run_workflow` tool's wiring) and is not asked again; given both, both layers bound every agent (the launching run's first) and only the host-written one is judged. Rules: the stamp's meaning narrows (+0), the audit's universe is the existing one (+0 functions), one function (`judgeHostToolCeiling`) asks the existing list judgment of the host's ceiling, and one in-memory flag on the list judgment's input (`toldUpstream`: a delegation lane inside a prepared run spawned the leg and told what it kept — so the refusal is asked and nothing is told twice) is not persisted. (#1947) A fourth place, (d): a delegation tool is mounted under the name it declares. A tool built as `createSubagentTool({ name: "FENCE" })` and mounted renamed (`{ ...tool, name: "Delegate" }`) answered to two names — the declared one in its definitions' lists, the copy a child is handed and its prompt text, the mounted one in the roster, the pool reading and the model's call — so a child definition's `denyTools: ["FENCE"]` was read as the exact name of the child's lineage and no longer as a twin of the `Fence` a factory injects, and that `Fence` ran (the same lanes before this rule: ran 1; with `TaskSpec.agents` set the per-task rebuild handed back a copy under the declared name and the rename vanished unseen — the root's call of `Delegate` came back "Tool Delegate not found"). Now the factory binds the declared name to its `execute` — the one function every working copy of the tool keeps (the nested copy a child is handed is built at the same place); `defineTool` binds a product's wrapper to the same name — and a mount whose `name` differs refuses the run at prepare, however the copy was made (a spread, an `Object.create` copy, a copy with any member set to `undefined` or destructured away, a Proxy that hides members, a shell copying only name / description / parameters / execute, a `defineTool` product, the result of the tool's own `withAgents`) — `config.tool_face_control`, `detail.reason: "renamed_mount"`, the sentence naming both names and the two ways out (mount it under its declared name, or declare the name you mount it under) — before any model turn, whether or not the model would delegate. Both rosters the run holds are read — the one the caller mounted and the one the per-task rebuild handed back (a function the tool supplies, which may itself rename) — so the sentence is the same with or without `TaskSpec.agents`; and a product renamed at mount is refused like the spec form. A closure, a bound copy (`Function.prototype.bind`) or a Proxy around `execute` is another function and is not followed (KL-1947-b). The table of declared names is never replaced by an empty one: a slot of `globalThis` that holds something other than the engine's table refuses (`config.tool_face_control`, `registry_occupied` — building a delegation tool throws, a roster is refused at prepare and before the resume claim). (#1951) A resume asks the roster it is handed once more before the claim (the resume screen, the same sentence, `CheckpointError` `checkpoint.config_refused` with `detail.code` `config.tool_face_control` and `detail.reason` `renamed_mount` — the code a fresh run reports — through the channel the row's own recorded names now use too): the checkpoint stays `pending` whatever the store, so the corrected retry resumes the same work — where the refusal at prepare alone, after the claim, left a row `resolved` on a store without the optional `reopen` (the retry was `checkpoint.already_resolved`); the rebuilt roster is still read only at prepare (KL-1951-a). Rules: +1 (one tool, one name: mounted name = declared name), −1 (the two-name reading of one tool); the pool reading still reads the mounted name, which is now the declared one; the check has one sentence (`renamedDelegationMountSentence`) asked at two stations that do not overlap in what they read — the resume screen before the claim (the roster as handed) and the config door at prepare (the roster as mounted and as rebuilt; a resume's mounted roster has already passed). Two more places, closed with it — the record of who wrote a leg's own lists (`ownToolLists`): (e) it holds only for the lists it wrote. The record carries `lists`, the digest (`toolListsDigest`, the one the judgment evidence uses, but always recorded) of exactly the lists the delegation tool handed the child, and a prepare keeps it only while its own lists digest to that value (`boundOwnToolLists`, the same shape as `boundToolListsJudgment`). A bare resume of a parked child whose host hands the table the tool wrote keeps the record (the leg starts; a refusal names the tool's list); one whose host hands another table — an entry added, `excludeTools: ["agent"]` — asks it as its own `TaskSpec` seat: the sentence names `TaskSpec.excludeTools` (`BASH` beside `Bash`: refused, `near_miss`), and `agent` is a twin of the catalog's `Agent` like any other name (refused), not the lineage's exact name. (f) a live seat that is not a record — `RunInternals.ownToolLists` set by a JavaScript host to a string, a record with no `lists`, or an empty `definition` / `lists` — refuses the leg (`config.tool_face_control`, `detail.reason: "invalid_record"`, before any model turn), in the sentence a row's damaged record gets before the claim, naming the seat (a bare `TypeError` otherwise). The shape check is one for the row and the seat: a tool name the caller-name contract accepts, a present `definition` and a `lists` that are non-empty strings. Rules: the record's author and its binding are one rule (a record holds for the lists it wrote); the live seat and the row share the one shape check.
201
+ - **Against Claude Code 2.1.296.** `cli296.js:237460` refuses a rule whose tool name has no `_` and a non-upper-case initial (`Tool names must start with uppercase`, suggestion `Use "Bash"`) and nothing there refuses `BASH`; that a `BASH` rule then matches nothing is inferred from the exact-string matching (black-box reading, not run in this car), so the rule layer refusing the case twin is a stricter deviation. Not otherwise re-read against `cli296.js` in this entry. By the 7.51.0 entry's reading the reference matches an agent's list by exact name and never fails a run on a name nothing answers, so a case twin starts the run there and hides nothing; refusing it is a deliberate, stricter deviation (a deny that cannot bind is a fail-open) — the register's row `agent-deny-entry-near-miss-refuses-run` (7.45.0, the agent-definition lane) is extended to this four-lane form.
202
+ - **Who breaks.** A deployment or a user's agent file whose `denyTools` / `allowTools` (`disallowedTools` / `tools` in a `.claude/agents/*.md`), `TaskSpec.excludeTools` / `allowTools` or policy lists spell a mounted tool another way is refused at the first run, naming both spellings, whether or not the agent is used that run (the 7.49.0 – 7.50.2 shape, for this one class of name only; an agent file naming a server nobody declared still starts) — those lists never bound the tool they named (a denied twin ran it; an allowed one granted nothing). A session-rule write holding a case twin of an engine tool (`BASH`, `READ`; `toolDeny` / `toolAllow`, operator writes included) is refused at the write (`SessionPolicyError` `inert_entry`, `detail.reason: "near_miss"`, the way out named: declare the deployment's own tool in the store's `mountedToolNames`), as a retired name already is. A stored row that already holds one (written by an earlier release) refuses every run of its session under `config.legacy_tool_name` and a non-operator write cannot take it out (`loosen_forbidden`); an operator rewrite clears it (probed in the D pins). A permission rule naming a case twin (`BASH(*)`) refuses the run the same way. A run whose delegation tool has an `extraTools` factory and no declared `extraToolNames`, and whose own `allowTools` / `excludeTools` or policy names spell another spelling of a tool the run carries (`excludeTools: ["BASH"]`, a deny `FENCE` beside `Fence`), is refused — 7.51.x started it and told one line, and the twin hid nothing (the run's own `Bash` ran): declare `extraToolNames` or spell the name exactly (KL-1935-b). @cli: the shell-side workaround for the silent deny (L-1535) retires on arrival. @server: its acceptance-door copy of the list judgment (`judgeDeniedToolNames`) must refuse the same spellings or relay the engine's `config.agent.unknown_tool` / `near_miss`. @client-core: render `detail.reason: "near_miss"` beside `"unknown_name"`. @test: the pins below are the black-box set. A delegation tool with an `extraTools` factory and no declared `extraToolNames` keeps the entry at the spawning run for the definitions' and delegation tools' lists (kept and told once, KL-1930-c) because its pool cannot be listed there — and the spawned child, whose universe is the real one (its roster, the catalog, the pools it can list, and the delegation tool that spawned it), refuses another spelling of a tool it carries before it starts (#1937; the first cut let the child through and its twin hid nothing): declare the roster to have the refusal at the spawning run. A policy deny, a session's stored `toolDeny` or a workflow agentType's list that names a twin of a tool only a listable delegation pool or only the catalog carries (`FENCE` beside the pool's `Fence`, `WEBFETCH`) now refuses the run before it starts (`config.legacy_tool_name`) where it was advised and the pool's tool ran. A host that starts a workflow itself with `RunWorkflowOptions.toolCeiling` naming a twin of a tool its agents carry gets the first `ctx.agent()` rejected; a deployment that builds its own `run_workflow` wiring passes the launching run's selector layers as `WorkflowInternals.toolCeiling` (the fourth argument), as the tool now does — layers passed there are the launching run's and are not asked again (@server if it mints its own wiring). A row an earlier release parked with another spelling in its ceiling (`deny: ["BASH"]`) resumes and applies it as spelled (KL-1935-a). A host that set `RunInternals.ownToolListsWhere` itself (an engine-minted seat; none is known) fails to compile — write `ownToolLists`. A host that renames a delegation tool at mount (`{ ...tool, name }`, or a wrapper that sets `name` on the factory's product) is refused at the first run, naming the declared and the mounted name (7.51.x started it): mount the tool under its declared name, or declare the name you mount it under with `createSubagentTool({ name })` — agent files, `denyTools` and the model's calls then speak the one name. A tool the factory did not make is not read, nor is a delegation tool whose `execute` the host wrapped in a closure, a bound copy or a Proxy of its own and stripped of its delegation members, or whose table the host removed (KL-1947-b). A row an earlier release parked under a renamed mount resumes once the resume hands the tool under its declared name: a resume that still hands it renamed is refused before the claim (`checkpoint.config_refused`, `detail.code` `config.tool_face_control`, `detail.reason` `renamed_mount`, the sentence naming both names) and the row stays `pending` on any store; a resume that hands another table than the child was spawned with (the recorded lists no longer hold) is refused at prepare, after the claim — a store with `reopen` puts the row back to `pending`, one without leaves it `resolved`: re-supply the table the child was spawned with, and have the store implement `reopen`. Nothing sets `RunInternals.ownToolLists` but the delegation lane: a host that does writes `lists` (the digest of the lists it wrote) or leaves the seat out; a malformed seat refuses the leg.
203
+ - **Pins.** `test/backlog1930-near-miss-refuses.test.ts` (91 cells, real Runner over `/bin/sh`: R the refusal per source × side and per spelling, the run's own lists, a workflow agentType and a `toolPolicy`, each ran 0 with no model turn; C controls — a real name hides the tool, nothing answers ⇒ one account, a name the roster carries, a policy deny of the real name, a workflow deny of the real name, two caller tools that differ only in case with one excluded and both named; K the limits as they stand — a typo, a retired name with no successor, the unlistable pool, a retired-name caller tool excluded and denied, an inherited parent policy; S one reading: no second case comparison in the two stations; and 28 for #1932 — A a pool tool `FENCE` beside the caller's `Fence` under a `toolPolicy` / workflow agentType deny, requireApproval and allow lists, a declared-only pool tool, an unlistable pool kept and told, the no-pool control still a twin; B the run's own lists under an unlistable pool, a declared roster without the name, a declared roster with it, a static pool with and without it; C a list judged upstream is not judged again in the spawned child (re-expressed by #1935); D the session-rule write gate and a stored twin row; E the carrier named in the sentence; F permission rules; K6 a delegation tool's own list is read against its own pool; and 15 for #1935 — R a root-facing list refuses another spelling whatever a delegation tool injects (an excluded `Agent`, an unlistable pool, a policy deny, a permission rule), only a reachable, listable pool clears a name; D a child-facing list is read against the declared pool, reachable delegation tools only, each tool its own pool, a per-task definition against every reachable tool's pool, the delegation tool's own name — and only that name, not an alias set on the mounted copy — carried by its children's pool (D4 – D6); P a layer a run inherits is not asked again (a parent naming its own `read` / `GREP` / `Task` tool; an ancestor's stored session rules); S2 one root-facing function; and 15 for #1937 — A the main form per source (a delegation tool's / a definition's / a task definition's `denyTools: ["FENCE"]` beside the pool [Fence] behind a factory with no roster: told once at the spawning run, the spawned child refused before any model turn, Fence ran 0), the declared-roster control (clean at the spawning run, `FENCE` hidden, `Fence` runs), the allowed side, two layers (maxDepth 2: judged at the leaf, not at the middle layer; the declared roster leaves the leaf unasked), the flag's unit cell (`toldUpstream` / `judgedUpstream`), a delegation tool run with no prepared selector behind it (nobody told: the child tells, and still refuses a twin); B the policy audit's universe (a session's stored rule, a workflow agentType's list, a catalog-only twin by `toolPolicy` / permission rule / `excludeTools`, the exact names still start); D a host-written workflow ceiling (refused per agent on both spawn legs; the launching run's rides the trusted fourth argument; a caller tool named `read` clears it; the exact name still hides; one absence test — a `null` trusted ceiling is no ceiling handed down; a delegation tool the agent's own `excludeTools` takes out counts in no universe; both channels given — both layers bound the agent, the host's is judged, the launching run's layer also removes a delegation tool from the reach)); and 14 for #1942 — E the main form (the built-in Explore and Plan × a delegation tool named `agent` or `Task` × a factory with no roster × depth 1: the child starts, nothing told), the controls (no factory, depth 2, the default name, a non-twin name), a host's own definition and tool-level list (deny and allow, the catalog's own spelling still an exact name), a real twin of the tool's name (`AGENT`, `TASK`) still refused before the child's first turn on both sides, the retired spelling `Task` as the tool's own name, a parked unstamped child resumed bare (it starts), an old-shape row (refused as before), six damaged records (refused before the claim: by type, and a tool name no caller tool can carry), the kept-entry sentence of an unlistable pool and the listable pool's unchanged). Re-expressed (old → new meaning, #1942): `backlog1377` ⑤ and `backlog1432` ④ (the unlistable pool's kept-entry sentence says what the spawned child will do with the entry); the cells that passed `ownWhere` to the list judgment pass `ownBy` (`backlog1875`, `backlog1886`, `backlog1910`, `backlog1930` A5); `scripts/verify-phase-api.mjs` floor 1083 → 1084 (the suspend-saga phase takes the leg's own-lists record); `module-size-ratchet` three rows (+2 / +3 / +3, each with its reason). `test/backlog1910-persisted-selector-canonical.test.ts` +5 cells (U9 – U13: a restored or live ceiling holding another spelling of a tool is applied as written, the row consumed by a leg that ran, the spelling read-back still before the claim). Re-expressed (old → new meaning, #1935): `backlog1930` K3 / A6 / B1 / B2 (kept and told → refused; the root-facing form), C1 (the child refused → the child starts, told once at the parent), C3 (refused at the child → refused at the root), K6 (refused at the child → refused at the root), E2 (the sentence's carrier follows the child's universe); `backlog1377` ③ (carrier), ⑥ (the pool carries `bash`; the root no longer clears a child's list), ⑪ (the count comment); `backlog1432` ① (carrier); `backlog1736` U4 / U6 and `backlog560` ⑤ and `prepare-listings-transcript` AGENTS AUDIT (the delegation tool's pool carries the tool the definition names); `scripts/verify-phase-api.mjs` floor 1082 → 1083 (the policy-chain phase takes the leg's selector). Re-expressed (old → new meaning, #1937): `backlog1930` K3 and C1 ×3 (the child starts and its twin hides nothing → the spawned child is refused against its own roster, its tools never run), C2 (now across the three sources: kept at the spawning run, told once, the child starts), K5 (advised at the parent and the child's Fence ran → refused at the parent for a listable pool; the unlistable half pins KL-1930-e), S1 / S2 (the policy audit asks the root-facing universe and calls `rootFacingToolNames`); `backlog1377` ⑪ (the judge-call count 8 → 10: the host-written ceiling is asked by the same judge, once per side). Re-expressed (old → new meaning, #1930): `test/backlog1377-subagent-deny-names-one-judgment.test.ts` ① `edit` (×3 sources), ② `bash` and `mcp__github.com__*` (×3), ③ the near miss's account (×3) — kept and told → refused (`config.agent.unknown_tool`, `detail.reason: "near_miss"`, the sentence naming the mounted spelling), ⑧ the second delegation tool's `edit` → refused beside a `NoSuchTool` that is still told (⑤, the unlistable pool, is unchanged: a near miss there is still told); `test/backlog1432-deny-universe.test.ts` ① `bash` / `BASH` (×3 sources) — told, run starts, `Bash` stays → refused, no child runs (④, a factory with no declaration, unchanged); `test/per-task-agents.test.ts` [886]①c (`bash` told → `NoSuchTool` told, `bash` refused); `test/prepare-listings-transcript.test.ts` AGENTS AUDIT (`bash` told → refused beside `BASH` and an allowed `bash`). Mutations, red cells: the list judgment's refusal removed → R1–R4, K3 (declared) and the re-expressed cells; the audit's removed → R5, R6; the judgment asking its own case fold → S1 and the cells whose spelling is no case twin; the refusal widened to every stale entry → K2; the complete-universe gate dropped → K3; the run's own lists judged against the incomplete universe → K3 (per fixture: `.gate-logs/car-1930/mutations.md`). #1935 mutations, red cells per fixture (`.gate-logs/car-1935/mutations.md`): completeness counting every delegation tool → R4, D1; the root-facing judgment or the audit waiting for completeness → R2, R3, B1, B2, C3, K3, A6; the child asking inherited layers again → P1 ×3, U9, U11, U13; a child-facing list read against the spawning run's roster → C1 ×3, D1, D2, E2, K3, K6; the upstream evidence not honored → C1, C2, D2, K1 – K3 and six #1736 cells; the audit without the pools' exact names → A1 – A3, A5; the ancestor rows asked for a twin → P2; the write gate off the catalog → D1 (session), S2; per-tool pools read as their union → D3, D4; the delegation tool's own name left out of its children's pool → D5; the mounted copy's caller-set aliases carried → D6. #1942 mutations, red cells per fixture (`.gate-logs/car-1942/mutations.md`): the spawning tool's name left out of the child's universe → E1, E3, E4, E5, E7 and the seven E6 cells that park first; the record read from the live seat only → E5; the row stamped without it → E5; the row-shape refusal off → the six damaged-record cells; the record not handed to the pre-claim read-back → the same six; the recorded name not judged by the caller-name contract → the protocol-spelling and wildcard cells; the listable pool's sentence for an unlistable pool → E8 and the `backlog1377` ⑤ / `backlog1432` ④ cells; the child's label not minted from the structure → E4. #1947 F (26 cells in the same file, real Runner, scripted model): F1 the main form (declared `FENCE`, mounted `Delegate`, a factory injecting `Fence`, a definition's `denyTools: ["FENCE"]` ⇒ refused at prepare, zero model turns, `Fence` ran 0, the sentence names both names), F2 the same form under its declared name starts and `Fence` runs once, F3 the default name `Agent` and a custom declared name `Delegate` start, F4 renamed with a root that only speaks is refused all the same, F5 the nested copy at depth 2 is named as the tool declares (the grandchild starts), F6 renamed with `TaskSpec.agents` is refused before the rebuild (the same sentence) and the unrenamed control starts, F7 the default name under another and a case-only rename are refused, F8 a renamed copy that inherits every member (`Object.create`) is refused too, F9 a tool the factory did not make is not read, F10 the declared name is bound to the factory's `execute` and follows its copies, products and shells (a host closure or a bound copy is not followed; no `ToolSpec` member carries the name), F11 one station and one call site, F12 a `defineTool` product renamed at mount is refused and an unrenamed one starts, F13 a `withAgents` that hands back a renamed copy is refused, F14 a parked row resumed with a renamed mount on a store without `reopen` is refused before the claim (the row stays pending, the corrected retry completes), F15 the same on a store with `reopen` (same refusal, same pending row, `reopen` never asked) and the unrenamed control resumes, F16 the limit as it stands (a renamed copy from the tool's own `withAgents` is read only at prepare, after the claim), F17 the stations (the screen asks once, after the row's names; `runtask.ts` hands it `config.tools`), F18 – F22 a mount refused with the member set to `undefined` / destructured away / destructured then through `defineTool` / hidden by a Proxy / reduced to a shell of name, description, parameters, execute, F23 the tool's own `withAgents` returning such a stripped, renamed copy, F24 the limit as it stands (a host closure around `execute` is not followed; a bound copy is another function the same way — probed, not pinned), F25 the registry is one table per process (a second loaded copy of the module reads what the first bound), F26 an occupied slot refuses (construction throws, a run is refused at prepare with the arm named, nothing is swapped for an empty table). #1942 E gains E9 (a resumed leg whose host hands another table — `BASH` added — is refused as its own `TaskSpec.excludeTools`, stamped and unstamped), E10 (`excludeTools: ["agent"]` is refused, near_miss), E11 (the table the tool wrote, handed back, keeps the record: both resume), E12 (a live seat that is not a record: three shapes, `invalid_record`, zero model turns), E13 (the refusal is read before the acquire branches the session: a `resumeAt` run leaves the leaf where it was), E14 (a live record that does not hold for the lists handed spares nothing: the host's own lists are told, a record that holds still spares them), E15 (the contract as it stands: a table handed back in the order the engine wrote it keeps the record; reversed or with an entry repeated it is another table, read as the host's own `TaskSpec` seat), E16 (a row an older worker wrote — the evidence stamped with the digest of the written order, no record — resumed bare on a store without `reopen`: the stamp still matches and the leg starts) and four more damaged-record cells in E6 (an empty definition name, no digest, a non-string digest, an empty digest); E5 and E6 re-expressed (the record carries `lists`; the sentence for a value that is not a record names the digest; E6 now reads `checkpoint.config_refused` with `detail` `{ field: "inheritedGate", code: "config.tool_face_control", reason: "invalid_record" }`). Re-expressed with the channel: F14 / F15 / F17 (the renamed mount on a resume), `backlog1910` U1 – U8, U12, P1 – P3, the non-string member (code `resume.parent_constraint_mismatch` → `checkpoint.config_refused` + `detail`, the unit cells read the structured `{ message, reason }`), `backlog1623` ⑦. Mutations, red cells per fixture (`.gate-logs/car-1947/mutations.md`): the comparison dead → F1, F4, F6 – F9, F12, F13; inverted → every cell but F10 and F11; the seat not minted → F1, F4, F6 – F8, F10, F12, F13; the member left out of the closed set → F8, F10, F12 (and `tsc`); the call moved into the lists judgment → F6, F11; the call reading only the roster as mounted → F11, F13; a tool with no declared name read as a mismatch → F2, F3, F5, F6, F9, F12; a product not carrying the name → F12. The record: the bound check dropped → E9, E10, E14; inverted → E1 – E7, E9 – E11, E14, C2, F2, F6, F12; the live seat's shape check removed → E12, E13; the digest member not required → E6, E12, E13; an empty definition name not refused → E6, E12; the row stamp without the digest → E5, E9 – E11; the telling read from the raw seat → E14; the record bound after the acquire → E13. Member tables extended: `test/backlog126-definetool-axis-carry.test.ts` (carried: the one delegation seat a product keeps), `test/public-knob-liveness.test.ts`, the generated `ToolSpec` field reference (`docs/sdk/04-tools-and-mcp.md`).
204
+ - **Known limits.** KL-1930-a (a typo with no other spelling is kept and told), KL-1930-b (a retired name with no successor: kept in the lists, a hard failure in the policy audit — #1781), KL-1930-c (an unlistable pool keeps the definitions' and delegation tools' lists at the spawning run — kept and told once; the spawned child refuses the near miss against its own roster, #1937; the run's own lists and the policy names refuse at the run itself, KL-1935-b; a bare resume of such a child tells the kept entry once more, as it did when it was spawned — the resumed leg carries no mark that its spawning run told), KL-1930-d (a caller tool wearing a retired name, excluded and also denied by a policy: the audit's retired arm refuses, as before — the new case-twin arm clears a caller's own exact names), KL-1930-e (an inherited parent policy's names are not audited on the child's roster, as before — and the parent's audit cannot see an unlistable pool's tool; with a listable pool the parent now refuses the twin, #1937), KL-1935-a (a row an earlier release parked with another spelling in its ceiling is applied as spelled: the twin hides nothing), KL-1935-b (a root-facing list refuses another spelling even where an unlistable pool might hold the tool: declare `extraToolNames` or spell it exactly), KL-1937-a (closed in this release: a renamed delegation mount is refused at prepare — #1947, and with it #1939), KL-1947-b (a delegation tool whose `execute` is wrapped in a closure, a bound copy or a Proxy of the host's own and stripped of its delegation members is not found: deliberate laundering, the engine does not chase it), KL-1951-a (a renamed copy handed back by the tool's own `withAgents` is read only at prepare, after the resume claim — the mounted roster is read before it), KL-1937-b (an inherited ceiling naming another spelling of a tool only an unlistable pool holds — the spawning run could not see that tool, so the entry was kept as a name nothing answers to — is applied as spelled in the child, where the twin hides nothing and the tool runs; the pool-listable form refuses at the spawning run; #1941). KL-1932-a (a name cleared only by the spawning run's own tool was refused at the child) no longer exists: a delegation tool's own list is read against its own pool at the parent.
205
+
206
+ ### The retired `hooks` seat is refused the way every retired seat is — one table row per owner, one door, one code per owner; `config.mods_invalid` is no longer minted for a retired key (#1923; P2; correction inside 7.52.0 of hard-breaking 1 of the `Hooks` entry below — the word that entry named never shipped; behavior change — open to veto; hard-breaking: none beyond that entry's; codes: `config.mods_invalid` no longer answers a `hooks` key; closed sets: none — `config.deps_retired_key`, `config.spec_retired_key` and `checkpoint.config_refused` are existing words; exports: none; members: none) (@server @cli @client-core @test)
207
+
208
+ - **Before (the tree this entry corrects, unreleased).** The retired `hooks` key had its own door beside the retired-key tables, and the two disagreed. `new Runner({ hooks })` constructed and the run failed `config.mods_invalid` at the first leg's prepare, while `new Runner({ promptProvider })` — a retired key in the table — threw `config.deps_retired_key` at once. A task spec carrying `hooks` failed `config.mods_invalid` where `promptProvider` failed `config.spec_retired_key`. A resume carrying `hooks` threw a bare `config.mods_invalid` (not a member of the resume's config-refusal set) where `promptProvider` was `checkpoint.config_refused` carrying `config.spec_retired_key`. Readings on the base: construction no-throw / `config.deps_retired_key`; spec `config.mods_invalid` / `config.spec_retired_key`; resume `config.mods_invalid` / `checkpoint.config_refused` + `config.spec_retired_key` (`hooks` / `promptProvider`).
209
+ - **Now.** `hooks` is one row in the `TaskSpec` retired-key table and one in the `RunnerDeps` one (one shared sentence naming `RunnerDeps.mods` / `TaskSpec.mods` and the `classic.*` events); the special door and its second reading before the resume's claim are deleted. All three forms answer the retired-key class's own words, identical to `promptProvider`'s: `new Runner({ hooks })` throws `config.deps_retired_key` at construction; a task spec — root or delegated child — fails the leg `config.spec_retired_key`; a resume is refused before the claim as `checkpoint.config_refused` carrying `config.spec_retired_key` (whatever the outcome kind; the row stays pending). An explicit `undefined` is absence, as for every retired key. `config.mods_invalid` keeps every other mint — a registration the kernel cannot take.
210
+ - **Against Claude Code 2.1.296.** Not a Claude Code behaviour: an engine-internal configuration refusal; the retired-key class is this engine's own.
211
+ - **Who breaks.** @server @cli @client-core: a host that still passes `hooks` is refused in this release either way (7.51.x ran it); only the word, and for the deps the moment, change — a reader that maps `config.mods_invalid` to "a `hooks` key is still wired" must read `config.deps_retired_key` (construction) / `config.spec_retired_key` (a leg) / `checkpoint.config_refused` + `detail.code` (a resume) instead, and `new Runner` now throws where the run used to fail. No closed set moves: the two retired-key words and the resume's refusal are words the shared door already throws (`config.spec_retired_key` is already a member of `CheckpointConfigRefusalCode`). @test: the pins below are the black-box set.
212
+ - **Pins.** `test/retired-hooks-key-one-door.test.ts` (10 cells: C construction + sentence; S root and child; R four outcome kinds pending + the deps half; P parity of `hooks` and `promptProvider` over construction / root / child / resume and `undefined` as absence; M a malformed registration is still `config.mods_invalid`, and no special `hooks` door is left in src). Re-expressed (old → new meaning): `test/mods-classic-stations.test.ts` three cells (`RunnerDeps.hooks`: run fails `config.mods_invalid` → `new Runner` throws `config.deps_retired_key`; `TaskSpec.hooks`: `config.mods_invalid` → `config.spec_retired_key`; resume: bare `config.mods_invalid` → `checkpoint.config_refused` + `detail.code` `config.spec_retired_key`, the row pending), `test/mods-classic-redo.test.ts` H1 (child leg `config.mods_invalid` → `config.spec_retired_key`) and H2 (pre-claim `config.mods_invalid` → `checkpoint.config_refused` + `config.spec_retired_key`). Mutations, red cells: `hooks` deleted from the deps table → C1, C2, P1, R2 and the stations deps cell; deleted from the spec table → S1, S2, R1, P1 and the stations spec and resume cells.
213
+
214
+ ### A frontmatter fence is a whole line: an indented `---` no longer closes the block, and a block that opens and never closes is refused by name — one judge for the skills loader and the memory entry parser (#1841; behavior narrowing — open to veto; **deviation from Claude Code 2.1.296 runtime parse (stricter), aligned with its strict form `y6`**; closed sets: none; exports: none; members: `ParsedEntryFile.unclosed?: true`, additive and optional) (@cli @client-core @test)
215
+
216
+ - **Before.** Both parsers compared `line.trim()` to `---` for the opening and the closing fence, so an INDENTED ` ---` (a rule inside a YAML block scalar, a continuation of a value, a line the memory format carries verbatim in `extra`) closed the block early and every key after it fell silently into the body. A block closed only by `----` / `--- x` was a different silent shape: the skills loader reported "no frontmatter", and the memory parser read the file as a bare note.
217
+ - **Now.** One predicate (`isFrontmatterFence`, `src/core/frontmatter-fence.ts`, a leaf with no imports): a fence is a WHOLE line — `---` flush left, then spaces / tabs only, then at most one CR. The skills loader (`parseSkillFile`) and the memory entry parser (`locateFrontmatter`) judge the opening fence and the closing fence by it; `agent-definition.ts` parses no frontmatter, so there are two readers. A skill file keeps its tolerance of a leading byte-order mark (it was the trim that swallowed it; it is now stripped before the opening fence is judged); the memory opener now answers to the same predicate as its closer (CRLF-opened and `--- `-opened files parse; an indented first `---` still does not).
218
+ - **A block that opens and never closes is refused by name, never read as "no frontmatter" and never read loosely.** Reading it as bare text would drop what the block declared (a skill's `disallowed-tools` is a silent loosening; a memory file's marker is hidden and a fresh id is minted over the id it carries), and falling back to the loose reading would be a second reading of the same fact. Skills: the existing `no_frontmatter` warning, detail `SKILL.md opens with --- but is not closed by a whole-line ---`, the skill is not installed. Memory: `parseEntryFile` reports the fact (`unclosed: true` beside `hadFrontmatter: false`), and the harvest answers it with the existing rejection code `invalid` (the branch that already refuses an oversize file), reason `memory file opens with --- but is not closed by a whole-line --- …`, nothing minted, the file left on disk; the rejected file keeps its committed baseline (repairing the fence and editing on the same handle commits the edit); the same refusal stands at the other two admission seats, the remote data plane (`harvestFilesToPatches`: rejection `invalid`, no update over the committed entry, no minted id) and a held entry's release (disposed `discarded`, never committed as an all-body note). A COMMITTED entry whose closing fence is damaged this way is withheld from the read plane (it reads as id-less) until the author closes the block, and nothing is deleted. One sentence (`UNCLOSED_FRONTMATTER_REASON`) at both readers.
219
+ - **A tool list is read whole or the skill is refused.** In a skill, `allowed-tools` / `disallowed-tools` with an INLINE value (plain, quoted, a closed flow sequence) followed by indented lines nothing reads (`allowed-tools: Read` then ` ---` then ` * Bash`) used to copy the first value and drop the rest silently — a `disallowed-tools` that lost its tail is a looser skill. It was refused by #1841 (`no_frontmatter`, the detail names the key); a plain scalar is now read whole instead — its indented lines fold into the value, as YAML reads them (#1849), and of the entries they yield `---` is no name, so that skill is refused by name (#1862) — and a closed quoted value or a closed flow sequence followed by more indented lines is still refused. A block scalar or a multi-line flow sequence consumes its own lines and is copied as written (#1862: a block scalar's `---` line is refused the same way); a `*` that reaches a list that way is read by the grant table as it always was (a `*` on an allow list voids the list, on a deny list it reads as nothing by itself — KL-1303-c, KL-1372-a), never as every tool. In a memory file the same lines stay inside the block as verbatim `extra`, typed as nothing.
220
+ - **Against Claude Code 2.1.296** (`cli296.js`, byte offsets into that file). Its plain parse `Pv` (@11655075, `/^---\s*\n([\s\S]*?)---\s*\n?/`) closes at ANY `---` after the opener — an indented one, `----`, `a---b` — and CC itself names that reading a hazard (`the closing --- is ambiguous (a value containing "---"?)`, @11655668, inside its parse function `si` @11655185). Its strict whole-line forms are `y6` (@11655109, `/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/`, used by its ambiguity check and rewrite path), `nn` (@28467881) and `$g` (@39554364, `/^---[ \t]*$/m`). sema reads the strict form: stricter than CC's runtime parse, equal to CC's own strict form; the fence rows are run against both regexes in the pin. Reasons: CC lists the loose reading as a hazard; a `---` line inside a YAML block scalar is legitimate content and the loose reading loses data; one judge for both readers.
221
+ - **Who breaks.** A stored file that relied on an indented or glued closer. **Skills:** a `SKILL.md` closed by ` ---` / `----` / `--- x` stops loading — it was read wrongly before (closed early, or at the indented line) and is now announced `no_frontmatter` with the sentence above; a skill with a quoted or closed-flow tool list followed by indented lines stops loading likewise (it loaded with a shortened list before; a plain list followed by indented lines is read whole, #1849, and refused when an entry it yields is no name, #1862). **Memory:** a file closed that way stops parsing (no id read); its next harvest answers `invalid` instead of minting a second entry over it. Both are from silent-wrong to loud-refused; close the block with a line that is exactly `---`. cli / client-core: a shell that mirrors the reading follows the same predicate byte for byte (flush-left `---`, then `[ \t]*`, then an optional CR); test: the rows in the pin are the black-box set.
222
+ - **Pins.** `test/backlog1841-frontmatter-fence.test.ts` (97 cells): A the predicate table (19 rows) and its agreement with `y6`, with `Pv` recorded where it differs; B skills closing / opening rows, a block scalar carrying an indented `---`, trailing blanks, a lone CR on the last line, a byte-order mark (F1); C memory closing rows, the opener (F2: CRLF / trailing blank accepted, indented refused, a marker in a CRLF-opened file is SEEN), serialize → parse closure with fence look-alikes in `extra` (F3); G the unclosed fact at both readers and through a real harvest (five closers; a committed entry damaged, then repaired with and without an edit on the same handle), a held entry's release, and the remote data plane; H the tool-list continuation (both keys, three value shapes), the consumed block scalar / flow sequence, the grant table reading `*`, and the memory `tools:` lines kept as `extra`.
223
+
224
+ ### A skill's tool lists take list values only: a YAML block sequence is read as the list it is, and a value the reader cannot read as a list refuses the skill — one reading for `allowed-tools` / `disallowed-tools`, wherever the value begins (#1845, #1849; P1; behavior change — a silent drop becomes a read or a loud refusal, open to veto; closed sets: none; exports: none; members: none) (@cli @client-core @test)
225
+
226
+ - **Before.** The skills loader (`parseSkillFile`, `src/core/skills-directory.ts`) read a tool list only from an INLINE value (plain, comma, quoted, flow sequence) or a block scalar. A YAML block sequence — the key with no value, then ` - name` lines — was read as no list at all and the skill installed with no warning: `disallowed-tools:` / ` - Bash` installed a skill that denied nothing, `allowed-tools:` / ` - Read` one that pre-approved nothing. The fence rule above made the shape reachable for skills that used to be skipped as headerless (a `metadata:` block scalar carrying a `---` before `name`: the new reading installs it, the old one never did), so a newly installed skill could silently lose its declared refusals while the model kept the tool.
227
+ - **Now.** The two tool-list keys take LIST values only, and each is read whole or the skill is refused. Inline values and block scalars are read as before, except as the #1849 bullet below says. A block sequence is read as the list: an empty value (or one that is only a `#` comment) followed by `- name` lines under the key — indented, or flush left with it (both are YAML's block sequence) — all at the first line's indent, blank and comment lines allowed among them; quotes dropped, a trailing ` #` comment dropped, and each line's item cut into entries like every other value since #1862 (`- Read Grep` is two entries, as in Claude Code; this entry first read it as one, never split again — a rule spelling keeps its spaces and commas inside its parentheses either way); the sequence ends at the next flush-left key or the closing fence. Refused instead (`no_frontmatter`, the detail names the key and says why, never quotes the file): a mapping, a number or a boolean as the value, a flow-sequence or block-sequence item that is a number, a boolean, null, a mapping or a sequence (Claude Code drops such an item silently; a dropped deny entry is a looser skill), a line under the key that is not a `- name` entry, entries indented differently (an indented entry among flush-left ones, or the other way round), a dash line glued to its name (`-Read`), an entry that is YAML structure rather than a name (nested, mapping, flow, anchor, alias, tag, block scalar, an unterminated quote, an empty item), any flush-left line that is not a key or a dash line inside the sequence (the entries after it would be lost with it), and indented lines behind a value whose shape cannot run over lines (a quoted value, a closed flow sequence, a scalar a comment ended — the #1841 case for those shapes) — one sentence for all of them (`` `disallowed-tools` has a value the reader cannot read as a list (…) ``), one record per key. A key with no value (nothing under it but the next key, a comment or the closing fence) and an explicit `null` / `~` are YAML's null, and null is an empty list — as the key's absence, and as Claude Code reads it (C-R487): the skill installs with no list and nothing is announced; `[]` and `""` keep installing with no list too. A `*` entry is copied like any other and read by the grant table as it always was (KL-1303-c, KL-1372-a). Keys that are not tool lists keep skipping their indented bodies, a block sequence included. The two lists are kept as lists from the moment they are read (the loader no longer carries them as scalar text to a later split).
228
+ - **One reader of the value, wherever it begins — seven more shapes (#1849; P2; behavior change, open to veto).** Seven holes were separate readings or silent drops; all seven now go through the tool-list reader's one entry point. That reader was one only at its entry: inside it four tokenizers (the comment stripper, the flow-item cutter, the text splitter, the bracket counter) each kept their own rules — the source of the rows #1862 fixes; since #1862 one scanner reads every form.
229
+ - **Comments.** A `#` comment ends the value in every shape (YAML's rule: a `#` at the start of the text or after a space / tab, outside a quote; a quote opens only where a scalar begins). `allowed-tools: Read, Grep # never add Bash here` used to pre-approve Bash — the inline value kept its comment as entries while the block-sequence entry already dropped it; a multi-line flow sequence drops comments per line (a comment-only line adds nothing); a `#` glued to a name (`mcp__a#b`) or inside a quote is text. Superseded by #1862 and #1866: a ` #` inside a rule's parentheses is a comment as anywhere (as this entry first read it, and as YAML does; #1862 read it as the rule's text and #1866 withdrew that — an unquoted `Bash(echo a # b)` now refuses, its parenthesis left open, where this entry read `Bash(echo a`); a quote opens only where a YAML scalar starts, so a quote that follows a plain word is text and the entry that keeps it is refused, and a value that starts with a quote is ONE quoted scalar — the list of quoted names (`"Read", "Grep"`) this entry read is refused (text after a closing quote); a flow sequence (`["Read", "Bash(echo # x)"]`) is the spelling.
230
+ - **A value that begins on the next line.** `allowed-tools:` then ` Read` (or a quoted, flow or block-scalar value there) is the same value as `allowed-tools: Read`; the "empty value means a sequence head" arm is gone — only a dash line, or nothing at all, is a block sequence. A plain scalar folds its indented continuation lines into itself wherever it begins (`allowed-tools: Read,` then ` Grep` is `Read, Grep`; the #1841 case `allowed-tools: Read` then ` ---` then ` * Bash` read the four entries a block scalar of the same lines gives — since #1862 both refuse, `---` being no name). The scalar ends at the next flush-left line, at a comment line, or at a line a comment ended; a line of its own that is `key: value` is a mapping, not a list, and is refused.
231
+ - **A repeated key.** A tool-list key written twice is refused by name (YAML refuses a repeated key); the second list used to be dropped silently (the first occurrence won). Every other key keeps first-wins.
232
+ - **A stray line.** A flush-left line that is no key behind a tool list's value (`allowed-tools: Read` then `Bash`) is refused by name; it used to be skipped silently.
233
+ - **Quotes.** One decoder for every quoted scalar the loader reads (an inline value, a flow item, a block-sequence entry, `name`, `description`): `''` in a single quote; `\"`, `\\`, `\t`, `\n`, `\xNN`, `\uNNNN`, `\UNNNNNNNN` and YAML's other single-character escapes in a double quote; an unknown escape stays as written; a value is decoded once. The block-sequence refusal of entries with escapes is gone with it (they decode). A tool value whose quote does not close on its line was refused here; since #1862 a quoted scalar runs over lines until it closes, as YAML reads it, and one that never closes is refused. A decoded `\t` or `\_` inside an entry refuses the list since #1862 (no separator cuts them). `description: "say \"hi\""` is now `say "hi"` (it was `say \"hi\"`).
234
+ - **Null.** A key with no value (nothing under it but the next key, a comment or the closing fence) and an explicit `null` / `~` are YAML's null, and null is an empty list — the skill installs with no list and nothing is announced, as Claude Code reads it (C-R487; the first version of this entry refused a bare empty header, which is withdrawn).
235
+ - **Garbage.** A value that is a mapping (`{a: 1}`, `{}`, a `key: value` line under the key), a number or a boolean (`5`, `-1.5`, `0x1F`, `.inf`, `true`), one that starts with an anchor, alias, tag or other mark a plain scalar cannot start with (`&x [Bash, Write]` — the words would be copied as junk entries and the list lost; a lone `*` is still the wildcard entry), or a list item that is a number, a boolean, null, a mapping or a sequence (flow or block) is refused by name: Claude Code drops it silently, and a dropped deny entry is a looser skill. A quoted `"5"` or `'true'`, a word like `5x` or `1.2.3`, and several words (`5 6`) are strings and read as written.
236
+ - **Against Claude Code 2.1.296** (`cli296.js`; byte offsets are positions of the matched text, nothing was run). Its skill / plugin validator says `allowed-tools must be a string or array of strings` (@2068813) and describes the deny key as a `Comma-separated string or YAML list` (@3519629), and it reports `YAML frontmatter failed to parse` for a malformed block: CC reads a real YAML list, so a block sequence is valid input there. The validator reports a wrong-typed value and a non-string array item as errors and says nothing about a null (`h!==void 0&&h!==null`, @36154896); the neighbouring "At runtime this value is dropped" (@2068654) is the `description` message. The runtime reader of a skill's `allowed-tools` is `ib` (@18728638 calls it; @15787912), over `Ygt` (@15787564): absent / null is no tools, a falsy value is none, a string is one string, an array is its string items (a non-string item is dropped silently), any other type is an empty list; every string is then split on commas and spaces outside parentheses (`Lu`, @7089046) and a `*` collapses the list to `*` alone. So CC drops a bad value and keeps the skill; sema follows CC on a null (an empty list, C-R487) and is STRICTER than CC where the value is garbage — a mapping, a number, a boolean, a non-name item — which it refuses loudly (a divergence, open to veto); since #1862 also where CC keeps an entry that matches nothing (a tab, a no-break space or a leading quote in it, a `key:` item, an unclosed parenthesis) or reads past an unclosed `[` / quote, all refused loudly — "only on garbage" was the claim of this entry and is no longer the whole list. sema keeps its closed subset reader (no YAML dependency) and answers every list shape with a read or a refusal. One difference is left as it was: CC splits each ARRAY ELEMENT too (`Lu` runs over the array's strings), so a block-sequence entry `- Read Grep` is two names there and one entry here (an inline value and a flow sequence go through sema's text splitter, which cuts on commas and white space outside parentheses; CC's `Lu` cuts on commas and the space character only — a tab stays inside a name there); not changed in this entry — changed by #1862 (every item cut on commas and the space character; a tab inside an entry refused).
237
+ - **Who breaks.** A stored skill whose tool list was a block sequence: it now takes effect — a `disallowed-tools` block sequence now denies what it names (it denied nothing before), an `allowed-tools` one now pre-approves what it names, so a skill that looked inert starts asking for (or refusing) tools. A stored skill with a bare `allowed-tools:` or `disallowed-tools:` (an empty value, nothing under it) installs with no list, as it always did. A key with lines under it the reader cannot take as entries is refused, announced `no_frontmatter` with the key named. This narrows what loads (open to veto): sema refuses where Claude Code's runtime drops the value and keeps the file, only for a value that is garbage (a mapping, a number, a boolean, a non-name item) or lines nothing placed — chosen because a dropped `disallowed-tools` is a looser skill than the one its author wrote. #1849 narrows and widens further: a skill that repeats a tool-list key, leaves a flush-left line that is no key behind a value, leaves a tool value's quote open, writes a mapping, a number or a boolean as the value, or puts a number, a boolean, null, a mapping or a sequence among the items is now refused (all were installed, with a list dropped, cut short or holding junk entries); a `null` / `~` value installs with no list; a skill that wrote a `# comment` after a tool list no longer lists the words of the comment (an allow list no longer pre-approves a tool the comment mentions, a deny list no longer denies one); a plain value's ` #` inside parentheses ended the rule (#1862 read it as the rule's text; since #1866 it ends the value again and the parenthesis it leaves open refuses the skill); quote escapes in a tool value, a `name` or a `description` now decode; and the next-line and continuation-line shapes that were refused (or read as junk entries) are read — the quoted-token shapes this entry also read are refused since #1862. cli / client-core: a shell that previews a skill's lists from the same files must read a block sequence the same way (entries per line, same refusals); test: the cells in the pin are the black-box set.
238
+ - **Pins.** `test/backlog1845-tool-list-block-sequence.test.ts` (287 cells): A the shape that lost its deny list (a block scalar carrying `---` before `name`, then a block-sequence `disallowed-tools`) installs and puts Bash in the deny table, the `allowed-tools` twin, both keys, and block sequence = inline list at the grant table; B shapes read (quotes, four-space / tab indent, CRLF, comments and blanks, a header comment, rule spellings kept whole, a line is one entry, `*`); C thirty-five not-a-list shapes × both keys (each refused by name with a sibling skill still loading), both keys bad, a repeated bad key, the quoted-value continuation sentence, refusal text free of file content; D every pre-existing list shape and every non-tool key unchanged; E a `#` comment ends the value in every shape (and the grant table sees the list the author wrote); G a value on the next line, a folded plain scalar, mapping / comment-line / unclosed-quote refusals, eleven spellings of one list read alike; H nine repeated-key shapes × both keys; I eight value shapes × both keys followed by a flush-left stray line; J quote escapes in an inline quote, a flow sequence and a block sequence read alike, decoded once; K a key with no value, `null` or `~` installs with no list (ten shapes × both keys, no grant row, the other key untouched), a header followed by `- Read` is still the list, a mapping / number / boolean / indicator-led value / non-name item is refused (twenty-eight shapes × both keys) while what only looks like a number is a string; F the weakest spots each with its counterexample (which item shapes count as a name; an empty value is null — an empty list — and only a stray flush-left word after the header refuses, a flush-left sequence is the list; where the sequence ends; where a comment starts; where a multi-line plain value ends; what the repeated-key refusal does not touch). `test/backlog1841-frontmatter-fence.test.ts` H: an inline value with ` ---` / ` * Bash` below it is now one folded scalar (the entries a block scalar gives — re-expressed by #1862: both refuse on `---`); a quoted value or closed flow sequence with indented lines below it still refuses. `test/nuia-baseline.json`: `src/core/skills-directory.ts` leaves the ratchet (baseline 18, 26 counted on the stage tree, 0 now: every line and match-group read is a checked read); the #1845 pin file counts 0 too, so it needs no row. Two older pins that held a bare `allowed-tools:` to install with no member (`test/rb424-skills-loader-hardening.test.ts` MCPSK-1, `test/backlog1303b-loader-and-surface.test.ts` ①) were re-pointed at the explicit empties (`[]`, `""`) by the first version of this entry; the bare empty installs again (K), and both pins hold either way.
239
+
240
+ ### A skill's tool-list value is read by ONE scanner: one cut into entries for every form, a quote only where a scalar starts, and a quote / `[` that does not close refuses the skill — in any key; the deny list's `disallowedTools` alias is read; a key is matched without the blanks before its colon (#1862, #1861, #1854, closed by #1866–#1869 in the next entry; P1; security; behavior change — a narrowing (more shapes refused loudly) and a widening (the alias, the spaced key, multi-line quoted values, block items cut), open to veto; closed sets: none; exports: none; members: none) (@cli @client-core @test)
241
+
242
+ - **Before.** The tool-list reader of #1845 / #1849 was one only at its entry point: inside it a comment stripper, a flow-item cutter, a text splitter and a bracket counter each had their own idea of quotes, parentheses and whitespace (three definitions of whitespace, two indicator tables). Seven readings followed, each reproduced through `createSkillsFromDirectory`: `allowed-tools: [Read, Bash(gh pr comment --body "see #12")]` took ` #12")]` for a comment, never closed its sequence and swallowed the next line — the `disallowed-tools` key under it — as allow entries (deny: none, no warning); an unclosed `[` did the same with no parenthesis involved; `["Bash, Write"]` read `"Bash` / `Write"` while the inline `"Bash, Write"` read two names and the block item `- "Bash, Write"` one; a no-break space was text to the comment rule and a separator to the splitter (`Read,␣Grep␣#␣never␣Bash` with no-break spaces granted `#`, `never` and `Bash`); `[Read, WebFetch: Bash]` passed `WebFetch:` as a name; `[Bash, Write], Edit` read `[Bash` / `Write]` / `Edit`; `disallowed-tools : Bash` was no list; and another key's unclosed `[` (`argument-hint: [file`) swallowed the tool key under it.
243
+ - **Now.** ONE scanner (`scanValue` and its `EntryCutter`, `src/core/skills-directory.ts`). The forms differ only in how the value's lines are gathered — the value after the colon or on the next indented line, with the indented lines that continue it, up to the next flush-left line that is not blank, a comment or a `]` (so a flush-left `key:` line always starts a new key); each `- ` line; each line of a block scalar's text (a literal one's lines as written, a folded one's after YAML folds them — corrected by #1876: this said every line was cut on its own) — and every gathered text goes through the same state machine. A quote opens where a YAML scalar starts (the value, a flow item, a block item; anywhere else a quote character is text), may run over lines, and is decoded once; a `[` at the value's start opens the flow sequence; a `#` after a space or a tab, or at a line's start, is a comment — never inside a quote (inside a rule's parentheses too, since #1866: the first version of this entry read it as the rule's text). Every scalar is cut into entries at commas and the space character outside parentheses (a depth-counted zone: `Bash(echo $(date) now)` is one entry; a parenthesis the rule grammar escapes, `\(` / `\)`, counts for nothing, so `Read(docs/a\) Write b)` stays one entry and never grants `Write`; since #1866 `Bash(a)(b)` and an entry with a `)` and no `(` refuse, an entry's parentheses being one group that closes it — the first version installed `Bash(a)(b)` and read a lone `)` as text); a tab is no separator. Line breaks inside a scalar fold as YAML folds them, in a plain value and in a quoted one alike: the blanks around a break go, one break is a space, a blank line between is a line break of the text (so the entry holding it is refused), and an escaped blank (`\ `) before a break stays. An entry is a rule spelling or the list is unreadable — since #1866 judged by the rule syntax itself (`ruleSpellingOf`: an ASCII tool name as written, then at most one parenthesized rule), which refuses a tab, a no-break space, a line break or an invisible character in a name, a name led by a YAML indicator, and a trailing colon; the lone `*` is a spelling, copied like any entry, no collapse to `*` alone (the first version of this entry judged entries by a hand-written name test of its own). YAML's typing of a value or an item keeps its #1849 reading (a null value is an empty list; a number, a boolean or an item typed as null refuses). Text after a closing quote, `]` or comment refuses; so does a quote or `[` that does not close, in ANY key's value (`description: "…` never closed, `argument-hint: [file`), named in the `no_frontmatter` detail (`` `argument-hint` has a value the reader cannot read (its `[` does not close …) ``). A block scalar's lines are already scalar text: they are cut, and no quote or comment is read in them. A key is the text before its colon without the blanks in front of the colon, for every key; a flush-left line that is ONE quoted scalar and a colon is a key too (`"disallowed-tools": Bash`, decoded by the same decoder, as YAML reads it), and a U+2028 / U+2029 / lone CR on a key line no longer stops it being one. `disallowedTools` is the deny list where `disallowed-tools` is absent or null (#1854; since #1867 a null hyphen key — empty, `null`, `~`, a comment only — is the key's absence, so the alias is read; the first version of this entry let it shadow the alias); a hyphen key with a value (`[]` and `""` included) is the list and the alias is not looked at, except that an unclosed quote or `[` in it refuses as in any key. No alias for `allowed-tools`. The reader has fewer rules (not fewer lines — the module is longer, its JSDoc says more): four tokenizers become one scanner; the two indicator tables and the hand-written name test become the rule syntax's one test (#1866); blanks are read by one definition (`isBlank`, which every blank test and trim in the module calls).
244
+ - **Against Claude Code 2.1.296** (`cli296.js`, byte offsets, read not run). The runtime reader `Ygt` (@15787564) takes a string as one item and an array as its string items, then `Lu` (@7089046) cuts each item on `,` and the space character outside a parenthesis zone — a BOOLEAN zone (`(` opens it, `)` closes it) — and a `*` collapses the list; the frontmatter is YAML-parsed, re-parsed with leading tabs turned to spaces on failure (`tXt` @11656597), and dropped on a second failure. Aligned: the cut (commas and spaces, parentheses kept whole; flow items and block items cut alike, #1861); the deny alias (`jde` @16305645: `e["disallowed-tools"]??e.disallowedTools`, #1854 — aligned on a null hyphen key only since #1867: `??` falls through on YAML's null, and the first version of this entry did not); the spaced key (YAML trims a plain key); YAML's structure inside a rule's parentheses (a ` #` comment, a flow's `,` / `]`, a `: ` mapping — since #1866; the first version of this entry read a `#` there as the rule's text, KL-1862-a, deleted). Divergences (open to veto): the zone counts depth (`Bash(echo $(date) now)` is two entries there) and refuses parentheses left unmatched; `*` does not collapse (KL-1861-b); tab-indented block items are read, as CC's fallback reads them (KL-1861-a); each line of a literal block scalar (`|`) is cut on its own, where a folded one (`>`) is folded first as YAML folds it — `disallowed-tools: >` / ` Bash(echo` / ` a)` is `["Bash(echo a)"]` (KL-1862-b, corrected by #1876); and entries CC keeps to match nothing (no rule spelling: a tab or a no-break space in a name, a leading quote, a `key:` item, parentheses unmatched), and values CC's YAML rejects and then drops (an unclosed `[` / quote, text after a closed quote), are refused loudly here. An installed deny list therefore holds every entry YAML and the cut give its value: the #1866 pins mutate deny lists over 1500 generated skills and find none installed without an entry the mutation did not touch.
245
+ - **Who breaks.** A stored skill whose block items hold several words now has several entries (`- Read Grep` was one entry no rule reads; it now grants Read and Grep). A skill with a tab or a no-break space inside an entry — written, or decoded from `\t` / `\_` — is refused (it installed with an entry no rule reads). A list that relied on a quote after a plain word or after a closed quote (`Read, "Grep"`, `"Read", "Grep"`, `"*", mcp__*`) is refused: write a flow sequence (`[Read, "Grep"]`, `["*", mcp__*]`). An entry starting with a YAML indicator (`---`, `-x`, `"x`, `#x`, `*name`) and an inline `Read: x` are refused. A `#` inside a rule's parentheses stayed in the rule in the first version of this entry; #1866 withdrew that (an unquoted one now refuses — never cut short, never gaining an entry). A multi-line quoted tool value is read (it was refused); a blank line inside a multi-line plain or quoted value now refuses (#1849 read the words around it as entries; YAML reads a line break inside one entry — use `- ` lines or a flow sequence, both of which allow blank lines between items). A `disallowedTools`-only skill now denies what it names; a key written `disallowed-tools : X` or `"disallowed-tools": X` is now the list. An entry holding a zero-width space, a byte-order mark or a control character is refused. Any key whose quote or `[` never closes on its lines refuses the skill (it installed, a key below it possibly swallowed); `argument-hint: [a] [b]` keeps loading (KL-1849-b; since #1866 a `(` left open inside such a value no longer hides its `]`, the former KL-1862-c). Left to #1863: the other keys' values are still read by the line reader (KL-1849-b). cli / client-core: a shell that previews a skill's lists must cut block items like inline values and refuse the same shapes; test: the cells in the pins are the black-box set.
246
+ - **Pins.** `test/backlog1862-tool-list-scanner.test.ts`: R the seven review rows through the public loader (all RED on the pristine base); S the splitting table, 21 forms × both keys; P the parenthesis zone (nested, back to back, a `#` and quotes inside it, over a line break, escaped parentheses, unclosed in five forms × both keys); T tab, no-break space and invisible characters; L key lines over U+2028 / U+2029 / CR, blank-line folding; A the alias (alone, shadowed, shadowed but unclosed, an empty hyphen key, repeated, no allow alias); K the spaced key and the quoted key; W `*`; F flows (a quoted `]` / `#` over lines, comment lines, nesting, an empty quoted item, a line after `]`); Q where a quote opens, escaped blanks before a break; B block-scalar lines; O other keys; Z1 large values (200000 entries in one block item; a rule folded over 400000 lines; 300000 unterminated keys — linear time measured, not asserted); Z 3000 seeded generated skills (1000 clean spellings read exactly; 2000 with a mutated allow list refused, or holding only scanned names with the deny list beside it intact — moved to the #1866 pins and extended to deny lists, its oracle now the rule syntax). Cells whose meaning #1866 / #1867 / #1868 changed are re-expressed there (listed in the next entry). Re-expressed: `test/backlog1845-tool-list-block-sequence.test.ts` B, E, G (including a blank line inside a plain value, now refused), J, F1, F4; `test/backlog1841-frontmatter-fence.test.ts` H; `test/rb410-skills-directory-loader.test.ts` (whitespace split); `test/backlog1303b-loader-and-surface.test.ts` ① (the `"*"` entries move into a flow sequence). Mutations, each red on its cells: no quote state, no parenthesis zone, a boolean zone, `]` closing a quote, a key line swallowed, a cut on tab, a comment inside the zone, the no-break space as a blank, no indicator test.
247
+
248
+ ### A skill's tool-list scanner reads YAML's structure first and judges every entry by the rule syntax: a parenthesis unmatched either way refuses, a comment and a flow item end inside a rule's parentheses too, a null `disallowed-tools` yields to its alias, a scalar key keeps its characters unless it is one quoted scalar, and an indented key under a plain value refuses (#1866, #1867, #1868, #1869; P1 / P1 / P2 / P2; security; behavior change — a narrowing (more shapes refused loudly; a tool name outside the rule syntax reads as nothing in every list; a session's rules whose `toolDeny` holds such an entry refuse every call) and a widening (the alias on a null key, a block item over lines, a flow value of another key no longer hidden by a `(`), open to veto; closed sets: none; exports: `createSessionRulePolicy`'s `onEarlierGrammar` hook gains the bucket and the row's grammar; members: `SESSION_RULES_GRAMMAR` 1 → 2) (@server @cli @client-core @test)
249
+
250
+ - **Before.** Four readings of the #1862 scanner were wrong, each reproduced through `createSkillsFromDirectory`. (#1866) The zone refused an unclosed `(` but read a `)` with no `(` as text, so `allowed-tools:` / ` - "Read(docs/legacy) Write notes.txt)"` installed `Read(docs/legacy)`, `Write` and `notes.txt)` — the author's one rule became an unrestricted `Write` (a deny list alike: `Bash, Write)` installed `Write)`); inside parentheses a flow's `,` / `]` and a `#` after a blank were text, so `disallowed-tools: [Read(./secrets, Write, Edit)]` installed ONE `Read` rule and denied neither `Write` nor `Edit`, and a comment holding a `)` reopened the cut (`allowed-tools: Bash(echo "(") # :) Write` granted `Write`); and the hand-written name test refused only control and format characters, so `Bash` followed by U+3164 (a filler) or U+FE0F (a variation selector) installed a deny row that matches nothing. (#1867) A null `disallowed-tools` (empty, `null`, `~`, a comment only) shadowed `disallowedTools: Bash` — no deny list, no warning. (#1868) `description: 'Don't use this for builds'` lost a character (`Don' use…`): the value was judged whole-quoted by its first and last character and the decoder dropped the character after the lone `'`. (#1869) `description: X.` then an indented ` disallowed-tools: Bash` (one space, two, or a tab) was skipped — no deny list, no warning.
251
+ - **Now.** YAML's structure is read first, as YAML reads it, inside a rule's parentheses too: a `#` after a space or a tab ends the value, a flow sequence's items end at `,` and `]`, and a `:` followed by a blank, a line break or the plain scalar's end is a mapping mark. The parenthesis zone works only inside one scalar's text: nothing cuts inside it (depth-counted; `\(` / `\)` count for nothing), and an entry's parentheses must be ONE group that closes the entry — unmatched either way, or followed by more text (`Bash(a)(b)`, two rules joined by a tab or a no-break space), the skill is refused with one clause (`an entry's parentheses are not one group that closes it …`). Every entry is then judged by the rule syntax's own test, `ruleSpellingOf` (`src/core/permission-rule-syntax.ts`), which is now the one home of the tool-name rule — `readRuleEntry`, the reader of every list (request / deployment `allowedTools`, a skill's lists, session rule buckets, sub-agent denied lists), asks it too, and its old name pattern in `allow-entry.ts` is gone: a tool name, AS WRITTEN (the lenient parser's trim no longer reads a byte-order mark, a no-break space or a tab before the `(` as nothing — `Read<BOM>(*)` read as the whole `Read` tool in every list), of ASCII letters, digits and `_ - . : *`, starting with a letter, a digit or `_` and not ending in `.` / `:` (or the lone `*`), then at most one parenthesized rule whose `(` closes at the entry's end. The name's characters are those the MCP / A2A mint makes (`normalizeNameSegment`, `[a-zA-Z0-9_-]`) plus `.`, `:` and `*`. An entry that is no rule spelling refuses the skill; the detail names the key and the entry's place (`entry 3 is no rule spelling (…)`). What a rule's parentheses hold — a `)` of a path included, which keeps a person's stored path rule readable in every list — is not judged by the syntax or the loader: the grant table reads it and announces what it cannot read. A null `disallowed-tools` (empty, `null`, `Null`, `NULL`, `~`, a comment only, a quoted key with nothing after it, `~` on the next line) is the key's absence and `disallowedTools` is read; a hyphen key with a value is the list (`[]` and `""` are values) and the alias is not read — only its quote or `[` left open refuses, as in any key. A scalar key's value (`name`, `description`, …) is decoded only when it is ONE quoted scalar — the scanner's own closing quote is the value's last character — and is kept exactly as written otherwise. A plain value of any key followed by an indented `key:` line (a bare or quoted key, its colon followed by a blank or nothing) refuses the skill naming both keys; a `:` glued to text (`https://…`) is no key. A block item runs over the lines indented deeper than its dash: a quoted item until it closes (`- "Read,` / ` Grep"` is two entries; it was refused with a clause that said its quote did not close), a plain one folded. The unclosed-flow clause no longer mentions a `(` the value may not have. Blanks are read by one definition in the module.
252
+ - **Against Claude Code 2.1.296** (`cli296.js`, byte offsets, read not run). `jde` (@16305645) reads `e["disallowed-tools"]??e.disallowedTools`: YAML's null falls through to the alias — now here too. `Lu` (@7089046) runs over each string YAML gives, so a `#`, a flow `,` / `]` and a `: ` inside parentheses are YAML's there as here; its zone is boolean and installs an entry holding an unmatched `)` (`Write)`), which here refuses (a divergence, open to veto, KL-1861-b / KL-1861-a / KL-1862-b list the others). CC's YAML rejects a plain value followed by an indented `key:` line and drops the frontmatter; here the skill is refused by name.
253
+ - **Who breaks.** A rule holding a ` #`, a `: ` or — in a flow sequence — a `,` / `]` inside its parentheses must be quoted whole (`["Bash(git status, git diff)"]`, `- "Bash(echo a # b)"`, `'Bash(echo a: b)'`); unquoted, the skill is refused (the first version of #1862 installed it, reading the parentheses' text past what YAML reads). An entry with a stray `)` (`Write)`, `notes.txt)`, a lone `)`), `Bash(a)(b)`, or two rules joined by a tab or a no-break space refuses. A tool name outside the rule syntax — a character outside ASCII letters, digits and `_ - . : *` (a filler, a variation selector, a look-alike letter, a backslash, a `#`, an apostrophe), a leading `-`, `.`, `:` or a `*` that is not the lone `*`, a trailing `.` or `:` — refuses the skill at load, and now reads as `grammar` in **every** list: the request's and the deployment's `allowedTools` (announced, grants nothing), a session's `toolAllow` / `toolDeny` (a write holding it is refused; a stored row holding it: see **Stored session deny rows** below), a sub-agent definition's denied list (`config.agent.unknown_tool` at start); a caller tool registered under such a name is refused at prepare (#1878, below). The same spellings in an allow list grant nothing now (`Read<BOM>(*)` granted the whole `Read` tool). An allow list's wildcard veto keeps its reading: what the lenient parser names `*` (`* (*)`, `*` then a tab or a byte-order mark before `(`) still voids the list, although the entry itself is now no rule spelling — a stricter entry reading never un-voids a list. A `disallowed-tools:` with no value beside `disallowedTools: X` now denies X (it denied nothing). A scalar value that starts and ends with a quote but is not one quoted scalar is kept as written, quotes included (`'Don't …'`; `description: "It's a "quoted" word"` was stripped of its outer quotes). A plain value followed by an indented `key:` line refuses (the line was skipped, the inner key lost). Widened: `argument-hint: [file (optional]` loads (it was refused; KL-1862-c deleted with KL-1862-a); a block item continued on deeper lines is read (`- Read` / ` more` was refused); an invisible character inside a rule's parentheses no longer refuses the skill at load — a control or format character there is refused by the rule's grammar at the grant table (an allow entry is announced, a deny entry keeps the skill from activating), and a tab, a no-break space or a filler there is read as the rule's text (KL-1866-c: `Bash(rm<NBSP>-rf:*)` denies only that spelling). Narrowed, documented: a multi-line flow whose items are flush left is refused (KL-1866-a). server: a session write and a deployment's `allowedTools` holding such a name now read it as `grammar`; cli / client-core: a shell that previews a skill's lists must read the same structure and judge entries by the same syntax; test: the cells in the pins are the black-box set.
254
+ - **Stored session deny rows.** A session's rules whose `toolDeny` holds an entry no rule grammar reads cannot be read, and refuse every call until they are rewritten — never read as fewer. "No rule grammar reads" is the entry reader's word `grammar` in the grammar the row carries: a name outside the rule syntax (`-deploy`, a name outside ASCII, `Bash` followed by a variation selector), a byte-order mark / no-break space / tab before the `(`, an empty entry. An entry the grammar reads that names nothing — a name no tool carries, `*`, a tool with no rule form (`Deploy(prod)`) — still refuses nothing, the rest of the bucket refusing as written. The rule grammar is 2 (`SESSION_RULES_GRAMMAR`, was 1): an entry this grammar reads as no rule in a row an earlier grammar wrote (stamp 0 or 1) is `earlier_grammar` in either bucket (grammar 0's own reading of its parenthesized and `*` allow entries is unchanged), announced as `config.session_rules_earlier_grammar` once per distinct set per layer and now per bucket, naming the session, the bucket, each entry with the reader's word (`toolDeny "-deploy" (grammar) was stored in rule grammar 1`), and the way out: an operator write of a new value without the stored row's rev, leaving the entry out — the write judge refuses an entry it cannot read, and handing the stored row back is refused `inert_entry` / `earlier_grammar`. Every refused call is told, with no stored text, `the session rules cannot be read as rules (they hold a toolDeny entry that rule grammar 2 reads as no rule) — every call is refused until they are rewritten`. In `toolAllow` the same entry admits nothing and the rest of the list is read as written — except that the move is an entry's reading, never the list's: an allow list a `*` voids, in any spelling the voiding test reads as `*` (`* (*)`, a tab or a byte-order mark before the `(`), stays voided in a row stamped 1 or 2; in a grammar-0 row such a spelling is one entry that reads as nothing, as a bare `*` is there (grammar 0 compared it as a name; 7.51.x and the first version of this entry voided the list — open to veto); a refusal under that allowlist ends `; the session's rules are stored in rule grammar 1, where 1 entry is no rule in this engine's rule grammar (2) and admits nothing`. The tighten comparison (`loosenReasons`) reads the same verdict: over such rules a non-operator write is `loosen_forbidden` (`the prior rules refuse every call — their toolDeny holds "-deploy" (grammar), which no grammar reads`), and such rules as the next value loosen nothing; the published safety-merge corpus moves with it (`inert-deny-entry-removed-safe` loses its empty entry; `unreadable-deny-prior-loosened` and `unreadable-deny-next-safe` added). Rules no store stamped (a plain rules object handed to `createSessionRulePolicy`) refuse every call the same way, with no earlier-grammar announcement (the run's policy audit names the entry). `createSessionRulePolicy`'s `onEarlierGrammar` is now `(entries, field, grammar)`: a callback that reads only `entries` still type-checks and now also hears `toolDeny` sets. A plugin's agent definition is read the other way (an unreadable entry disables that definition only): its user cannot edit it, while a session's rules are written by the session's own operator and can be rewritten. Who breaks: @server — a durable backend storing the judge's answer now stores `grammar: 2`, and an engine before this version reading that row refuses it (a grammar newer than it reads) and its layer refuses every call, so every replica reading the store moves to this version before any writes through it; a one-time stamp migration that reads only an unstamped row's `toolAllow` before stamping it `grammar: 2` (server's M17, `oldGrammarEntriesOf` — the premise "grammars 0 and 1 differ only in `toolAllow`" does not hold for grammar 2) leaves a row whose `toolDeny` holds such an entry refusing every call but takes away its `config.session_rules_earlier_grammar` announcement — ask `unreadableDenyEntries` (exported by #1876) before stamping, and leave such a row unstamped or name it; a stored row (stamp 0 or 1) whose `toolDeny` holds such an entry — on 7.51.x it refused what the lenient name read named (`Read<BOM>(*)` the whole `Read` tool, `-deploy` a tool of that literal name) — now refuses every call for its session until an operator rewrites it; center: a merger running the corpus follows `loosenReasons` (one vector changed, two added). Pins: `test/backlog1866-session-deny-fail-closed.test.ts` F1–F7 (14 cells; 13 RED on the pristine base, F3b green on both, F3a red there only for the missing `unreadableDenyEntries`, F2c only for its grammar-0 arm; F1d a row stamped 2 written past the judge: refused, no earlier-grammar advisory, the policy audit naming the entry); re-expressed `test/backlog1761-rule-row-grammar-version.test.ts` ④ (a later grammar is `SESSION_RULES_GRAMMAR + 1`, no longer the literal 2), `test/backlog1790-grammar0-star.test.ts` A2b (a padded `*` in a grammar-0 allow bucket: `grammar` → `earlier_grammar`), A3c / A6 (the clause and the advisory take the row's grammar and bucket), `test/backlog1866-scanner-closure.test.ts` R7 session reach (`grammar` → `earlier_grammar` at stamp 1, unreadable at both); `test/safety-merge-corpus.test.ts` through the corpus. `test/backlog1474-rule-entry-reader.test.ts` T1a / T1b (an empty or padded deny entry no longer leaves silently: its rules refuse every call, an operator removes it). Mutations, each red on its cells: no fail-closed branch (F1a / F1b / F1c / F1d / F5a), the stamp left at 1 (F1a / F1b / F2a / F4a / F5a / F6a / F7a, the #1866 R7 session reach), no earlier-grammar generalization (F1a / F1b / F1c / F2a / F2b / F4a, #1790 A2b, the #1866 R7 session reach), no tighten clause (F5a / F5b, #1474 T1a / T1b, both new corpus vectors), the earlier move un-voiding a list in a stamped row (F2c).
255
+ - **Pins.** `test/backlog1866-scanner-closure.test.ts` (64 cells; 51 RED on the pristine base — 3 of them on the refusal clause only, the base refusing in its own words — and 13 controls green on both): R1 X1–X10 (an unmatched `)` in a quoted block item, a plain value, a flow item, a plain block item, a deny list, a lone `)`; balanced nesting and a quoted balanced rule stay entries; `Bash(echo ")")` refuses where the reference's boolean zone keeps one entry, and its escaped spelling reads; an escaped `\)` grants no `Write`); R2 A1–A9 and M20 (the alias read under each null spelling of the hyphen key, the grant table denying Bash), a valued hyphen key (`Write`, `[]`, `""`) shadowing, a null hyphen key leaving the alias's own refusal standing, a shadowed garbage alias ignored and an unterminated one refusing; R3 F1 / F4 / B1 / B2 / B5 / B10, and `argument-hint: [file (optional]` loading; R4 B3 / B4 / B6 / B7 / C1 / C3 refused, never a gained entry, a quoted item keeping its `#`, a comment after a closed rule stripped; M a `: ` inside parentheses; R5 E1 / E2 kept as written, whole quotes still decoded; R6 D1–D3 (one space, two, a tab), a quoted indented key, a tool key's plain value, and two controls (a URL continuation, a nested mapping under an empty value); R7 a filler, a variation selector and a zero-width space refused by the entry's place, the syntax home's spelling table, the name read as written (a byte-order mark / no-break space / tab before `(`, in every list), parentheses' content left to the content grammar in every other list (a session's stored path deny holding a `)` keeps denying) while the loader's cut refuses it, the wildcard veto (`* (*)` still voids an allow list), control characters in a name and in parentheses, two rules glued by a no-break space / filler / tab, KL-1866-c pinned as it stands, the mint's names (a server and a tool named outside ASCII), and one reach pin each for an allow list, a session deny bucket and a sub-agent denied list; P3 a block item over lines, the unclosed-flow clause, flush-left flow items (KL-1866-a), a glued dash; Z 4500 seeded skills (1500 clean, 1500 with the allow list mutated, 1500 with the deny list mutated): clean ones read exactly, mutated ones refused or holding only rule spellings (the oracle is `ruleSpellingOf`), and no installed deny list lacking an entry that lies before the first mutation. Re-expressed (old → new meaning): `test/backlog1862-tool-list-scanner.test.ts` R1 (a ` #` in a flow item's parentheses: the rule's text → a comment, the unclosed `[` refuses; the quoted item reads), the S flow row (`[Bash(a, b) Read]` → `["Bash(a, b) Read"]`), P back to back (`Bash(a)(b)` one entry, `Read)` installed → both refused), P `#` in the zone (the rule's text → refused unless quoted), P escapes (`Bash(a\\) b)` two entries → refused), P unclosed (`[Bash(echo, Write]` the flow clause → the parentheses clause), T tab (`Bash(a\tb)` refused → read), A empty hyphen key (shadows the alias → the alias is read), O (`argument-hint: [file (optional]` refused → loads; a quoted description stripped of its outer quotes → kept as written), the clause strings, Z (moved here); `test/backlog1845-tool-list-block-sequence.test.ts` B (`Bash(echo "a: b")` and `mcp__a#b` installed → quoted / refused; a deeper line under an item refused → folded, its C row removed), J (`it's`, `aAA/b`, `a\db`, `a\xZZb` as names → inside a rule), K (`[Bash(a,5,b), Read]` read → refused, the quoted item reads), F1 (`- Bash(echo a #b)` read → refused), F4 (`mcp__a#b` → `Bash(a#b)`; `Bash(echo 'a #b') Read` read → refused), the clause strings; `test/backlog1841-frontmatter-fence.test.ts` H (the clause). Mutations, each red on its cells: the zone lifting comments (R4 ×4, #1862 R1 / P, #1845 F1 / F4); a flow `,` / `]` inside parentheses read as text (R3 ×6, #1862 P ×2 / O, #1845 K ×2); the cut accepting a `)` at depth 0 (R1 ×7, #1862 P ×2 — on the clause only: the name head refuses those entries too); the cut not refusing text after the closing `)` (glue, back to back, the parentheses-in-other-lists cell, the control-character cell, Z); the name head admitting `)` (the syntax home's cell); the syntax accepting a downgraded parse (the home's cell, #1327 ×4); the name read after the lenient trim, with no as-written check (the as-written cell); the wildcard veto back on the entry grammar (the veto cell); the loader skipping the syntax (61 cells); the head back to the old Unicode class (49 cells, the three reach pins among them); a null hyphen key shadowing the alias (R2 ×11, #1862 A); `unquote` by the first and last character (E1 / E2, #1862 O); no indented-key check (R6 ×4); no mapping mark (M, #1862 R5, #1845 B / C / G ×4 / K ×2); a block item read as one line (P3, #1845 B).
256
+
257
+ ### Every reader of an entry list asks the one test of a rule spelling: the Skill card binds what the list reader reads, and a sub-agent's denied lists, the tripwire baseline, a name set and the rule DSL compiler read a tool name as written (#1875, #1870; security; behavior narrowing — open to veto; hard-breaking: none; closed sets: `PermissionRuleIssue.code` +1 `invalid.tool_name` (an exported type: an exhaustive switch over it gains a case), `DeniedEntryReading` +1 `unreadable` (engine-internal; its readers: `sub-agent-deny-lists.ts` — the list judgment, the workflow leg's reading, the layer screen — and the selector's reach in `tool-filter.ts`); exports: none; members: the Skill card's `boundGrants` lists gain one spelling family, `<reads as nothing (<word>): "<entry>">`) (@server @cli @client-core @test)
258
+
259
+ - **Before.** #1866 made `ruleSpellingOf` the test of a rule spelling and `readRuleEntry` asked it, but five other readers still decided by the lenient parser, which trims a name and reads a malformed parse as a whole-string name. So one spelling had up to three answers. The Skill card's binding spelled `Bash<TAB>(*)`, `Read<BOM>(*)` and `" Bash"` as `Bash` / `Read` and digested them as those tools, while activating the skill granted nothing for them. A parked card for `allowed-tools: ["Bash<TAB>(*)"]` therefore covered a resume whose definition had become `["Bash"]` — the run-wide `Bash` grant installed on an approval whose card granted nothing (it had covered both on 7.51.x, when both granted `Bash`). A sub-agent's denied entry named `Bash` / `Read` for those spellings and `" Bash"` / `-deploy` as written. The tripwire's drift baseline held `Bash` for `Bash(a)(b)` and `Bash<TAB>(*)`. A name set (`createAllowDenyPolicy`) kept `Bash<TAB>(*)`, `" Bash"` and `-deploy` raw — a deny that denies nothing. The rule DSL (`validatePermissionRules` / `createPermissionRulePolicy`) compiled `-deploy`, and `Bash` followed by U+FE0F / U+3164 / U+200B, clean — a deny or ask that never fires — while `" Bash"` and `Read<BOM>(*)` refused `Bash` / `Read` under the lenient reading.
260
+ - **Now.** Each reader asks the question it has, of one reader. The Skill card binds each list as the list reader reads it at activation (`compileAllowList`, before any roster). A rule keeps its canonical spelling (`Tool` / `Tool(content)`; the digest of a list whose every entry is a rule is byte for byte the 7.51.x one). An entry the reader reads as nothing is spelled `<reads as nothing (<word>): "<entry>">` — the reader's word, then the entry as the notice writes it, uncapped. Every entry of an allow list a `*` voids carries `wildcard_all`. A member that is not a string keeps its type label. Two lists now bind one digest only when the reader reads them alike, and a label written into a list is itself read as nothing and wrapped again. A sub-agent's denied entry the syntax cannot read is `unreadable`: the judgment before the run refuses it on either side (`config.agent.unknown_tool` / `config.tool_face_control`, the `grammar` sentence). That includes a blank, a tab, a byte-order mark, a no-break space, U+3000 or a filler between the name and the `(` (`Echo ()` was a rule form granting nothing on an allowed list and the whole `Echo` on a denied one), and a name ending in `.`. Every layer of the run's selector is read so at prepare, whoever judged it: a ceiling a checkpoint row restored, and the run's own lists carrying the spawning run's evidence. A readable `Tool(content)` still names `Tool` there and hides the whole tool (`Bash(a)(b)` hides `Bash`; its content's grammar is not the selector's — open to veto). The tripwire's baseline is the names the allowed-tools rows grant. A name set refuses an entry the syntax cannot read (`config.invalid_tool_name_set`, lesson "a spelling no tool name can have"). The rule DSL refuses a rule the syntax cannot read: an empty name and malformed parentheses keep `invalid.empty_tool` / `invalid.paren`, and a name outside the syntax's spelling is the new `invalid.tool_name`.
261
+ - **Against Claude Code 2.1.296.** No new divergence. The name test and its reading as written are #1866's. This entry extends it to the remaining readers of a list.
262
+ - **Who breaks.** @server @cli: a surface that renders `boundGrants` shows the new label family for an entry that reads as nothing (render it as "reads as nothing", never as a tool). A server keying a remembered answer on `boundGrantDigest` keeps every digest of a list of rules. A parked Skill card whose lists hold an entry that reads as nothing is refused at the resume's claim (`checkpoint.invalid_outcome`, "… no longer bind to the digest this Skill approval was asked under — its definition changed, or this engine reads one of its entries differently than the engine that approved it …"; the row stays pending) when the resume's definition reads differently. Before, it passed the claim when the lenient readings matched: #1372's `Probe ` case moves from the execution boundary to the claim. A Skill approval parked by 7.51.x on a skill whose lists hold an entry that reads as nothing (a `*` included: `["*"]`, `["Read", "*"]`, `["WebSearch(x)"]`, `["Deploy(prod)"]`, `["Bash(a)(b)"]`, `[" Read"]`, `["Read<TAB>(*)"]`, deny `["*"]`, deny `["Bash(a)(b)"]` — each of these digests differently from the 7.51.2 digest, and every list of rules alike) is refused the same way after the upgrade, its definition unchanged (the digest binds the reader's reading, and 7.51.x digested such an entry as a tool name): deny the row and re-activate the skill — the new card is approved again. A list whose every entry is a rule digests byte for byte as in 7.51.x, so its parked approvals resume. @server: after the upgrade, a parked delegated child whose row records a ceiling — or own lists judged upstream — holding such an entry (an earlier engine read `Read<TAB>(*)` as `Read`) resumes into a leg refused at its prepare: `config.tool_face_control`, naming the lists (`the lists of the run this leg was spawned under declares tool "Read\t(*)" (grammar) …`). The row stays pending; correct the list and resume again. A sub-agent definition, a delegation tool's lists or a run's own `allowTools` / `excludeTools` holding `Echo ()` or `Read<TAB>(*)` now refuse the run. `createAllowDenyPolicy` throws on `" Bash"`, `Bash<TAB>(*)`, `-deploy` and `mcp__peer__.` (it kept them). `createPermissionRulePolicy` throws on `" Bash"` / `Read<BOM>(*)` (they denied `Bash` / `Read`) and on `-deploy` / a name with an invisible character (they compiled and denied nothing). `-deploy` is no tool name in any list, a name set included. In a name set, `onInvalidName: "throw"` (the default) refuses the policy loudly; `"skip"` drops the entry and reports it through `onInvalidNameIssue`. On 7.51.x a deny naming a tool mounted as `-deploy` denied it by its raw name; in this version no caller tool mounts under such a name (#1878, the next entry), so a dropped entry frees no tool. `Tool()` / `Tool(*)` in a name set and on a sub-agent's allowed list: read as the bare `Tool` since #1880 (the r9 closure entry below).
263
+ - **Pins.** `test/backlog1875-one-entry-reader.test.ts` (60 cells; 46 RED on the pristine base d69d1ada8 — KL-1866-b's name-set cell among them, pinned as it stands — and 14 green on both — nine controls, the golden digests and ②c among them, and five cells where the base already answered the same way: `Bash(a)(b)` in the sub-agent / name-set / DSL readers and its parked card, and the `-deploy` tripwire cell). ① five spellings × five readers. ② the parked card refused at the claim and at the execution boundary, and on the real lane (park, swap the definition, resume as a person: refused before the claim, the row pending, `Park` never runs), with a control and ②c — the same lists and a roster that changed across the park, refused at the boundary. ③ the golden digests minted on the base, the label not forgeable, the `*`-voided list, the non-string label. ④ six separators between the name and `(` in a sub-agent's denied list, and ④b every selector layer screened whether or not judged upstream. ⑤ the rule DSL's name head, seven spellings and the old codes' controls. `test/backlog1623-tool-selector.test.ts` ⑦ an older engine's row whose ceiling holds `Read\t(*)`: the resumed leg refused, the row pending. Re-expressed (old → new meaning): `test/backlog1372-skill-deny-unreadable.test.ts` ④ (refused at the execution boundary → at the claim, the digests now differ); `test/backlog1377-subagent-deny-names-one-judgment.test.ts` ⑩ (`Edit `, ` Edit(src/**)`, `Bash(` read as a whole-string name → `unreadable`) and ⑭'s control; `test/backlog1736-unknown-tool-name-lazy.test.ts` U4 (`Echo ()` a rule form granting nothing / the whole `Echo` denied → refused `grammar` on either side; the rule-form cells use `Echo()`); `test/policy-name-audit.test.ts` (`mcp__peer____.` / `mcp__peer__.` left to the audit → refused by the screen; the audit cell reads `mcp__peer__a.b`). Mutations, one per reader put back on the lenient reading, each red on its cells: the binding (15 — ① ×5, ② ×6, ③ ×3, #1372 ④), the denied entry (18 — ① ×4, ④ ×8, #1736 U4, #1377 ⑩ ×3 and ⑭, #1623 ⑦), the tripwire baseline (4), the name set (5, the audit cell among them), the DSL's name head (11), the layer screen removed (3 — ④b ×2, #1623 ⑦).
264
+
265
+ ### A caller tool's every name is one a rule can name: the mount refuses a name or alias no list could write, so an entry a name set drops can no longer free a mounted tool (#1878, closing KL-1866-b and the remaining half of #1389; P2; security — the upgrade fail-open of the previous entry closed at its root; behavior narrowing — open to veto; **hard-breaking**: a deployment that mounts a caller tool or alias named outside the rule syntax, or named `*`, is refused at prepare; closed sets: none — `config.tool_name_invalid` keeps its code and widens its meaning; exports: none; members: none) (@server @cli @client-core @test)
266
+
267
+ - **Before.** The mount refused a caller tool's name or alias only for `__`. Every list reads a tool name through the rule syntax's name head — ASCII letters, digits and `_ - . : *`, starting with a letter, a digit or `_`, not ending with `.` or `:` — so a tool mounted as `-deploy` (the provider pattern admits a leading `-`), `检索`, `deploy.` or `*` could be named by no list. With the previous entry, a name set in `onInvalidName: "skip"` mode dropped `deny: ["-deploy"]` and only reported it: a deployment that changed nothing could call a tool 7.51.x had denied. The same held through an alias (a rule naming an alias reaches the whole tool, so `deny: ["-x"]` on a tool `deploy` aliased `-x` was dropped) and for a name outside ASCII (`deny: ["检索"]`). A session-rule store's `mountedToolNames` was judged for `__` only.
268
+ - **Now.** Every name a caller tool answers to — its `name` and each of its `aliases` — keeps one contract, judged in one place (`callerToolNameProblem`): a name a rule can name (the rule syntax's own name head, and not the lone `*` — the wildcard reaches every tool and names none) and no `__`. One that breaks it refuses the run at prepare, before session acquire and before any turn: `failed`, `config.tool_name_invalid`, the sentence naming the name (or the alias and its tool) and the spelling a name may have. A session-rule store's `mountedToolNames` is judged by the same contract at construction. A protocol-minted name (`mcp__<server>__<tool>`, `a2a__<peer>__<skill>`, segments normalized to `[A-Za-z0-9_-]`) always keeps it. The name set's `skip` mode is unchanged; what it drops is now a spelling no mounted tool has. The selector's note that a tool named `*` cannot be the sole allowed tool is retired: no tool is named `*`.
269
+ - **Against Claude Code 2.1.296** (`cli296.js`, byte offsets, read not run). A name an extension registers — a mod's `command.register` and its neighbours — is judged by `plt=/^[a-zA-Z0-9_-]{1,64}$/` (@10663077), and a tool the meta-mcp bridge injects by the same pattern (@32097338): a leading `-` is admitted there, a `.`, a `:` and a `*` are refused, and a name holds at most 64 characters. The contract here is the rule syntax's name head (#1866), the reverse at both ends: a leading `-` is refused, a `.` / `:` / `*` inside a name is admitted, and the length bound is the roster's (128, `TOOL_WIRE_NAME_MAX_CHARS`). A divergence, open to veto (registered as `caller-tool-name-contract`): every name that mounts must be one every list can name, and the name head's `.` / `:` are the protocol mint's characters (#1866). Whether the contract should also hold to the providers' pattern is #1898 (unwindowed; such a name mounts and a provider that refuses it fails at the first model call).
270
+ - **Who breaks.** @server @cli @client-core: a deployment whose `spec.tools` holds a tool or alias named with a leading `-`, `.` or `:`, a trailing `.` or `:`, a character outside ASCII (a CJK name, a look-alike letter, a variation selector, a filler), a blank, the empty string or the lone `*` now fails at prepare with `config.tool_name_invalid`. Upgrade path: rename the tool or alias — and the list entries that name it — to the rule syntax's spelling; the model sees the new name. Before, such a tool mounted and no list could grant or refuse it by name. A run parked by 7.51.x whose frozen roster holds such a name (`-deploy`, `检索`, `deploy.`) is refused at the resume's prepare (`config.tool_name_invalid`) and its row stays pending; once the tool is renamed, the parked call names a tool the run no longer has and can never be entered again — the row's only ways out are a deny or a cancel. A session-rule store constructed with such a name in `mountedToolNames` throws at construction (`TypeError`, naming the option and the name). A host that reads `config.tool_name_invalid` as "the name carries `__`" now also receives it for these names (same code, wider meaning). @test: the pins below are the black-box set.
271
+ - **Pins.** `test/backlog1878-mount-name-contract.test.ts` (40 cells; 30 RED on both d69d1ada8 and bc868972d, 10 green on both — the controls and the `__` sentence). U the upgrade regression on a real run: `-deploy` with a skip-mode deny naming it, the same with no list, the alias `-x`; controls `deploy` denied by name and through its alias, and the harness's own call. M nine refused spellings, each as a name and as an alias; the `__` sentence naming every protocol prefix; six admitted names, each mounted, called, and refused by a name-set deny and by a rule deny. S the predicate equals the rule syntax's reading over 24 spellings; the caller judge equals it plus `__`; every protocol-minted name over 12 hostile segments × both namespaces keeps the contract. D the store's declaration (six refused with the mount's sentence, a control). Upgrade reading — a skip-mode deny naming the tool, the model calling it: d69d1ada8 denied it (ran 0); bc868972d ran it (ran 1, the entry dropped) for `-deploy`, for the alias `-x` (called by its name and by the alias) and for `检索`; now the run fails at prepare (ran 0). Re-expressed (old → new meaning): `test/tool-roster.test.ts` G5 (a caller tool named `*` mounted beside `excludeAllTools` → refused at prepare; the flag's cells ride a tool named `Probe`); `test/backlog1156-legacy-tool-name-session-rules.test.ts` structure (the two deployment-name doors called `carriesNamespaceSeparator` → each calls `callerToolNameProblem` once, which calls it once); `test/backlog1875-one-entry-reader.test.ts` the KL-1866-b cell's title (the name set's reading unchanged; such a tool no longer mounts). Mutations, each red on its cells: the mount judging `__` only (21 — U1–U3, M ×18); the predicate on a second pattern without the trailing rule (6 — M `.` / `:` ×4, S1, D `deploy.`); the predicate on the provider pattern `^[A-Za-z0-9_-]{1,64}$` (10 — U1–U3, M `-deploy` ×2, M controls `a-b.c:d` / `a*b`, S1, D `-deploy`, D control); the lone `*` admitted (4 — M ×2, S1, D `*`); aliases unjudged (10 — U3, M ×9); the store's door judging `__` only (5 — D).
272
+
273
+ ### Every frontmatter line no value holds is judged in one place: an indented `key:` line under any value that is not a nested block refuses the skill naming both keys, and a flush-left line that is no key line refuses it naming its line; a lone CR is a line break and a quoted value over lines is kept whole (#1876, #1877; P1 / P3; security; behavior narrowing, and a widening for multi-line quoted values and flows — open to veto; **hard-breaking**: a skill installed since 7.51.x whose frontmatter holds a flush-left line that is no key line is now refused whole, named by its line; closed sets: none; exports: +1 `unreadableDenyEntries` (#1877), none removed; members: none) (@server @cli @client-core @test)
274
+
275
+ - **Before.** #1869 refused an indented `key:` line only under a PLAIN value. Under any other value the line was skipped with no warning, the key inside it lost. Reproduced through `createSkillsFromDirectory`: `description: "Deploy helper."` / ` disallowed-tools: Bash` / `allowed-tools: Read, Write` installed allow `[Read, Write]`, no deny, no warning. The same held for a single-quoted value with a tab-indented key; `argument-hint: [env]`; `metadata: {}`; a `description: |` whose body is indented 4 with the key at 2; a scalar value begun on the next line; and a tool list's quoted, flow or block-scalar value, which was refused without naming the key it hid. The scanner computed the problem for quoted and flow values and the caller threw it away; a block scalar's or a nested block's leftover lines reached a branch that skipped them. Flush-left, `allowed-tools:Read` (no blank after the colon) installed as a list, a stray `oops not a key` line was skipped and the `allowed-tools: Bash` after it installed, and a `disallowed-tools: Bash` line led by a byte-order mark or a zero-width space was skipped. In the session store, the write judge told a `toolDeny` entry no grammar reads that it "refuses nothing as written", though rules holding it refuse every call; and a grammar-0 advisory told `* (*)` (a spelling of `*`) to "spell the tool's name".
276
+ - **Now (one rule, one place).** Every value takes its own lines and no more: the scanner says how many lines of its text a value holds. It is over at the first line that holds anything after a quote or a flow closed or a comment ended it, and at a plain scalar's continuation line that is a key line; a block scalar ends at a less-indented line. Every line no value took reaches one branch of the frontmatter reader. Under a nested block (a mapping or a sequence under a key with nothing on its line: its first line is a `- ` entry, a key line, an explicit `? ` key, or a plain scalar reaching YAML's mapping mark) it is the block's own when it is indented at least as far as the block's first line (`metadata:` / ` owner: x` / ` disallowed-tools: Bash` is refused). A block scalar's header on the line below its key is a header; a refused block item holds only its own lines; inside a flow a mapping mark ends the key's scalar and the value's may begin, so `metadata: {note: "["}` loads; only a value that opens a flow takes a flush-left `]` / `}` (`metadata: {` / ` owner: team` / `}` loads). Under any other value — plain, quoted, a `[` or `{` flow, a `|` or `>` block scalar, on the key's line or the next — a key line refuses the skill naming both keys (`` `description` has a value the reader cannot read (an indented line under its value is a mapping key, `disallowed-tools` — YAML reads no key under a value that is not a mapping) ``), and any other line refuses a tool list. The deny list's alias keeps such a refusal (and an unclosed quote or flow, and a stray line under it) where a valued `disallowed-tools` shadows its list: lost text is not the alias's list. A `{` is a flow bracket like `[`, so `metadata: {a: b,` / ` c: d}` loads and an unclosed `{` refuses like an unclosed `[` (`` its `{` does not close within its lines ``). The block-sequence reader ends at the first line that is no `- ` entry and leaves that line to the same branch. #1869's plain-value lookahead and its plain-only clause are gone: one rule covers every value form, where the patch of the first version covered one.
277
+ - **Now (flush-left lines; hard-breaking, open to veto).** A flush-left frontmatter line is a YAML key line — a bare key (letters, digits, `_`, `-`) or one quoted scalar, then `:` and a blank or nothing — or a comment or blank line, or a flush-left `- ` entry of a nested sequence; anything else refuses the skill `no_frontmatter`, naming the line (`SKILL.md line 4 is no key line (…)`), or, behind a tool list, naming the list it would cut, with the line (`a flush-left line that is no key follows it (line 5)`). The refused shapes: `allowed-tools:Read` and `name:probe` (no blank after the colon), a stray word, a line led by a byte-order mark, a zero-width space or a no-break space, a flush-left `- ` under a scalar value, `allowed-tools:# c`, and the flush-left look-alikes of a fence (`----`, `---x`, `--- x`, `--- # end`, `a---b`, `-- -`, `---` followed by a no-break space or a form feed, a byte-order mark before `---`). `#` comment lines and blank lines pass; a whole-line `---` (trailing blanks allowed) still closes the block. **A skill installed on 7.51.x whose frontmatter holds a flush-left line that is no key line is now refused whole, named by its line.** Re-expressed pins: `test/backlog1841-frontmatter-fence.test.ts` B — the nine flush-left look-alike rows ("does NOT close the block": loaded with the key after it → refused naming line 5, the block not closed there; the fence rows unchanged); `test/backlog1845-tool-list-block-sequence.test.ts` I, last cell ("the same stray line behind a key that is NOT a tool list is skipped silently" → refused by its line).
278
+ - **Line breaks and multi-line quoted values (behavior change — open to veto).** Inside the frontmatter block a line break is YAML's: CR LF, LF, or a lone CR. A lone CR was a character, so `description: Demo` + CR + `disallowed-tools: Bash` kept the deny key inside the description, with no deny list and no warning; now the key after the CR is a key. A refusal still names the file's own line. U+2028 / U+2029 stay characters, as YAML 1.2 reads them. A quoted value over lines (a description, or any other key's) is kept as YAML decodes it over the lines the scanner says it holds: `description: "Deploy` / ` helper."` is `Deploy helper.`. Before, it was cut at its first line, quote included (`"Deploy`), and the skill loaded with that text. **A skill whose description or other quoted value was silently cut that way now reads the whole text.** KL-1849-b's clause on it is retired. A quote whose continuation after a line break is flush left (`"a` / `b"`, or the same with a lone CR) is unclosed and refuses, as its LF form always did. Re-expressed pin: `test/backlog1862-tool-list-scanner.test.ts` L, the lone-CR arm (`Bash` + CR + `Write`: refused for a whitespace entry → refused for a flush-left stray line; `Bash # note` + CR + `x`: installed `["Bash"]` → refused); the U+2028 / U+2029 arms are unchanged.
279
+ - **A folded block scalar's list (documentation corrected).** A literal block scalar (`|`) is cut line by line; a folded one (`>`) is folded first, as YAML folds it: `disallowed-tools: >` / ` Bash(echo` / ` a)` is `["Bash(echo a)"]`. The #1862 / #1866 entries and KL-1862-b said every block-scalar line was cut on its own; that held only for `|`. The code is unchanged.
280
+ - **Session rules' sentences.** The write judge says what an entry no grammar reads does in `toolDeny`: `… holds "-deploy" (earlier_grammar), which refuses every call as stored (rules whose toolDeny holds an entry no grammar reads cannot be read) — a write holding it is refused; nothing was written.` An allow entry still `admits nothing as written`, and a deny entry that names nothing still `refuses nothing as written`. An entry the list reader's voiding test reads as `*` (`* (*)`) gets the `*` way out in the earlier-grammar advisory (`"*" is no allow rule in any grammar …; to drop it …`, or beside others `To allow what the others meant, … leaving "*" out — …`) and in the write judge (`leave the entry out: it spells "*", …`), no longer "spell the tool's name". `unreadableDenyEntries` — THE answer to "can these rules' denies be read?" — is exported from the package root (#1877: server's one-time stamp migration had no engine answer to ask and stamped on its own premise).
281
+ - **Against Claude Code.** Claude Code reads a skill's frontmatter with a YAML parser. YAML's grammar has no key under a scalar or flow value and no non-key line where a mapping key is expected, so a parser following it rejects every shape refused here and the whole frontmatter with it (inferred from YAML's grammar, not run against the reference). Here the skill is refused by name, as #1869 already did for the plain value. Node properties on a key line followed by a nested mapping (`metadata: &m` / ` owner: x`) are legal YAML and refused here (KL-1876-a; #1869 refused them too).
282
+ - **Who breaks.** A stored skill with an indented `key:` line under a quoted, flow, block-scalar or next-line value: it installed with that key lost, and is refused now, naming both keys (re-indent the key flush left, or quote the text). A stored skill with a flush-left line that is no key line (above): it installed, the line skipped; refused now, naming the line. A skill whose `metadata: {…` flow mapping never closes is refused (it loaded). A skill whose multi-line `metadata: {a: b,` / ` c: d}` holds a key-shaped line keeps loading. server: the session store's sentences changed text only (codes, `detail` and judgments unchanged). Before its one-time stamp migration stamps an unstamped row, server asks `unreadableDenyEntries`, or the row loses its earlier-grammar announcement (see #1866's **Stored session deny rows**). cli / client-core: a shell previewing a skill's lists refuses the same shapes. test: the pin cells are the black-box set.
283
+ - **Pins.** `test/backlog1876-indented-key-under-value.test.ts`: 59 cells, 41 RED on the base. The 18 green there are the four plain-value cells #1869 already fixed, a tool list's flow-mapping value, a tool list's next-line value, the legal flows the base never scanned, and the controls: the next key flush left, a URL's colon, block-scalar bodies, nested mappings with bare / numeric / quoted / spaced / explicit `? ` first keys and deeper lines, a compact nested sequence, flows over lines, a quoted value over lines holding `key: `, KL-1876-a as it stands, the fence / comment / blank-line controls, and the unchanged D / E sentences. A — seven scalar-key value forms × an indented tool key or an indented non-tool key, and five tool-list value forms × both list keys, each refused naming both keys. N — next-line values; a nested block's shallower line; the alias seat; a refused block item; a lone CR in all three tool-key spellings; multi-line quoted values; flows closing by their own bracket and holding quoted brackets; a flush-left closer; a block-scalar header below the key; an unclosed `{`. B — `key:value`, a stray word, a byte-order mark / zero-width space / no-break space before a key, a flush-left dash, a glued comment, the list-charged form. C — `>` folds, `|` cuts. D / E / F as above. Mutations, each red on its cells: no key-line judgment under a value (38, the #1869 R6 cells among them); a plain scalar not ending at a key line (14); `{` no flow bracket (4); nothing opening a nested block (5, three #1845 controls among them); everything opening one (2); a nested block holding every indented line (1); a stray line skipped (17, the re-expressed #1841 / #1845 cells among them); a flush-left `key:value` read as a key (2); a refused item skipping its unheld lines (1); a flow's mapping mark beginning no value (1); a header below the key ignored (1); a flush-left `}` closing no flow (1); any value taking a flush-left closer (1); a lone CR as a character (2, the #1862 L CR arm among them); a closer of either kind closing any flow (1); no adjacent value after a quoted key (1); a quoted value kept by its first line (1); the deny sentence back (1); no `*` counted in the advisory (1); `*` read by the entry reader (2); the export removed (1); a folded list cut before folding (1); the alias's lost text dropped under a valued hyphen key (2).
284
+
285
+ ### r9 closure: a tool-list key is read only flush left; a quoted value keeps its `#` lines; the parked Skill refusal says what is true; a name set and a sub-agent's allowed list read `Tool(*)` as `Tool`; the rule DSL refuses `*`; every selector layer is read by one reader, a denied wildcard refused in it (#1886, #1891, #1887, #1888, #1880 name-set half, #1889, #1890, #1901; P1 / P3 / P2 / P2 / P2 / P2 / P3, #1901 as ranked on its ticket; security; behavior narrowing, and a widening for `Tool(*)` / `Tool()` on an allowed list — open to veto; **hard-breaking**: a skill whose nested block — or whose key line after a closing quote or `]` — writes a tool-list key is refused whole, the nested case legal YAML; an allowed `Read(*)` / `Read()` admits `Read` in a name set and on a sub-agent's allowed list (both lanes); a rule-DSL `*` rule is an issue; a denied `*` in a ceiling, in upstream-judged lists or in the workflow lane refuses; closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
286
+
287
+ - **Before.** Seven readings, each reproduced on the 7.52.0 stage before this entry. (#1886) A nested block (a mapping or a sequence under a key with nothing on its line) skipped every line indented at least as far as its first line, so `tags:` / ` - prod` / ` - ops` / ` disallowed-tools: Bash` / `allowed-tools: Read` installed allow `[Read]`, no deny, no warning; so did the same key deeper (` disallowed-tools: Bash` under ` - prod`, under ` owner: team`). (#1891) Text after another key's closing quote or `]` on its line was kept as part of that key: `description: "Deploy." disallowed-tools: Bash` and `argument-hint: [env] disallowed-tools: Bash` installed with no deny list. (#1887) A quoted value over lines dropped its last lines when they started with `#` — a line inside a quote, not a comment: `description: "Deploy` / ` #tag"` read `"Deploy`. (#1888) A Skill approval parked by 7.51.x on a list holding an entry that reads as nothing (`["*"]`, `["Read", "*"]`, `["WebSearch(x)"]`, deny `["*"]`) was refused on resume, the definition unchanged, with a sentence saying the skill "changed after the approval was given". (#1880) A name set kept `Read(*)` / `Read()` as written: `deny: ["Read(*)"]` denied nothing (no throw, no notice; `Bash(*)`, `mcp__srv(*)`, `mcp__srv__t()` alike), and `allow: ["Read(*)"]` removed `Read`. A sub-agent's allowed list matched `Read(*)` / `Read()` as written too (it admitted nothing, told as "grants nothing"). (#1889) The rule DSL compiled `*` / `*(*)` / `*()` with no issue and denied nothing. (#1890) The selector's layer screen read only the denied lists and refused only an entry the rule syntax cannot read: a ceiling layer whose denied list was no list or held `5` / `null` started the leg with no notice. The run's own allowed list holding `Read<TAB>(*)` was refused when this prepare judged it. With the spawning run's evidence (`judgedUpstream`) it passed.
288
+ - **Now (one rule for #1886 and #1891).** A tool-list key (`allowed-tools`, `disallowed-tools`, `disallowedTools`) is read only as a key of the frontmatter, written flush left. Written anywhere else, its list would be lost: in another key's nested block (any depth, a sequence or a mapping, a `- ` entry's key, a quoted key), or after another key's closing quote or `]` on the key's line. Either way the skill is refused `no_frontmatter`, naming both keys: `` `tags` has a value the reader cannot read (it holds the tool-list key `disallowed-tools` — a tool list is read only as a key of the frontmatter, written flush left; anywhere else its list would be lost) ``. Non-tool keys in nested blocks load as before. Text after a closing quote or `]` that writes no tool-list key is kept as written, as before: `argument-hint: [pr-number] [priority]`, `description: 'Don't do it'`. Nothing of a nested block is otherwise read (no YAML is modelled there; #1863 B reads it with a parser).
289
+ - **Now (#1887).** A value that is one quoted scalar keeps the scalar the scanner decoded, to its closing quote over every line it runs: `Deploy #tag`, `Deploy # helper`, `Deploy` + line break + `#tag` (a blank line inside folds to a line break). A comment after the closing quote — on its line or the next — is no part of it: `description: "Deploy." # note` reads `Deploy.` (it read `"Deploy." # note`, quotes and comment included).
290
+ - **Now (#1888).** The refusal of a parked Skill approval whose card's skill no longer digests says what can be true: `the allowed-tools / disallowed-tools of "h", as this resume serves it (one it does not load counts as absent), no longer bind to the digest this Skill approval was asked under — its definition changed, or this engine reads one of its entries differently than the engine that approved it (an entry that reads as nothing) — …`. The judgment is unchanged: the row stays pending at the claim (deny it, then re-activate), and is spent at the execution boundary.
291
+ - **Now (#1880, #1889).** A name set reads each entry as the rule syntax does: `Tool()` / `Tool(*)` are `Tool` (deny denies it, allow admits it; `nameSets` states `Tool`, once however many spellings write it); `Tool(x)` is still refused as a content form; the lone `*` is told apart by list (see the entry after this one). A sub-agent's allowed list is read the same way by the selector (`reachOf`) and by the judgment before the run: `Tool()` / `Tool(*)` admit `Tool`, with no account; `Tool(x)` still admits nothing and is told ("grants nothing"). The two lanes an agent definition's `allowTools` reaches read these entries alike (`Tool()` / `Tool(*)` admit `Tool` in both) and differ where they did before: `Tool(x)` admits nothing and is told on the Agent tool's selector but refuses the leg's launch in the workflow agentType's name set (`a rule CONTENT form`), and a lone `*` admits every tool on the selector but is one name no tool carries in the name set. The rule DSL refuses a rule whose tool name the syntax reads as the wildcard `*` (`*`, `*()`, `*(*)`; deny and ask alike): `invalid.tool_name`, `` `*` names no tool — a rule reaches the tool it names, so this one would compile and never match; name the tool, or state the answer for every call no rule reaches with `defaultAction` ``. An allow rule is still refused for its behavior first (`unsupported.allow`).
292
+ - **Now (#1890).** Before a leg starts, every layer of its selector — each ceiling layer and its own — has both lists, every member, read by one reader. A value that is not a list, a member that is not a string, or a spelling no tool name can have refuses the leg: `config.tool_face_control`, naming that layer's list (`the lists of the run this leg was spawned under …` / `the lists of the run 1 spawn above the run this leg was spawned under …` / `TaskSpec.allowTools …`, in its allowed or denied tools). This holds whatever `judgedUpstream` says (the engine that judged upstream may have read with an earlier grammar). The evidence spares only the question of whether a name is one a tool of the task answers to. A definition's lists and the workflow leg's denied reading go through the same reader. The reader also holds the refusal of a denied wildcard: `*`, `*()` and `*(*)` on a denied list hide nothing, so they refuse (`unsupported_pattern`) in every layer and lane — before, only a literal `*` in a list judged at this prepare was refused, and `*(*)` was only told. The sentences are the ones the list judgment already wrote.
293
+ - **Against Claude Code 2.1.296** (`cli296.js`, read, not run). Its frontmatter parse (`si` → `tXt`) falls back, on a YAML error, to a rewrite (`I`, @11653663, line 242128) that wraps a flush-left key's whole value in double quotes when it holds `[`, `]`, `#`, `: ` and similar. So `description: "Deploy." disallowed-tools: Bash` and `argument-hint: [env] disallowed-tools: Bash` load there as one string each, the deny list lost. Refusing the shape that writes a tool-list key is a divergence (stricter, open to veto); the bracketed hint without one loads alike. `metadata:` / ` disallowed-tools: Bash` is legal YAML (a nested key, no deny list there either), refused here (open to veto, KL-1886-a). A `*` rule: not run against the reference.
294
+ - **Who breaks.** A stored skill whose nested block writes a tool-list key (a mis-indented `disallowed-tools`, or one deliberately nested under `metadata:`) is refused, naming both keys: move the key flush left. So is a skill whose key line writes a tool-list key after a closing quote or `]`: put it on its own line. A skill's description that was a quoted value with `#` lines, or followed by a comment, now reads its whole text, or only the scalar. A name set with `allow: ["Read(*)"]` now admits `Read` (it denied every `Read` call: a widening). So does a sub-agent definition's or a run's `allowTools: ["Read(*)"]`, in the Agent lane and the workflow lane (it admitted nothing: a widening to what the author wrote, open to veto). A name set with `deny: ["Read(*)"]` now denies `Read` (it denied nothing). A ceiling, own lists judged upstream, or a workflow agentType's `denyTools` holding `*` / `*()` / `*(*)` now refuse the leg or the spawn (`unsupported_pattern`; they hid nothing — open to veto). @server: a rule set holding a `*` rule (deny or ask) now throws `config.invalid_permission_rules` at `createPermissionRulePolicy` (`onInvalidRule: "throw"`, the default), or is skipped and reported under `"skip"` (it compiled and matched nothing). Use `defaultAction` for every call no rule reaches. A parked delegated child whose row records a ceiling holding a list that is no list, a member that is not a string, or an allowed entry no rule syntax reads resumes into a leg refused at its prepare; the row stays pending — correct the list and resume again. Sentences only: the parked Skill refusal (above). Codes and `detail` are unchanged.
295
+ - **Pins.** `test/backlog1886-r9-closure.test.ts` (80 cells, the last two added with the entry after this one; of the first 78, 58 RED on the base `975f4c7c3`, 20 green there: 13 controls, the four known limits pinned as they stand, and three cells the base already refused — a tool-list key shallower than its block, and the run's own lists judged here). A — four reported shapes, each tool-list key spelling, a `- ` entry's key, a flush-left sequence and its flush-left entry, an explicit `? ` key, a shallower tool key (the indented-key clause), an explicit key shallower than the block and a `- ` entry's key under a quoted value; controls — nested mappings / sequences / a mapping in a sequence entry / an explicit non-tool key load, a tool list flush left after a nested block is read, a commented tool key. G — after `"…"`, `'…'`, `]`, a later word, a next-line value, a tool list's own value, a block item, a shadowed alias; the scan is linear (24 000 repeated `- ` markers); controls — a comment after the quote, the bracketed hint, `'Don't do it'`, inner quotes, a tool key inside the quote, a shadowed alias's garbage. KL — KL-1886-a, KL-1876-b, KL-1876-c, KL-1887-a, KL-1892-a as they stand. B — three shapes and two controls (a comment line after the closing quote; a `#` comment line inside a flow). C — four forms at both sites, and the byte-for-byte 7.51.x digest of a list of rules. D — `Read(*)` / `Read()` × deny / allow; `Bash(*)` / `mcp__srv(*)` / `mcp__srv__t()` deny; both lanes of an agent definition's `allowTools` (`Read(*)` admits, `Read(x)` does not) and the judgment's account. E — `*` / `*(*)` / `*()` × deny / ask, the allow control. F — eleven shapes × `judgedUpstream` true / false (a denied `*` / `*(*)` / `*()` among them), the workflow leg's read, a ceiling `{ deny: "Read" }` / `{ deny: ["Read", 5] }` refused through the real prepare (#1901: it compiled as hiding nothing, so the parent's refused `Read` reached the child), and the controls. Re-seated pins (old meaning → new): the parked-Skill refusal pins in `backlog1875-one-entry-reader`, `backlog1328-parked-skill-rescreen-swap`, `backlog1303c-park-resume-grants`, `backlog1372-skill-deny-unreadable`, `backlog1390-skill-table-value-one-read` and `backlog1348-card-grant-disclosure` ("no longer declares" → "no longer bind to the digest"). `tool-policy.test.ts` "the bare-name collapse forms" (`Read()` / `Write(*)` kept as literal names → read as `Read` / `Write`). `backlog1845-tool-list-block-sequence` F6 (a tool key under `metadata:` ignored → refused, naming both keys). `backlog1377-subagent-deny-names-one-judgment` ⑪ (two `compileAllowList` sites → one, the workflow helper reading through the one list read). `backlog1736-unknown-tool-name-lazy` (an allowed `Echo()` told "grants nothing" → admits Echo, no account; `Echo(x)` unchanged). `backlog1623-tool-selector` (own `excludeTools: ["*"]` with matching evidence → completed ⇒ refused `unsupported_pattern`; the evidence still spares the name account). `backlog1866-scanner-closure` (`deniedToolNamesOf(["Bash", "*"])` kept `*` ⇒ refused).
296
+
297
+ ### A tool-list key written as a key of a flow mapping refuses the skill like one in a nested block; a name set's deny list refuses the wildcard and holds each name once (#1912 and two findings of the r10 review; P2 / P2 / P3; security; behavior narrowing — open to veto; **hard-breaking**: a skill whose frontmatter writes a tool-list key as a key of a flow mapping, on a key's line or on a line of a nested block, is refused whole; a name set whose deny list holds `*` / `*()` / `*(*)` throws `config.invalid_tool_name_set` (it denied nothing); closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
298
+
299
+ - **Before.** Reproduced on `a533ed5ce` through `createSkillsFromDirectory` and `createAllowDenyPolicy` before this entry. (#1912) `metadata: {disallowed-tools: Bash}` installed with no deny list and no warning; so did the same key in a flow inside a flow (`{a: {disallowed-tools: Bash}}`), over the flow's lines, quoted, as a pair of a flow sequence (`[disallowed-tools: Bash]`, `[{disallowed-tools: Bash}]`), as the value of `description`, and on a line of a nested block (` a: {disallowed-tools: Bash}`, ` - {disallowed-tools: Bash}`). The same key as a block (`metadata:` / ` disallowed-tools: Bash`) was already refused. (name set) `deny: ["*"]`, `["*()"]` and `["*(*)"]` constructed with no throw and no issue, denied nothing, and `nameSets` and the projection stated `*` (only the prepare-time name audit, a host-only advisory, said anything); `deny: ["Read", "Read(*)"]` stated `Read` twice.
300
+ - **Now (#1912 — #1886's rule, one more seat).** A tool-list key is read only as a key of the frontmatter, written flush left; written anywhere else its list is lost and the skill is refused `no_frontmatter`, naming both keys in the sentence the nested block's key gets. The seat added: a key of a flow mapping, or of a pair in a flow sequence, that another key's value holds — a bare or quoted key followed by YAML's mapping mark, at any depth, on the key's line, over the flow's lines, or on a line of a nested block (after its key or its `- `). The scanner reports it once (`flowKey`) and the nested block's line reader asks the scanner about the flow its line writes: a flow that closes on its line is read from it alone, one that does not is read over the lines it runs (as deep as the block, or opening with an indented closing bracket; a flush-left line is the frontmatter's own and is judged there) through a window whose size doubles until the flow closes in it, so the cost is the flow's own text, and the lines it ran over are still read at their heads as before — a key on a continuation line, JSON-style adjacent marks (`"disallowed-tools":["Bash"]`) included, is found. A string that spells a tool-list key and is no key stays a value: a flow sequence's item (`[disallowed-tools]`, quoted or not), a flow mapping's value (`{note: disallowed-tools}`, `{note: "disallowed-tools: Bash"}`), a key with no pair (`{disallowed-tools}`: YAML's null, nothing lost); a comment spells nothing.
301
+ - **Now (name set).** The lone wildcard in a name set's deny list — `*`, `*()`, `*(*)`, every spelling the rule syntax reads as `*` — is refused like any entry that names no tool: `config.invalid_tool_name_set` at construction (under `onInvalidName: "skip"`, dropped and reported through `onInvalidNameIssue`), `` the wildcard — `*` names no tool, and a deny entry reaches the tool it names, so this one would be kept and never match (a deny that reads protective and denies nothing). Name the tools to deny; to leave a run no tools, write an empty allow list. `` In the allow list it stays as it was: one entry no tool carries, the list admits what else it names and nothing for it (`allow: ["*"]` admits nothing, `["Read", "*"]` admits `Read`). A name set holds each name once, in the order of its first spelling: `["Read", "Read(*)"]` is `["Read"]` in `nameSets` and in the projection; the decisions are those of the repeated list.
302
+ - **The two lanes of an agent definition's `allowTools` (documentation corrected).** The entry before this one said the Agent tool's selector and the workflow agentType's name set "agree". They read `Tool()` / `Tool(*)` alike. They differ where they did before: `Tool(x)` admits nothing and is told on the selector but refuses the leg's launch in the name set; a lone `*` admits every tool on the selector (a documented all-admit sentinel) and is one name no tool carries in the name set, so `allowTools: ["*"]` leaves a workflow leg no tool — one fact read two ways, not changed here. A workflow agentType's denied `*` is refused before the name set sees it.
303
+ - **Against Claude Code.** The reference reads a skill's frontmatter with a YAML parser and takes the tool lists from the top-level keys, so a `disallowed-tools` that is a key of a `metadata` flow mapping belongs to `metadata` and the skill loads with no deny list there (inferred from YAML's grammar and from the reader taking top-level keys; not run against the reference). Refusing the shape is the same divergence as the nested block (stricter, open to veto). A name set has no counterpart in the reference.
304
+ - **Who breaks.** A stored skill whose frontmatter writes a tool-list key as a key of a flow mapping (`metadata: {disallowed-tools: Bash}`, or a flow on a nested block's line) is refused whole, naming both keys: it installed with that list lost. Move the key flush left, or quote the text. A name set built with `deny: ["*"]`, `["*()"]` or `["*(*)"]` now throws (or, under `"skip"`, is dropped and reported): name the tools, or write an empty `allow` list to leave a run none. A name set that repeated a name states it once in `nameSets` and the projection, so a consumer comparing the list it passed with the one stated sees the shorter list; a frozen projection carrying a repeat resumes alike (the replay is a membership test). Sentences only: the `names no tool` clause.
305
+ - **Pins.** `test/backlog1912-flow-tool-key.test.ts` (46 cells; 26 RED on the base `a533ed5ce`, 20 green there: the controls, a tool list's own flow value, the allow side, the lanes, the decisions of the repeated list, the known limits as they stand). A — the reported shape and its sentence equal to the block form's; a flow in a flow; each tool-list key spelling, bare, quoted, with a flow value, with no value, first and after another pair; over lines; a flow sequence's pair and a mapping in a sequence; `description`'s value; five shapes on a nested block's line, more on one whose flow runs over lines (JSON-style adjacent marks, a key after a comma, the continuation as deep as the block, a long opening line, thousands of blank or comment lines), the lines a flow ran over still judged at their heads (`- ` entry, explicit `? ` key and complex key), a flush-left closing bracket judged as before; controls — strings and values that spell a key, a key with no pair, comments, flows without a tool-list key, a tool list flush left beside a flow. B — three spellings, one lesson, the skip path, the allow side as it stands, both lists at once, the content-form lesson kept. C — dedupe with order, protocol spellings, the decisions. L — the two lanes' `*` and `Read(*)`. KL — KL-1892-a's continuation form and KL-1912-a as they stand. Mutations, each run against the cells: the flow-key judgment removed at the key line (12 RED), at the nested line (2), the plain-key arm (11), the quoted-key arm (3), the nested flow read over one line only (1), the lines it ran over not read at their heads (1), a flush-left closing bracket taken into the flow (1), the recheck without the one-line flow (1); the deny-wildcard refusal removed (6), widened to the allow list (3), the dedupe removed (2); the nested flow's window removed (the staircase cell below, RED; 6 s at 500 openers). Two cells added to `backlog1886-r9-closure` (a nested block of 6 000 flow lines, and a staircase of 600 nested flow openers each closing on the line below, load in well under a second and the key at their end is found); `flaky-guard` registers that file's wall-clock ceilings in `BUDGET_ASSERT_ALLOWLIST` (the stage's full run had it red: the 64 KB trailing scan's `< 3 000 ms` ceiling, ≥ 100× margin, was undeclared).
306
+ - **Known limits.** KL-1912-a: shapes the two rules do not reach load with the list lost — a key led by an anchor or a tag in a nested block or in a flow, text after a nested line's value or after a flow's plain item, an explicit key's value line, a flow's explicit key — a block scalar's body line, or a quoted value's continuation line, in a nested block that opens a flow writing a tool-list key is refused though it is text (KL-1886-a's over-refusal, widened by the flow rule), and the refusal sentence "YAML reads no key there" is not true of an anchored mapping. Three more shapes load with the list lost (found by the r10c review): a tool-list key in a flow mapping followed by a comment, its `:` on the next line (`metadata: {disallowed-tools # note` / ` : Bash}`); an anchor or a tag on the flow itself (`metadata: &m {disallowed-tools: Bash}`, `metadata: !!map {disallowed-tools: Bash}`, and the same on a nested block's line); a tool-list key written on the line where a nested block's multi-line flow closes, after the closing bracket (the same shape flush left is refused). KL-1892-a gains its continuation form (`argument-hint: env` / ` - disallowed-tools: Bash`). The YAML parser (#1863 B) closes them; the reader gets no further rule.
307
+
308
+ ### A tool name a checkpoint row records is the bare name the rule syntax reads, and a row is read back in that spelling only: a parked child's ceiling and a frozen policy chain are no longer widened, or narrowed, by this engine's reading of an entry an earlier engine recorded; an allowed list's `*()` / `*(*)` is the wildcard (#1910, #1908; P1; security; behavior narrowing, and a widening for `*()` / `*(*)` on an allowed list — open to veto; **hard-breaking**: a row parked by 7.51.x whose ceiling or frozen chain holds an entry not in the bare spelling (`Tool()`, `Tool(*)`, `Tool(content)`) no longer resumes; a restored ceiling's refusals move before the claim (`checkpoint.config_refused`, `detail.code` prepare's own `config.tool_face_control`, `detail.reason` its arm); an allowed `*()` / `*(*)` lifts the allowlist; closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
309
+
310
+ - **Before.** (#1910) A parked child's row recorded the ceiling it was spawned under as the spawning run's lists were written, and the resumed leg read the row with this engine's reader. A run that parked under 7.51.x with `allowTools: ["Agent", "Read", "Bash(*)"]` could delegate but not use Bash (7.51.x read `Bash(*)` on an allowed list as a name no tool has). Its child's row kept `Bash(*)`, and the same row resumed on the 7.52.0 stage read it as `Bash`: the child was offered Bash, the row's lists unchanged, nothing said (`Bash()` alike). (#1908) A frozen policy chain (`inheritedGate.constraintChain`) was replayed by matching each name as recorded. A chain holding `tool_deny: ["Read(*)"]` denied nothing, and `tool_allowlist: ["Read(*)", …]` removed `Read` — while a name set built today reads `Read(*)` as `Read`. A 7.51.x name-set row re-supplied on this engine was refused by the digest sentence, which named no entry. An allowed list's wildcard was the literal `*` only: `allowTools: ["*(*)"]` admitted nothing (no `Agent`, so nothing delegated). A ceiling layer that was not an object (`null`) failed the leg with a bare `TypeError` and no code.
311
+ - **Now (one rule).** The engine records a persisted tool-name list in its canonical spelling, through the one per-entry reading every list reader takes (`toolNameOfEntry`): `Tool()` / `Tool(*)` → `Tool`; a denied `Tool(content)` → `Tool` (it hid the whole tool); an allowed entry that admits nothing (`Tool(content)`) is left out. So the row reaches exactly what the list reached. The ceiling a parked child's row stamps (`CheckpointState.toolCeiling`) is written so (`allowTools: ["Agent", "Bash(*)"]` → `["Agent", "Bash"]`; `["*(*)"]` → `["*"]`). A name set (`createAllowDenyPolicy`) already records its frozen projection's names so. A row is read back in that spelling only — one predicate, `isCanonicalToolName` — before the claim, by one read-back (`persistedToolNamesRefusal`, the resume screen): the ceiling the leg will run under when it is the row's (no live seat hands one), each layer through the one layer reader, and the frozen chain (a `tool_deny` / `tool_allowlist`'s `names`, a `shell_command`'s `tools`). Any other entry refuses the resume: `checkpoint.config_refused` (`detail.code` `config.tool_face_control`, `detail.field` `inheritedGate`, `detail.reason` `unsupported_pattern` / `unknown_name` / `not_a_list` / `not_a_string`), the row pending whether or not the store has `reopen` (`the lists of the run this leg was spawned under declares "Bash(*)" in its allowed tools: a row records each tool name in its bare spelling … — as this leg's checkpoint row recorded it; refused before the claim …` / `the checkpoint's frozen inherited constraint chain holds "Read(*)" in layer 1's \`tool_deny\` names: …`). A member that is not a string is named by its type only. Neither reading is applied to such an entry — not the one of the engine that wrote it, and not this one's. The shapes #1875 refused on a restored ceiling at prepare (an entry the syntax cannot read, a member that is not a string, a list that is not one) are refused there too, now before the claim. An allowed list's wildcard is any entry the one reading reads as `*`: `*`, `*()`, `*(*)`. A ceiling layer that is not an object (`null`, a list, a string) is refused by the same layer reader: before the claim on a restored ceiling, and at prepare on a live seat (`config.tool_face_control`, `detail.reason: "not_a_list"`, `… are a value that is null, not a pair of tool-name lists …`). The compiled selector reads it as admitting nothing.
312
+ - **Against the reference.** Not compared: a row's ceiling and frozen chain belong to this engine's durable park, and no reference coordinate was read for them.
313
+ - **Who breaks.** @server (the store of parked rows, and a reader of the refusal code): a row parked by 7.51.x whose `toolCeiling` holds `Tool()` / `Tool(*)` / `Tool(content)`, on either list, no longer resumes: refused before the claim, `checkpoint.config_refused` (`detail.code` `config.tool_face_control`), the row pending. The denied side was read alike by both engines (`Read(*)` hid `Read`), so a row whose only such entry is denied is refused although nothing would widen: one rule, no reading of the writer's engine (open to veto). Finish such rows on a 7.51.x worker, or expire them and run the work again. The restored-ceiling refusals #1875 added (an unreadable entry, a member that is not a string, a list that is not one) move from prepare's `config.tool_face_control` — after the claim, the row back to pending only on a store with `reopen` — to the resume's one refusal channel before the claim — `checkpoint.config_refused`, `detail.code` `config.tool_face_control` (prepare's own code: a fresh run and a resume report one code, and `detail.reason` names the arm), the row always pending; the sentence is the same with the row's suffix (a host branching on the code: open to veto). A row whose frozen chain holds such a name is refused the same way (a 7.51.x name-set row already was, by the digest sentence; the sentence now names the entry). So is a row parked on this engine under a policy whose own projection writes its lists as written: a host-authored `projection`, or `createApprovalPolicy` with `deny` / `requireApproval` / `autoAllow` holding `Read(*)` or a content form. Those readers take entries literally, a second reading tracked as #1911. Name the tool itself there. An allowed `*()` / `*(*)` (a run's `allowTools`, a definition's) now lifts that layer's allowlist (it admitted nothing: a widening to what the author wrote, open to veto). A host that hands `RunInternals.toolCeiling` with a layer that is not an object gets the coded refusal instead of a `TypeError`. A row parked by 7.51.x under a name set that repeated an entry (`createAllowDenyPolicy({ deny: ["Read", "Read"] })`, or a repeat on the allow side) froze a projection holding both; this engine's name set holds each name once (#1912), so the parent chain the resume re-supplies digests differently from the recorded one and the resume is refused before the claim (`resume.parent_constraint_mismatch`, the row pending) — deny the row and approve the work again; the same row with the name written once resumes (open to veto; KL-1910-a ③). Every other prepare-time refusal of a resumed leg still lands after the claim and relies on `reopen` (#1914).
314
+ - **Pins.** `test/backlog1910-persisted-selector-canonical.test.ts` (run here with the re-seated pins: 3 files, 88 cells, exit 0). The one reading (twelve entries × allowed / denied / canonical). W — `persistedToolNameList` in → out; `*()` / `*(*)` wildcard. The read-back of a restored ceiling (`Bash(*)`, `Bash()`, `Read(x)`, `*(*)`, deny `Read(*)`; a deeper layer by its distance; all bare; absent) and the #1875 shapes there (unreadable, a non-string member by type, a list that is not one, a ceiling that is no list, layers that are `null` / a list / a string). A live layer that is `null` / a list / a string, at the judgment and through the real prepare. U — real park / resume, each counting the store's claims: U1 / U2 refused before the claim, Bash never offered, row pending, no claim (base `64341caba`: completed, Bash offered; `080b0d641`: `resume.tool_unavailable`); U4 deny `Read(*)` refused (base: completed); U5 / U6 / U7 on a store with no `reopen` — `Bash(*)`, an unreadable `Read<TAB>(*)`, a null layer — refused with no claim, the row pending (base: U5 completed; U6 failed at prepare with the row resolved — consumed; U7 a bare `TypeError` after the claim); U8 a resume handing `toolCeiling: null` — no live ceiling, as prepare reads it — has the row's ceiling read back and refused (base: completed, Bash offered); U3 control. W1–W3 — this engine's rows (`Bash(*)` → `Bash`; `Read(x)` left out; `*(*)` → `*`, the child parks). P1–P3 — a 7.51.x name-set row, a `tool_deny` / `tool_allowlist` projection, a `shell_command`'s tools, a non-string member (base: P1 refused by the digest sentence, P2 / P3 completed); P4 / P5 controls. Per-cell mutations (tsx lane probe, `.gate-logs/car-1910/`): the screen's ceiling read-back off ⇒ U1 / U2 / U4 / U5 completed, U6 consumed, U7 after the claim; the spelling rule off (shapes still read) ⇒ U1 / U2 / U4 / U5 completed, U6 / U7 still refused before the claim; the write canonicalization off ⇒ W1–W3 red (the row raw, and this engine refuses its own row); the chain read-back off ⇒ P1 (sentence) / P2 / P3 / P6 / P8 red; the literal-`*` wildcard back ⇒ W3 red (no park); the layer refusal off ⇒ the layer cells red (a bare `TypeError`, or a list / a string passing); the compile guard off ⇒ the selector throws; the screen reading only `undefined` as "no live ceiling" ⇒ U8 completed with Bash offered. Re-seated pins: `backlog1623-tool-selector` ⑦ (#1875 `Read<TAB>(*)` on a restored ceiling: `failed` / `config.tool_face_control` at prepare ⇒ refused before the claim, `checkpoint.config_refused` (`detail.code` `config.tool_face_control`), same sentence, row pending); `module-size-ratchet` (`resume-checkpoint-screen.ts` 393 → 396, `prepare-suspend-saga.ts` 876 → 877).
315
+
316
+ ### A stop verb is answered until `done`, and it cuts whichever compaction pass is in flight — one caller-stop gate for `halt` / `interrupt` / `destroy` / the task's `signal` (#1828, #1838; P1; behavior change — the stop verbs' window widened, `compaction_outcome{mooted}` widened beyond `manual`, open to veto; closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
317
+
318
+ - **Before (7.51.x, and the 7.52.0 stage after #1815).** A stop that landed while a compaction pass was in flight did not reach it. `interrupt()` and `halt()` were refused `steering.not_running` ("the task is no longer running") from the loop's end on, while `done` was still unsent, and the end-of-task pass ran out its summary call's retry schedule (two retry-after waits on a 503 gateway ≈ 10 s, every summary request still sent); `destroy()` was no stop fact at all and waited the same schedule out; a `halt()` during a turn-boundary pass (a manual `/compact` in flight) latched the stop and left the pass running — the session stayed locked behind it. Only the task's `signal` cut the end-of-task pass. A pass the leg's own abort did cut said nothing on the wire at a turn boundary, and a prompt-too-long recovery pass cut by any stop was reported `compaction_outcome{failed}` + `onError(phase:"compaction")` and fed the compaction breaker. The end-of-task skip read three separate stop facts (#1815).
319
+ - **Now.** One rule: a run has ONE caller-stop gate — minted beside the loop latch (`run-identity-wiring.ts`) and published on the live handle; `halt()`, `interrupt()` and `destroy()` trip it, and the task's own `signal` is composed into it. Every compaction pass reads it the same way — the end-of-task pass, the turn-boundary pass, the prompt-too-long recovery / guard pass: a tripped gate admits no pass, and a pass in flight carries the gate in its abort signal and as its withdrawal signal, so its summary call, its commit boundary, its preCompact / postCompact seats and its `RunnerDeps.summaryProvider` consult (the one await of the pass that read no signal: a provider's late answer is now dropped, never persisted; a provider that throws still falls back to the summary call) are cut and the cut is reported as the caller's cancel — `compaction.mooted{reason:"cancelled"}` on the trace, `compaction_outcome{outcome:"mooted", reason:"cancelled"}` on the wire with the pass's own `trigger` — never `failed`, never `onError`, never breaker evidence. `done` follows at once; `destroy()` no longer waits on a pass. The end-of-task skip reads the gate; the three #1815 facts are retired. The stop verbs' window closes on the published result (the station just before `done`) instead of the loop's end: an `interrupt()` / `halt()` after the loop's end is answered (`halt` → `{turnCut:false}` + `task.halt_unconsumed`), stops the tail, and changes nothing else — the loop's ending, its result and its attribution stand (a stop after the loop's end claims no `userInterrupted` / `haltedByUser`; an interrupt's claim is judged before any of its aborts runs a listener, and every verb trips the gate after its own abort of the run, so a verb a listener calls in reaction neither takes a claim away nor makes one — a `destroy()` is never signed as a person's interrupt). `task.halt_unconsumed`'s message now reads "it cut no turn and stopped no boundary" (was "nothing was cut or stopped by it"): a halt after the loop's end still cuts the tail's compaction pass, which reports itself. `steer` and `reconnectMcpServer` still close at the loop's end. Stop and attribution stay two facts: an engine stop (a limit, an exhausted output retry, a gate or guardrail) never trips the gate, so an engine-ended run keeps its between-task pass.
320
+ - **Who breaks.** Behavior widening on the wire (open to veto), same wording as the `TaskEvent` doc: `"mooted"` — the pass did not run to its end: a manual request no boundary could serve (`trigger:"manual"`, `reason:"task_ending"`), or a request withdrawn / a pass cut in flight by its caller — a `compact({ signal })` withdrawal, `halt()`, `interrupt()`, `destroy()` or the task's own `signal` (`reason:"cancelled"`), on any trigger: `"auto"`, `"manual"` or `"forced"`. Read `"mooted"` with its `trigger` — it is not a manual-only outcome. server / cli / client-core: a reader that takes `outcome === "mooted"` to mean "a manual `/compact` was mooted" (a manual-compact spinner, a counter) must also check `trigger === "manual"`. A halted run no longer runs a compaction pass at its halted boundary (a parked manual `/compact` is answered `mooted` / `task_ending`); the session's next run compacts at its own boundary. A recovery pass cut by a stop is no longer a compaction failure (no `onError`, no breaker count). The same holds (#1838) for a recovery or guard pass cut by a TURN interjection (a `steer` with `priority:"now"` landing while the pass runs, the leg itself still running): it was `compaction_outcome{failed}` with the reason "… cut short by a turn interrupt" (no `onError`, no breaker) and is now `compaction_outcome{mooted, trigger:"forced", reason:"cancelled"}` plus the `compaction.mooted{cancelled}` trace, like every other caller cut; a reader that matched that sentence reads `outcome` / `reason` instead (no such reader in the cli / server trees' `src` and tests at their local `origin/main` — inferred, not re-fetched). The engine's own stop (a limit) cutting the same pass still reads `failed` (KL-1828-b). `interrupt()` / `halt()` no longer reject in the teardown window: a host that read that rejection as "the run is over" now gets a resolved verb and `done` right after it. cli: Esc during a manual `/compact` in flight releases the session at once. server: a cancel through any verb (or the run's `signal`) reaches `done` without waiting on a summary call. Stated once, for both verbs (pinned per verb): once the result is published `halt()` / `interrupt()` are refused `steering.not_running` — the code `steer` answers, "the task has already finished" — and sign nothing; until then they are answered, and in the window after the loop's end `halt()` says so in `task.halt_unconsumed` while `interrupt()` leaves no notice of its own (KL-1828-c).
321
+ - **Pins.** `test/backlog1828-stop-during-pass.test.ts`: four verbs × three passes (the verb is answered, `done` within 2.5 s of the stop, `destroy()` itself within 2.5 s, no summary request after the stop, `compaction_outcome{mooted, cancelled}` with the pass's trigger, no `failed` / `onError` / `compaction.failed`; the end-of-task cells keep `completed` + the answer and sign no halt), controls (a boundary manual request and a recovery pass each land without a stop; an unstopped failing pass still reports `failed`, no `mooted`), a parked `summaryProvider` × the four verbs (no summary request, the late answer never lands, mooted / cancelled) with a reuse control, counterexamples (an engine-ended run keeps its pass and an interrupt during it still cuts it, the engine's terminal standing; a halt BEFORE the boundary pass is admitted by no pass — mooted / `task_ending`, no handler call; an interrupt keeps its marker when a listener on the cut summary call aborts the task's own signal, with its control; a destroy whose cut summary call's listener calls `interrupt()` writes no marker; after `done` the stop verbs refuse again; a halt cuts a pass parked on a preCompact handler), and per stop verb a refusal after the result (code, message, no notice, no marker) and an answer in the window with nothing left to cut; the recovery lane: a turn interjection landing mid-pass reads mooted / cancelled on the recovery pass and on the guard pass, while a real summarizer failure and an engine stop (the walltime limit) still read `failed` + `onError`. `test/ptl-lane-twins-474.test.ts` re-pinned to the same reading. `test/design504-user-halt.test.ts` T1: the halt's first attempt cuts the running turn on the call's own tick. `test/steer-interrupt-lifecycle-389.test.ts` HIGH-1 re-pinned: the late interrupt (after the loop's end, before `done`) is answered; still no person marker. Known limits, each pinned in the 1828 file's "weakest three": W1 a `Brain` that ignores the summary call's abort cannot be cut — `done` waits for that call, nothing it returns lands (KL-1828-d); W2 after the loop's end a halt leaves `task.halt_unconsumed`, an interrupt no notice (KL-1828-c); W3 a stopped run compacts nothing, so the session's next run sends its first request uncompacted (KL-1828-e). Also KL-1828-a (the window closes at the published result, a step before `done`) and KL-1828-b (an engine stop that cuts a recovery pass still reads as a compaction failure). `test/backlog1815-cancel-owes-no-compaction.test.ts` A.destroy added. `test/backlog677-cancel-window.test.ts` G4.b re-pinned: an interrupt from inside the end-of-task preCompact hook is answered and the pass is mooted (the old pin read the loop's end as the run's end). `test/refactor-a2-compaction-call-options.test.ts`: the lane discriminators read `force` (every lane carries a `signal` now).
322
+
323
+ ### A call the engine's own judgment refused and never let go carries `notExecuted: true` — on its `tool_end` and on its persisted `toolResult` row, from one reading (#1831; P2; wire truth, additive; closed sets: none; exports: none; members: none — `tool_end.notExecuted` already exists, `details.notExecuted` is a new key in an untyped bag) (@server @client-core @web-admin @cli @test)
324
+
325
+ - **Before.** `tool_end.notExecuted` rode only on the closing station's own mark (the loop's, for a sibling a halted batch settled or a call a turn interrupt reached first; the reconcile's never-started class). A call the GATE refused — the host's SLA sweep deciding a durable card nobody answered (`gate.settlement.kind === "park_sla_expired"`), a person's refusal, a policy / hook / plan-mode deny, a post-approval veto before the run — closed `isError: true` with a `denied` gate record and no `notExecuted`, so the three renderers drew "Failed to run" for a tool that never started. The persisted `toolResult` row of such a call carried no such fact either, so a reload (`sessions.get`) lost what the live frame could have said.
326
+ - **Now.** One reading over the call's occurrence (`src/core/runner/tool-not-executed.ts`, `refusedBeforeRelease`; the occurrence record is `tool-occurrence.ts`): the latest gate record is a refusal, the call's life never left `announced`, and its start went out on this stream. The occurrence's life now says what the code always meant by "the engine let the call go": `announced → released → settled | interrupted` (the old `executing` spelling was true on the resumed leg only — a live leg never saw the loop start the body, so a live call stayed `announced` whether it ran or not); the ONE transition into `released` (`release()`) is made by the resumed leg right before it runs the tool body itself, and by a live leg at the gate's allow exit. `emitSettled` (the one mint of `tool_end`) ORs the closing station's mark with that predicate and returns the result; the resumed leg's `toolResult` rows (every refusal arm of the resolver) repeat the return as `details: { notExecuted: true }`; on the live leg the end handler (`run-harness-handlers.ts`) writes the same return onto the engine-minted result the loop persists as the row right after the end event, beside whatever the refusing station put in `details` (the shell read station's `read_path_denied` card keeps every member on the row, and the frame's card is projected before the write, so a row mark is never part of a card). Because the write reads the return and not a refusal, every live row whose frame says `notExecuted` carries it — a halted sibling's and a never-started interrupted call's included — and a refusal that also aborts the run (the headless classifier's denial limit) loses nothing. The row the interrupted-run reconcile appends for a never-started call carries it too (`closeNeverExecuted`, `session-reconcile.ts`: the one class→mark table the synthetic `tool_end` and the appended row both read). `details.notExecuted` on a persisted row is the engine's key alone: a tool's own value under that name is dropped from the row (live and resumed), the tool's other members stay. A refusal recorded AFTER the release — the shell read admission refusing inside the tool, live or resumed — is still `denied` by `read_boundary` with `isError`, and carries NO mark. The recovered-start exception: an occurrence whose start this stream never carried is closed by a station that knows only its own class.
327
+ - **Who breaks.** Nothing that worked: the member is additive and optional (the type already had it; the semantic widens from the station's marks to the engine's refusals). A consumer that rendered `isError && !notExecuted` as "Failed to run" now renders a refused call as "not executed" (web-client / cli / desktop — the intended change); a consumer that read `notExecuted` as "the loop skipped it" alone now also sees gate refusals; PostToolBatch is untouched (it keys on the blocked set and the loop's own mark). A reload that reads session rows sees `details.notExecuted === true` on such a row (a refusal row's `details` was `{}` or the refusing station's card before). A tool that wrote `details.notExecuted` itself no longer finds it on its persisted row (it was never lifted onto the frame). **Not covered, by design:** a call the loop itself closed without a gate record (an unknown tool, a refused input, an abort after the release) — absence is never "it ran", and a later window may widen the reading with its own pin. server: any adapter that copies `tool_end` keys by name should carry `notExecuted` (it was already in the type).
328
+ - **Pins.** `test/backlog1831-not-executed.test.ts` (frame AND row on every refusal cell): the host's SLA sweep, a person's durable refusal, the approved call's re-screen veto (resumed); a policy deny, a live person's refusal, a PreToolUse deny, plan mode, a shell read station deny whose card keeps its members (live); controls with no mark: a call that ran (live, resumed), the tool's own read admission refusing after the release (live, resumed), a tool writing `notExecuted` into its own `details` (success and failure, live and resumed — never on the frame, never on the row, its other members kept), the gate allowed and a hook rewrote the input to schema-invalid, an unknown tool, the next process's replay of a dead process's call (frame and row); a run aborted mid-batch (the never-reached siblings' frames and the appended rows marked, the executing call's not); the OR (a released call the loop closes unrun keeps the loop's mark; a halted sibling, frame and row), the denial-limit stop (frame and row agree on every refused call, the stopping one included), the recovered-start exception, and the predicate over a hand-built occurrence. Mutations: `.gate-logs/criteria-1831-mutations.md`.
329
+ - **Known limits.** KL-1831-a: the live release is the gate's allow exit, a hand-off and not proof the body started — a released call the loop then closes unrun (an abort between the gate and the body, an input the loop refuses after a hook rewrote it) carries no mark. KL-1831-b: the row key is the engine's against a tool's plain `details` member, not against a `details` that carries its own serializer (`toJSON`), which decides what a JSON-writing store persists under any key.
330
+
331
+ ### A stopped run owes no end-of-task compaction, and a stop that lands during the pass cuts it — a terminal frame never waits on a summary call (#1815; P1; behavior change; closed sets: none; exports: none; members: none) (@server @cli @test)
332
+
333
+ - **Before (7.49.x – 7.51.2).** At a run's end the engine ran one more compaction pass when the session was past the trigger — also for a run that had been stopped before it gave a settled answer (an `interrupt()`, the task's `signal`, a limit, a bare `halt()`; in a retry backoff or before the first token alike). The pass was handed no cancel signal, so on a degraded gateway (503 / overloaded) its summary call went out after the stop and was re-sent on the client's retry schedule; `done` was not sent, the run stayed `running` and the session stayed locked. On a healthy gateway a stopped run still paid one full summary call after the stop.
334
+ - **Now.** One reading: a stopped run owes no end-of-task pass. `skipCompaction` (`run-terminal-adoption.ts`) now also stands down for a stop its CALLER asked for, whether or not an answer had settled — the interrupt verb, the task's own `signal`, the bare halt (`haltConsumed`; the halt ends the loop cleanly and leaves the leg's controller alone) — the turn boundary's own reading ("a dying loop won't compact"). The caller's channels are a closed set; an abort the engine raised on work (a limit, an exhausted structured-output retry, a gate or guardrail stop) cannot be told from them by the abort flag and keeps the documented between-task pass, unchanged; so does the existing yield for any abort that landed on a settled answer's tail. The session of a skipped run compacts at the next run's own boundary. A stop that lands while the pass is IN FLIGHT (the run ended on its own, then the caller's `TaskSpec.signal` fires during the summary call or a `preCompact` handler) now reaches it: `maybeCompact` and the hook seats take the caller's signal — never the leg's own controller, which is aborted on every exit — and the cut is a `compaction.mooted{reason:"cancelled"}` trace frame, not `compaction.failed` / `compaction_outcome{failed}` / `onError` — judged on the abort outcome's own marker (the one the turn boundary reads), so a storage failure that merely races a late cancel keeps the failure account. A pass that fails with no stop is unchanged (`compaction_outcome{failed}`, `compaction.failed`, `onError`, then `done`). The `finish()` option `cancelled` is gone (it could only be true on a pass that no longer runs).
335
+ - **Who breaks.** Nothing that worked. server: a run stopped under a degraded gateway now reaches `done` (the row terminal, the lock released) — S-1621 unblocks. A stopped run's history is no longer compacted at its tail: the next run's first boundary does it, so a stopped run over the trigger carries its history one run longer; the preCompact / postCompact handlers do not fire for that tail pass. Known limits (confirmed by probes in the review rounds): the three caller-stop facts this section introduced (the interrupt verb's first-abort claim, the task's `signal`, a consumed halt) left out `stream.destroy()` and an `interrupt()` landing after an engine abort, and `halt()` / `interrupt()` were refused once the loop had ended — all three are closed in this release by #1828 (one caller-stop gate, the section above), which retires the three facts; the awaits after the pass (the session touch, MCP / A2A disposal, the usage ledger's final charge) do not listen to a cancel; and the cancel-versus-failure split reads the same origin marker the turn boundary reads (set where the summary call's abort outcome is formed), so a custom `Brain` that rejects with the signal's reason, or a transport failure that a late cancel overtakes, is classified on that marker alone.
336
+ - **Pins.** `test/backlog1815-cancel-owes-no-compaction.test.ts` A (halt / interrupt / signal / before the first token / healthy gateway: `done` within 2.5 s, zero summary requests on the wire), B (an unstopped pass still runs; a failing pass still reports and `done` still comes; the next run on the same session compacts), C (the caller's signal during the summary call; during a parked `preCompact` handler); `test/backlog677-cancel-window.test.ts` G3.b re-pinned (a stop on work: no pass, no seat).
337
+
338
+ ### An MCP server declares its own startup bound — `McpServerSpec.startupTimeoutMs`; env `MCP_TIMEOUT` is the default seat, not the only one (#1552; knob; additive for a task — and the no-run probe loses its own default verdict clock, a loosening, see Who breaks; hard-breaking: none; closed sets: none; exports: none; members: `McpServerSpec.startupTimeoutMs?` +1 optional; `McpConnectionPool.keyOf` +1 required parameter, a type-only mechanical BREAKING) (@sdk @client-core @dgx)
339
+
340
+ - **Before.** The startup phase of an MCP dial — the handshake, then the initial tool listing — had ONE bound for every server in the process: env `MCP_TIMEOUT`, else the SDK's 60 000 ms. The dials run side by side, so one upstream that hangs held the whole task's preparation for the full wait, and the only remedy was to shorten the bound for every server at once.
341
+ - **Now.** `spec.mcp[i].startupTimeoutMs` bounds THAT server's startup alone. One reader (`mcpStartupTimeoutMs(server)`, `src/core/mcp.ts`), one order: the declaration, else env `MCP_TIMEOUT`, else the SDK's 60 000 ms (the built-in default is unchanged). Both startup requests carry it (the handshake; the initial listing as one walk, however many pages). The declaration is whole milliseconds in `[1 000, 2 147 483 647]` — the range every MCP millisecond bound keeps; any other value (a string, a fraction, zero, a negative, NaN, `null`, a number outside the range) refuses THAT server at its dial: `status: "failed"`, `errorCode: "invalid_config"`, the warning's `cause.code` = `config.mcp_startup_timeout_invalid`, never spawned, its siblings mount; never clamped and never read as the default. Env stays the lenient process-level seat (clamp and announce); a malformed env value is announced even under a declaration that shadows it. Every seat that needs the bound asks that one reader, per server: the dial (and every re-dial) and the connection pool's key. `probeMcpServers` has no clock of its own by default any more: it dials through the entrance a task does, so each startup phase carries that server's bound from the same reader and a server that meets both phase bounds is `connected` there exactly as in a task; a declaration the reader refuses is the dial's to refuse, so the probe returns that server's `invalid_config` row and does not reject. An explicit `timeoutMs` is the caller's overall ceiling and lives beside the per-phase bounds — whichever arrives first decides.
342
+ - **Wait time, stated.** A hung server is cut at its bound; a stdio child that does not exit when its input closes then adds the transport's close ladder (≤ 2 s to SIGTERM, ≤ 2 s more to SIGKILL) before preparation proceeds. Measured here with a 2 s bound (`S1`, `S1c`): preparation settled in about 2.1 s for a child that exits on its closed input and about 2.4 s for a tsx-run child that ignores it.
343
+ - **Who breaks.** Nothing in a task: absent = today's reading, and `MCP_TIMEOUT` keeps its meaning for every server that declares no bound. **One behavior change, a loosening, in the no-run probe:** a `probeMcpServers` call that sets no `timeoutMs` used to cut each server's WHOLE dial at env `MCP_TIMEOUT` when it was set (no clock of its own when it was not); it now has no default clock and each startup phase carries the bound. A server whose handshake and listing each meet the bound reads `connected` where it read `failed` / `timeout`; the worst-case wait for a server that keeps its handshake just under the bound and then hangs on the listing is the two phases (plus the close ladder), where the whole-dial clock cut it at one bound. A caller that wants one overall ceiling passes `timeoutMs`. Type consumers: `McpServerSpec` gains one optional member — sdk / client-core type mirrors follow, and any table keyed `Record<keyof McpServerSpec, …>` needs a row (this repo's `MCP_SERVER_SPEC_LIVENESS` did). A host that assembles the declaration from JSON must pass a number: a numeric string is refused. **Mechanical BREAKING, type only:** `McpConnectionPool.keyOf(spec, principal, kind, startupMs)` — the static now takes the bound the dial runs under as a required fourth parameter (pass `undefined` for none: the key is then the one it always was); a TypeScript caller outside the engine fails to compile until it passes it, a JavaScript caller keeps today's key. No caller was found in sema-server or sema-cli `src` (grep). dgx asked for this (the one-service-hangs-every-turn case); a deployment that wants a short bound on a flaky upstream sets it on that server's entry and leaves the process default alone.
344
+ - **Two seats the review rounds found reading the bound wrongly, closed here.** ① The probe kept a verdict clock of its own: one bound, read from env alone, raced against the WHOLE dial, while a task grants the bound to each startup phase. With `MCP_TIMEOUT=1000` and a server declaring 10 000 whose handshake lands at 1.5 s, a task connected and the default probe reported `failed` / `timeout`; with 650 ms per phase under a bound of 1 000 (two phases, 1.4 s in all) the same. The clock is deleted — the probe asks the dial's per-phase reader and nothing else (`S7b`, `S7c`). ② The pool key left the bound out, so a mount joining a pooled dial still in flight waited on it under the dialling mount's bound (a 1 s mount rode a 20 s dial). The bound in force is now part of the key (`McpConnectionPool.keyOf`, threaded from the dial's one reading, as `kind` is): mounts under one bound share one connection — the environment seat counts as a bound, so env 20 000 and a declaration of 20 000 share — and mounts under different bounds dial their own (`S6`, `S6c`, `S6d`). Cost: two mounts of one server that disagree about their bound now start two connections where they used to start one; mounts of one server in one run tree normally carry one declaration.
345
+ - **Pins.** `test/backlog1552-mcp-startup-timeout.test.ts` S1–S8, 41 cells (a hung server and a healthy sibling; the declaration over env in both directions, env filling absence, the undefined default, a shadowed malformed env still announced; the listing phase; twelve bad values plus the range's edges; the real `Runner` preparation; the probe with no clock of its own, a server whose two phases each meet the bound but add up past it, the caller's overall ceiling beside the phase bounds, and the refused-declaration row; the pool key — one bound shares, different bounds dial their own, env and declaration alike; the in-session re-dial). Mutations: the dial reads the env seat only ⇒ 31 cells red; a bad declaration falls back to the process-level bound ⇒ 20 red (every bad-value cell, none of the honored-value controls); the initial listing drops the bound ⇒ S3 alone red; the probe races the whole dial against the bound again ⇒ S7c red (with the env seat's number, S7b too); the pool key leaves the bound out ⇒ S6 / S6c / S6d red. `test/module-size-ratchet.test.ts`: `src/core/mcp.ts` 4 413 → 4 422 (raised, the one reader). `docs/CLOSED-SETS.md` (the thrown code, an error and not a notice), `docs/INTEGRATION-CORE.md` §8.2 / §8.3.
346
+
347
+ ### A model's own credential stays on the host it was first sent to: an entry moved to another origin under the same credential sends nothing (#1821; security; behavior narrowing — open to veto; **BREAKING**: hard-breaking one, the retired notice word; closed sets: `EngineNoticeCode` −1 `route.base_url_changed_key_unchanged` +1 `route.credential_origin_mismatch`, `RouteRefusalCode` +1 `route.credential_origin_mismatch`, `RoutePairingStatus` +1 `broken:credential_origin_mismatch`, `ROUTE_CREDENTIAL_PAIRING` +1 row `per-model / apiKey / options` (#1848); exports: +1 `__resetCredentialOriginBindings` (a test seam, #1837), none removed; members: `RouteRefusalDetail.boundOrigin?` / `.requestOrigin?` (optional, new); behavior narrowing also of which header is a credential, #1837 — open to veto) (@server @web-admin @cli @client-core @sdk @test)
348
+
349
+ - **Before (≤ 7.51.2).** A per-model credential — the per-model auth hook's key (`TaskSpec.getApiKeyAndHeaders`, which a server fills from a catalog entry's `apiKeyEnv`) or an auth header on `Model.headers` — was "paired by construction": the pairing law never refused it, wherever the entry pointed. A catalog swap (`Runner.swapDeps`) that moved an entry's `baseUrl` X → Y and left its credential reference alone sent the very next request to Y carrying X's key. The engine saw the moment and said so — `route.base_url_changed_key_unchanged`, audience operator, advisory — and sent it anyway; the user saw nothing.
350
+ - **Now.** One law for both credential sources: a credential has exactly one root. The deployment credential's root is unchanged (its brain's declared root, #1531). A per-model credential is bound to the ORIGIN — scheme + host + port — of the first request SENT carrying it (`src/brain/route-adjudicator.ts`: `resolveRouteCredential` tags each per-model credential that carries a secret with a digest of it; `adjudicateModelRoute` READS the engine's one process-wide credential-origin table and writes nothing; the table's one writer is the first-party brains' request gate, `admitRouteRequest`, whose admission runs when the engine commits that request to be sent — `SSERequest.onCommitted` — so a pre-flight, the read face, a consumer's own call of the law, a request the context-overflow recovery built speculatively and declined, and a request whose build fails after the gate bind nothing; the admission judges again and binds in ONE synchronous step, so a credential that went out to another origin between a request's build and its commit — caller code runs while a body is built, an `extraBody` member's `toJSON` — refuses that request at its commit, before anything is sent). Which header carries a credential, BY ITS NAME, is ONE reading, shared with the withheld-value table (#1754): `isCredentialHeaderName` (`src/core/secret-env.ts`, the credential vocabulary's record-key view — `Authorization`, `Proxy-Authorization`, `X-Api-Key`, Azure's `api-key`, `x-goog-api-key`, `Cookie`, any name holding a credential word; not a vendor's own spelling of a name the vocabulary declares non-secret, #1850). The withheld table adds a half of its own — a value that looks like a credential under any name — and the law does not (#1850). It decides which bag carries a credential (`hasAuthCarrier`, the deployment's `config.headers` included), which header values a per-model credential's identity is read from (`Authorization` / `Proxy-Authorization` as their `<scheme> <credentials>` part, any other whole), which headers a per-model winner strips, which the user-header merge leaves to that strip, and which `streamOptions.headers` refuses — the former two-name table (`authorization`, `x-api-key`) is gone. What is bound is what the deployment HANDED over (#1848): every non-empty credential value a request was given explicitly — an `apiKey`, a credential header's value — whatever its length or wording (a seven-character gateway key, `EMPTY`); being handed over as a credential is the evidence. The no-credential door is explicit: a local gateway that takes no credential is handed none (the hook answers no key, or the empty key `""`), and is served on every host. (The withheld-value table's qualification, #1816 — fewer than eight characters, a placeholder word — answers whether an environment value could be a secret; it is that table's alone.) The winning per-model source is ONE credential (#1848): the hook's answer — its `apiKey` and its credential headers — rides together and is bound together, as the deployment's `config.apiKey` + `config.headers` always rode as one; only the credential headers of the other bags are stripped — whether a header is stripped is decided by its SOURCE, not its name. The winning bag is read ONCE: the headers a per-model credential sends are the very ones its identity was computed from (a header whose getter answers one value when hashed and another when sent cannot carry a credential bound elsewhere), and the entry's root is read once per request build, before any such caller code runs: the URL the request goes to and the origin its admission judges and binds are that one reading; each caller header bag (`Model.headers`, the per-call `headers`) is read once too, into one copy that both the law and the header merge are handed (a bag whose keys hide a credential header from one reading and show it to the next cannot ride beside the deployment key). The key's own header (`authorization` / `x-api-key`) is written as a lock, case-blind, like the brains' structural headers; an answer that hands the key AND a header of that name (any case) is refused before anything is sent (#1850), so the lock supersedes nothing the answer carried. A request carrying it to another origin is refused before a byte is built: `[invalid_request] route.credential_origin_mismatch: …`, naming both origins, the entry and the fix; a move to another path of the same origin (`/v1` → `/anthropic`) is not a move. A NEW credential (the entry's credential reference changed with its address) binds afresh. Each secret a request carries is bound on its own — adding, dropping or changing another auth header beside a bound one does not make it new, and a refused request binds nothing new; `apiKey K`, `x-api-key: K` and `authorization: Bearer K` are one secret (an `Authorization` value is read as its `<scheme> <credentials>` part, an `x-api-key` value whole, both with the whitespace the wire strips removed). The refusal is terminal for the request: no retry (it is thrown while the request is built, before the retry ladder), and a failover chain's next brain meets the same binding (the table is per process, not per brain). The run announces it once per (model, bound origin, request origin): `route.credential_origin_mismatch`, audience **user** — the person whose request was refused hears why; `detail: { modelId, boundOrigin, requestOrigin, sessionId, runId }`, display spellings only, never a credential or a digest of one (`src/core/runner/credential-origin-seat.ts`, the run's fifth brain sink). Only the request gate's refusal reports it (`routeRefusalError`, thrown by `admitRouteRequest` in the three first-party brains); a pre-flight or read-face judgment neither binds nor reports. The read face says it before a request goes to the new host: once a request went out to the old origin, `routePairingStatus` answers `broken:credential_origin_mismatch` for the moved entry. A derived seat (a role- or tier-derived summarize / classifier model) whose credential went out to another origin falls back from its next pre-flight on (`route.fallback_to_primary`, `cause: "route.credential_origin_mismatch"`); a request of its own in the meantime is refused at its own request gate. A derived seat on another host that shares the main model's key no longer takes the key first: its pre-flight binds nothing, so the main model's first request binds it and is served. The swap-time advisory is retired: the swap never sees a hook's credential value, the request does.
351
+ - **Who breaks.** **Hard-breaking (the retired word):** `route.base_url_changed_key_unchanged` is no longer minted and left `ENGINE_NOTICE_CODES` — a consumer that keys a record by `EngineNoticeCode` (client-core's audience table, a server's notice routing / log readers, cli's notice renderer) gets a compile error until the row goes, and gives `route.credential_origin_mismatch` its row (audience `user`). **Behavior narrowing (open to veto):** web-admin's catalog publishing — an entry's `baseUrl` moved to another host must move its credential reference (`apiKeyEnv` / sealed key) in the same publish, or every request to it is refused; a provider that only changed domains and accepts the same key there needs an engine restart (the binding lasts as long as the process; no in-process rebind) — and so does an entry whose first request went to a wrong host that never answered (the binding is written when the request is committed to be sent, KL-1821-e). One real key used by two entries on two hosts refuses the one whose request goes out second — the "one credential, one root" reach, the same as for a deployment credential; a value handed over binds whatever it looks like (#1848: a seven-character key, `EMPTY`), so local nodes that share a placeholder key are served on every host only when they are handed NO key (the hook answers none, or `""`) — handing each the same `EMPTY` refuses the node sent second, and so does one gateway account header (`Helicone-Auth`) handed for models on two hosts. **The pairing table gains a row (#1848, closed table +1):** `ROUTE_CREDENTIAL_PAIRING` has `{ credentialSource: "per-model", carrier: "apiKey", carrierBag: "options", credentialKnobs: ["getApiKeyAndHeaders().apiKey", "getApiKeyAndHeaders().headers"] }` — the hook's answer carrying both, the per-model mirror of the deployment's dual row — so `terminal.detail.route` / `AssistantMessage.apiErrorRoute` can carry that shape; server / client-core / sdk code that pinned the table's rows or switches on the route shape adds it (`routePairingRowOf` finds it; `admittedRequestRoute` admits it). **The key header is a lock (#1848, a fix):** the three brains write the apiKey's header (`authorization` / `x-api-key`) case-blind, deleting a same-named header of another spelling first — the exemption "auth stays on the exempt path" is retired. On the deployment arm this changes one shape only: `config.apiKey` beside a `config.headers` entry spelled as the lane's key header in another case (`Authorization` on the OpenAI lanes, `X-Api-Key` on Anthropic) sent both, folded into one comma-joined header (a request no server accepts); the key now wins, as it already did for the lowercase spelling (the deployment's own contradiction; a per-model hook answer that holds both is refused, #1850). A header of another name beside the key (the dual-auth shape) still rides. **A hook answer's own credential headers ride (#1848):** a per-model hook that answers `{ apiKey, headers }` with a credential header beside its key now sends it (7.51.x stripped only `authorization` / `x-api-key` there; the #1837 tree stripped every credential-named one) — on the Anthropic lane an answer's own `Authorization` rides beside `x-api-key`, the per-model mirror of the deployment's dual-auth shape; a construction-time `Authorization` (another source) is still stripped under a per-model key. A consumer switching on `RoutePairingStatus` or `RouteRefusalCode` adds the new word. **Behavior narrowing of which header is a credential (#1837, open to veto):** a header whose name holds a credential word beyond the former two (`api-key`, `x-goog-api-key`, `Cookie`, a gateway's own `Helicone-Auth`, …) is now a credential everywhere the law reads one — on `Model.headers` it makes the entry's credential per-model (the deployment key no longer rides with it; a gateway's own header that must ride BESIDE the deployment key goes on the brain's `config.headers`, where it rides as part of the one deployment credential, KL-1837-a); in a per-model hook's answer it rides WITH the key — the answer is one credential (#1848): `{ apiKey: K, headers: { "Helicone-Auth": … } }` sends both and binds both, each secret to the origin, and an answer of headers only (`apiKey: ""` beside them) sends and binds the headers — while a credential header from another bag (the entry's under a hook answer, the deployment's under either) never rides beside a per-model credential; on `config.headers` alone it is a deployment credential, refused off its root as #1531 refuses `apiKey`; a per-model winner strips it from the deployment's headers; `streamOptions.headers` carrying one is refused at construction / `setStreamOptions`; `hasAuthCarrier` (exported) answers `true` for it (server's degrade-hop summary reads it). server: zero assembly change — its hook keeps answering per request; `GET /v1/models` (`routePairingStatus`) gains the new status word and no longer binds anything by listing an entry; `hooks/hook-llm.ts` calls `adjudicateModelRoute` for its own direct emission, which now reads the bindings the engine's brains wrote and writes none (its own sends bind no key — that lane's refusal is still core's code and text). client-core: the `adjudicateModelRoute` value port is a pure read again. **Test suites (server, cli, client-core, sdk):** the table is per process, so one fixture key sent to two local mock upstreams in one test process (two `listen(0)` ports are two origins) gets the second refused — give each case its own fixture key, or call the new test seam `__resetCredentialOriginBindings()` between cases.
352
+ - **How to migrate.** client-core / server / cli: drop the retired row, add `route.credential_origin_mismatch` (user audience; render `message`, or the two origins from `detail`). web-admin: change the credential reference together with the base URL; restart the engine for a same-key domain move.
353
+ - **Pins.** `test/backlog1821-credential-origin-binding.test.ts` (real `Runner`, real first-party brain, two real local listeners each its own origin, listeners record only whether a fixture credential rode): C1 X → Y under the same key ⇒ zero requests to Y and none re-routed to X, the run fails with the code and both origins, exactly one user-audience notice whose `detail` keys are exactly the five above and carry no key and no digest-shaped run, the swap mints no credential line; C2 a same-origin path move sends with the key; C3 a move with a new key sends to Y with only the new key; C4 a two-entry swap refuses only the moved one; C5 a failover chain of two brains: zero requests, one notice; C6 the same secret respelled as an `Authorization: Bearer` header on the entry is refused; C7 pointing the entry back sends again; C8 after a request to the old host, the read face says `broken:credential_origin_mismatch` before any request to the new one; C9 a second auth header added beside a bound one: refused, and the added secret's first use is still to come; C10 ` \tBearer\tK ` after `Bearer K`: refused; C11 an `x-api-key` value is opaque (`Bearer K` there is the apiKey `Bearer K`; `tenantA K` / `tenantB K` do not collide); C12 the law and the read face bind and report nothing, the gate's refusal reports once; #1837: F1 a summarize seat on another host sharing the main model's key — the main model is served on its own host on every run, zero requests to the seat's host, the seat falls back from the second run on with the cause; F1b the derived seat's own request refused at its own gate; F1c a speculatively built, declined request binds nothing; F1d a build failing after the gate binds nothing; F1e an admission judges again as it binds, F1f a request to X started from inside the build of a request to Y (an `extraBody` `toJSON`) commits first and Y is refused at its commit with zero bytes, F1g the same with the entry object moved to X from inside that build — the admission judges the request that was built (Y), zero bytes to Y, the key stays bound to X; F5 listing a candidate entry on another host binds nothing; #1848 (a): F3 explicit keyless (no key, the empty key) reaches two hosts with no credential header, F3c `EMPTY` / `lm-studio` / `Bearer EMPTY` handed as the credential bind (Y refused, zero requests; `declareWithheldEnv` still refuses the same values — its own question), S1 a seven-character apiKey, a one-character apiKey and a seven-character entry `x-api-key` bind, S2 the short key through the real Runner, F3b a real key beside `Bearer none` binds; #1848 (b): H1 a hook answer `{ apiKey, Helicone-Auth }` through the real Runner sends both and binds both (each refused on its own at Y), H2 an answer of headers only (no key, or `apiKey: ""`) sends and binds its header, H3 the entry's and the deployment's credential headers are stripped under the hook's answer, H4 (three lanes) an answer holding the apiKey and a header named as the lane's key header, any case, an empty value included, is refused by the pre-flight judge and by the build in one sentence, nothing sent (#1850), H5 the deployment twin (`config.apiKey` beside `Authorization` in another case: the key wins; `api-key` beside it still rides), H6 on all three lanes a hook header whose getter answers an unbound value first and X's bound credential after sends the first to Y, never X's, H7 on all three lanes a hook header whose getter points the entry back at X while its request to Y is built leaves Y refused with zero requests, H8 on all three lanes a hook bag whose keys hide its credential header from the first enumeration and show it to the second is enumerated once and sends nothing of it beside the deployment key; `test/request-params.test.ts` [1282] the construction-time `Authorization` is still stripped under a resolver key while the resolver's own `Authorization` rides beside `x-api-key` (Anthropic) and is refused beside the key (OpenAI, #1850); `test/route-adjudication.test.ts` the strip writes back what the resolver read, a hand-built per-model credential carries no header; F4 a key on `api-key` / `x-goog-api-key` / `Cookie` does not follow its entry, F4b a brain whose only credential is an `api-key` header is refused off its root, F4c a per-model key strips the deployment's `api-key`, F4d `hasAuthCarrier` and the withheld intake read one vocabulary, F4e `Proxy-Authorization: Basic K` is the secret `K`, F4f `Idempotency-Key` survives a per-model key's strip while `X-Api-Key` beside it does not; R1 `__resetCredentialOriginBindings()` rebinds afresh, R2 the `sema.core.credentialOriginTable` slot holds origins only; W1–W7 the weak points (below; W5 a derived seat that sends first takes the key, W6 a first request to a dead host holds the binding, W6b a request cut before its first attempt binds, W7 an entry's gateway header replaces the deployment key, W7b a hook's gateway header rides with its key whether the key is the apiKey or a header, W8 one gateway account header handed for models on two hosts refuses the host sent second, W9 local nodes handed `EMPTY` refuse the second while the empty key serves both, W10 an answer holding the key and a header under the lane's key-header name is refused whole — nothing sent, nothing bound (#1850); `test/side-query.test.ts` a gateway stub that requires the key and `Helicone-Auth` both is served; `test/backlog1399-terminal-detail-projection.test.ts` the new pairing row on all three lanes' 401 and in the table). Mutations (12, each red on its pin): `.gate-logs/mutations.md` of the car. `test/backlog1531-deployment-credential-one-root.test.ts` P18: an entry key binds without re-rooting the deployment key (both refusals side by side). Re-pinned: `test/swap-models.test.ts` (a swap mints the catalog line only), `test/route-adjudication.test.ts` (a derived seat whose own key moved host falls back with this cause; the refusal's rendering withholds query values), `test/brain-context-overflow-selfheal.test.ts` (the re-send check is exercised by a same-origin move; per-test fixture keys), `test/governance-codes.test.ts` (user-audience row), `test/module-size-ratchet.test.ts` (runtask.ts banked 1 500 → 1 385).
354
+ - **Known limits.** KL-1821-a (no in-process rebind; restart rebinds), KL-1821-b (the table holds 16 384 bindings; past that the least-recently-sent is forgotten), KL-1821-c (one value handed over on two hosts refuses the one sent second — a placeholder word and a gateway account header too; the no-credential door is no key — open to veto), KL-1821-d (a hand-built or copied `RouteCredential` carries no identity and binds nothing — nor any header of its own: the headers a per-model credential sends are the ones the resolver read, once), KL-1821-e (the binding is written when the request is committed to be sent, not when the host answers), KL-1837-a (one wide header vocabulary: an entry's own gateway header replaces the deployment key; a hook answer's gateway header rides and binds with its key).
355
+
356
+ ### A child never inherits an env entry whose value is a credential the deployment declared or the engine holds — `declareWithheldEnv` takes one table `{ names, values }` (#1754; security; behavior narrowing — open to veto; **BREAKING**: hard-breaking one, the declaration's shape; closed sets: `EngineCapability` +1 member `inherit_env.own_credential_values_withheld`; exports: +1 type `WithheldEnvDeclaration`, none removed; members: none) (@server @cli @client-core @test)
357
+
358
+ - **Before (7.51.1 – 7.51.2).** The own-credentials subtraction read NAMES only (`declareWithheldEnv(names)`). A value the engine itself held under another name went to the child: a shell that copied the user's `OPENAI_API_KEY` to `MODEL_API_KEY` for the engine had one of the two declared, and under `"all"` the other copy of the same key reached the model's shell.
359
+ - **Now.** One table, one subtraction, one finding, one disclosure, by name and by value. `declareWithheldEnv({ names, values })` (`src/core/secret-env.ts`): `names` as before; `values` are credential values the deployment holds. `inheritedHostEnv` (the one reading every spawn site uses) withholds, under every policy (`"all"`, `"scrub"`, an allow list), every inherited entry whose name is declared or whose VALUE equals a declared value or a value the engine holds — compared whole, as the same string (`withholdOwnCredentials`). The engine adds what it holds at five intake lines, each where the engine receives the credential: a first-party brain's construction credential — `apiKey` and its credential headers — in `brainConfigSnapshot` (`src/brain/route-adjudicator.ts`, every `createOpenAIBrain` / `createAnthropicBrain` / `createOpenResponsesBrain`); a model entry's own credential headers (`Model.headers`) in `resolveRouteCredential` (same file, the route's read of them, per request — taking a value in twice is taking it in once); a protocol peer's credential headers in `resolveProtocolHttpHeaders` (`src/core/protocol-http-auth.ts`, every MCP http server and A2A peer dial); the memory distiller's chat seat key in `openAiCompatChatSeat` (`src/core/memory-engine/distiller.ts`, a host that builds the seat itself) and in the `RunnerDeps` receipt at `new Runner` (`src/core/runner/construction-doors.ts`, `memoryConsolidationDriver.chat`, held before the seat is built at the first consolidation). A credential header is one whose name the credential vocabulary reads as a credential (`Authorization`, `X-Api-Key`, `Cookie`, …) or whose value carries one; it is held as written and, when it reads `<scheme> <credentials>`, as its credentials part (`credentialHeaderValues`). A `User-Agent`, a region, the per-task principal (the task's identity) are not held. The engine's values are kept for the life of the process and a re-declaration never removes them; the deployment's are replaced whole by its next declaration. The table keeps no value: each is kept as a SHA-256 digest on the process slot (`Symbol.for("sema.core.withheldEnvTable")`, which any code in the process can read). Each withheld entry is a finding of kind `own-credential` (unchanged kind); `config.secret_env_scrubbed` (once per process, unchanged) lists it in `detail.names` and `detail.ownCredentials` — names only, never a value. An allow list naming an entry that holds such a value is read, not refused: the entry is withheld at the spawn, with its finding (a value is a fact of the environment, not of the configuration); an allow list naming a declared NAME is still refused.
360
+ - **The declaration's refusals.** Not a plain object `{ names, values }` — a names-only array included: `declareWithheldEnv: the declaration must be a table { names, values } (got an array — a names-only list is no longer a declaration: pass { names, values })`; a member besides the two; a list that is a Proxy or whose element throws while it is read (each list is read once, by index — what is judged is what is kept); `values` not an array; a value that is not a string a variable can hold (empty, holding a NUL or an unpaired surrogate) or is no credential (the entry qualification of #1816, below); a value that is, in this process's environment now, the value of a variable every child is handed whatever the spawn passes (`NODE_V8_COVERAGE`, the MCP stdio transport's default set `HOME LOGNAME PATH SHELL TERM USER`, Windows' required variables — the same set a declared NAME may not be in, for the same reason: no reading can withhold it). Each throws and leaves the previous declaration standing; no refusal sentence repeats a value. Every engine intake applies the same judgment and takes nothing in: a brain whose credential is such a value is refused at construction, a request whose model entry carries one is refused before it is sent, a peer whose credential header is one is not dialed, a `new Runner` whose consolidation seat key is one is refused (`config.invalid_argument`, the variable named), and a directly built seat is refused with the seat's own code (`config.memory_consolidation_driver`).
361
+ - **Who breaks.** **Hard-breaking (the shape):** every caller of `declareWithheldEnv(names)` — server `src/server-secret-env.ts` (`declareServerWithheldEnv` / `withdrawServerWithheldEnv` and the reset) — throws at its first call until it passes `{ names, values }`; nothing passes silently. A consumer that keys a record by `EngineCapability` (server `src/capabilities/engine-capabilities.ts`, the capability facts) gets a compile error until it gives the new word its fact. `openAiCompatChatSeat` now reads its `apiKey` once at construction and sends that value (it read the seat object again on every call): a host that rotated the key by mutating the seat object builds a new seat instead; a key read that throws is refused coded, without the thrown value. **Behavior narrowing (open to veto):** a deployment whose environment holds a copy of a brain key or of an MCP / A2A credential header under a name it never declared stops handing that copy to the model's shell under `"all"` (and under `"scrub"` where the name does not look like a credential); a variable that happens to hold the same string as such a credential is withheld too, and named in the disclosure.
362
+ - **How to migrate.** server: `declareWithheldEnv({ names, values: [] })` keeps today's behaviour exactly; add to `values` the credential values the service holds that may be copied into its environment under other names (the engine already holds its brains' `apiKey`s and credential headers, its model entries' and peers' credential headers, and its consolidation seat's key). `RemoteEnvHost` reads `inheritedHostEnv(policy, findings)` already, so its children get the value arm with no further change; project `inherit_env.own_credential_values_withheld` onto `/v1/capabilities` once it declares the table. cli: gate on the new word (sending `"all"` safely needs both words). client-core: the capability-word reader takes the new member.
363
+ - **ExecutionEnv contract.** An `ExecutionEnv` that starts a child on this host composes the child's inherited environment with `inheritedHostEnv(policy, findings)` — the one place both arms apply — and adds its own grants (`shellEnv`, a per-exec `env`) on top; an implementation that reads `process.env` itself applies neither arm.
364
+ - **Pins.** `test/backlog1754-held-values.test.ts` V1–V8 (V8 is #1816's): a declared value under `"all"` / `"scrub"` / an allow list (a same-name entry with another value and an entry holding the value as a part are handed on); each of the three first-party brains built with only an `apiKey` hides every alias of that key from a real `NodeExecutionEnv` child (the child prints its environment's names only), its credential headers too, a non-credential header not; a model entry's credential header once a brain's judge routes it; the distiller seat's key, built directly and through `new Runner` (before any consolidation); an MCP server entry's static credential headers through the public `probeMcpServers` (a real dial) hide their aliases from a real child, a region header and the principal do not; a real `Runner` run with two Bash spawns under `"all"`: neither child sees the aliases, one disclosure for the run naming them, no value in the notice, the tool outputs or the session record; the table's refusals (no refusal sentence repeats a value; a list read once by index — an element answering twice, an array species, an overridden iterator change nothing) and replacement (the engine's values stay); the process slot holds no value; `holdOwnCredentialValues(` has exactly five callers in src; the capability word. `test/backlog1753-*.test.ts` re-shaped to the table (`{ names, values: [] }`), every name-arm cell unchanged. Mutations (removing the value comparison reds 20 of 25 cells; dropping the value finding reds the disclosure cells; each intake and the scheme split red their own cells; a raw value on the slot reds the slot cell).
365
+ - **Known limits.** KL-1754-a: a credential resolved per request (`TaskSpec.getApiKeyAndHeaders`, the per-model auth hook) is not in the value arm — the engine asks for it per request and does not keep it; the host that resolves it declares it (by name, or by value). KL-1754-b: the value is compared whole — an entry holding it as a part (`Bearer <value>` where the engine holds only the key, a URL, a base64 or other encoding, the value with a trailing newline) is not its value and is handed on under `"all"` (under `"scrub"` the value rule still catches some of these shapes: a `Bearer …` value, a URL userinfo). KL-1754-c: whether a held value sits in a variable every child is handed is judged when the value enters the table, not again at each spawn. KL-1754-d: an intake holds what it read at that moment; a model entry whose `headers` answer differently on a later read (an accessor, a mutation) is read again by the brain's request build, so the value sent can be one the table never took in. KL-1753-a stands: the engine process's own initial environment stays readable to same-user processes; the word speaks of inheritance.
366
+
367
+ ### A string is a credential the process withholds only if it could be one: one entry qualification for the declaration and the engine's intakes (#1816; security; behavior narrowing of the value arm — open to veto; hard-breaking: none beyond #1754's declaration; closed sets: `EngineNoticeCode` +1 `config.credential_not_withheld` (#1837); exports: none; members: none) (@server @cli @client-core @test)
368
+
369
+ - **Before.** `declareWithheldEnv` refused the empty string only, and every intake took any non-empty string. A brain key a local OpenAI-compatible gateway is told to be handed (`"true"`, `"1"`, `"EMPTY"`, `"none"`) was held as a credential, so under `"all"` every variable with the same value (`CI=true`, `FORCE_COLOR=1`) was withheld from every child for the life of the process and counted as an own credential; `Authorization: Bearer 1` held `1` the same way. A declared `" "` was accepted and withheld every blank variable.
370
+ - **Now.** One function judges a table entry for both doors (`notACredentialReason`, `src/core/secret-env.ts`): a string is **no credential** when it is white space only; fewer than 8 characters (`CREDENTIAL_MIN_CHARS` in `src/core/credential-vocabulary.ts` — the floor the vocabulary's vendor-token and bearer rows and the scrub's value rules already used, now spelled once); a flag or placeholder word in any case (the scrub's flag words `true false none null yes no on off enabled disabled required optional`, plus `undefined placeholder lm-studio not-needed sk-no-key-required`); or one character repeated three times or more, after at most one of the vocabulary's own token prefixes (`xxxxxxxx`, `00000000`, `sk-xxxxxxxx`, `ghp_000000000000`). The string is judged whole, as the table keeps it: an opaque key may hold a space (`r4NdomWord 42`) or end in a run of one digit (`a9B7c6D5e4F3g2H1-000`) and is a credential. One header-level reading: an `Authorization` / `Proxy-Authorization` value is `<scheme> <credentials>` by definition, so over a credentials part that is no credential (`Bearer 1`) the header hands the intake that part alone — nothing of it is taken in, and the part is said. **The declaration refuses** such a value and says which kind, never the value (`declareWithheldEnv: values[i] is not a credential value (got a string of fewer than 8 characters, which names no secret)`). **The five intakes** (a brain's `apiKey` and credential headers, a model entry's credential headers, a protocol peer's credential headers, the distiller seat's key, the Runner's consolidation seat key) **take it in no further**: the holder uses it as given — it is no bad value — and the variables that equal it stay with every child. Each such value is said once per process as the notice **`config.credential_not_withheld`** (audience operator, minter engine; `detail: { reason }` — the kind, never the value; #1837) through the engine's one notice throat: the intake that has the structured seat in hand — the `RunnerDeps` receipt at `new Runner` (`src/core/runner/construction-doors.ts`) — hands it to `RunnerDeps.onNotice`; the four intakes with no such seat (a brain's construction, a request's route reading, a peer's dial, a directly built distiller seat) print the same notice's line on `console.warn`, the throat's arm for an absent seat (KL-1816-a). The qualification is this table's alone — "could an environment value equal to this be a secret?" is a guess the table must make, because withholding a setting is a cost; a credential a request is HANDED is no guess, and the route law binds every non-empty one (#1848, which retires #1837's reuse of this judgment there: a seven-character gateway key followed its entry to another host). An absent or empty key says nothing. One reading made true with it (#1837): the credential vocabulary's record-key reading (`isSensitiveRecordKey`, `src/core/credential-vocabulary.ts`) said "`-` and `_` are one" but compared its non-secret exact names (`IDEMPOTENCY_KEY`, `PARTITION_KEY`, `SORT_KEY`, `CACHE_KEY`, `MAX_NEW_TOKENS`, …) as written — it now folds `-` there too, so a header or member spelled `Idempotency-Key` / `partition-key` is no credential: not held by the withheld-value intake (#1754's header door), not stripped or refused as a credential header (#1821), and no longer masked whole by the argument summary, the egress record-key mask and the terminal account's named-detail mask. The scrub's four value rules and the vocabulary's two rows spell the floor through the one constant (the regexes are unchanged); the assignment rule's flag-word list is the same table the placeholder rule reads.
371
+ - **Two readings of #1754's table made explicit.** A value listed twice in `values` is the one value (the declaration is a set; accepted, withheld once). An entry matched by its declared name AND by its value is one `own-credential` finding, not two.
372
+ - **Who breaks.** A caller that declares by value a string this judgment refuses now throws at the declaration (before: accepted). A real secret under 8 characters cannot be declared by value any more — declare the variable by name. A deployment whose brain key is a placeholder stops losing same-valued settings from its children (`CI=true`, `FORCE_COLOR=1` reach them again); the placeholder's own aliases are no longer withheld, which withholds nothing a secret could be.
373
+ - **Pins.** `test/backlog1754-held-values.test.ts` V8: the declaration's refusal for each kind and each word (one sentence each, no value repeated), the 7 / 8-character boundary, padding, whole-string acceptance of an opaque key with a space and of a mixed key ending in a run of one digit (through a header and through the declaration, under `"all"` and the scrub), the schemed-header reading; a declared placeholder still lets `CI=true` reach a real child while the table's real value is withheld; each of the six intake doors takes a placeholder in as nothing (five placeholders, with a `Bearer`-prefixed copy) and withholds a real key; the once-per-value report and its silence for an absent, empty or real key; #1837: the `new Runner` intake hands `config.credential_not_withheld` (operator, `detail: { reason }`, no value) to `onNotice` and prints nothing, and a seat-less intake prints that notice's own line word for word; a value listed twice; one finding per entry (under `"all"` and the scrub). Mutations, each red in its own cells: the intake takes everything in; the declaration without the judgment; the floor at 6; the scheme split moved into the predicate; no schemed-header reading; the schemed-header name widened to any name ending in `-authorization`; no repeated-character rule; any alphanumeric prefix before the repeated run; the extra placeholder words removed; the flag words removed; a report on every call; no trim; (#1837) the Runner intake drops its seat; the report never handed to the seat.
374
+ - **Known limits.** KL-1816-a.
375
+
376
+ ### A vendor's own spelling of a non-secret name is no credential header, and a hook answer that hands the key twice is refused (#1850 / #1858 / #1859; correction of #1837 / #1848 inside 7.52.0; hard-breaking on one answer shape; closed sets: `RouteRefusalCode` +1 `route.credential_answer_conflict`, `RoutePairingStatus` +1 `broken:credential_answer_conflict`; exports: none; members: `resolveRouteCredential` input +1 optional `keyHeader`) (@server @web-admin @cli @client-core @sdk @test)
377
+
378
+ - **Before (the 7.52.0 stage).** The route law reads which header is a credential through the credential vocabulary's record-key view, whose non-secret exemptions (`CACHE_KEY`, `IDEMPOTENCY_KEY`, `PARTITION_KEY`, …) matched a WHOLE name only. A vendor's prefixed spelling of the same non-secret thing — Cloudflare AI Gateway's `cf-aig-cache-key`, `X-Idempotency-Key`, `X-Partition-Key` — read as a credential: on a model entry it made the entry per-model and the deployment key stopped riding (a 401 at a real provider); a hook answer carrying only such a header did the same; `streamOptions.headers` carrying one threw `must not carry an auth header`. 7.51.x read two names only, so the key rode. Separately, a hook answer that handed an `apiKey` AND a header under the very name the lane writes the key in (`Authorization` on the OpenAI and Open Responses lanes, `x-api-key` on Anthropic) was resolved by letting the key win silently, and the superseded header's value was bound to an origin though it never left.
379
+ - **Now.** ONE vocabulary, three readings of the same declared names. The HTTP header-name reading (`isCredentialHeaderName`, internal — the vocabulary's `isSensitiveHeaderName`) reads a name the vocabulary declares non-secret as the whole name OR its TAIL at a `_` / `-` boundary when the front part names no credential word, case and `-` / `_` alike: `cf-aig-cache-key`, `X-Idempotency-Key`, `X-Partition-Key` are no credential header; `secret-cache-key`, `auth-token-range-key`, `xcache-key`, `cache-key-id`, `x-cache-key-secret` still go by their words. The env-name view and the record-MEMBER reading (`isSensitiveRecordKey`, the name of a field a structured record shows or masks) keep the names WHOLE (`APP_CACHE_KEY` is a secret variable and is withheld, and a record member of that name is masked). Every header consumer reads the one predicate: the route law's bag classification, the per-model identity, the strip, the user-header merge, the `streamOptions.headers` refusal and drop, and the withheld-value intake's name half. **Which header carries a credential is one reading of the NAME; the withheld table adds a value half** (a value that looks like a credential under any name) because withholding a value from a child costs little and a wrong guess there is cheap, while the law replaces the deployment key and binds an origin on what it reads — a guess there drops a key that should have gone out, and refuses a request. So the sentence in #1837 / #1821 now says what is true: the NAME reading is shared, the value half is the table's alone (KL-1850-a). **A hook answer is one credential and one header holds one value:** a request with a non-empty `apiKey` and a header of the lane's key-header name (any case, an empty header value included) is refused before anything is sent, as a route verdict with its own code, `route.credential_answer_conflict` (#1859) — `[invalid_request] route.credential_answer_conflict: model "<id>": the request carries both an apiKey and a "<header>" header, and this lane writes the apiKey in "<key header>" — …` — by the pre-flight judge and the request build alike (one sentence), and binds nothing. The lane's key-header name is declared once per brain and read by its lock and by the law (`resolveRouteCredential({ keyHeader })`, `createBrainRouteJudge({ keyHeader })`; both optional — a caller that does not know its lane gets no conflict verdict). A failover chain's pre-flight (`createFailoverBrain(...).adjudicateRoute`) reads it as it reads every refusal, leg by leg like its stream: an answer one lane contradicts and another lane takes (OpenAI, then Anthropic) is servable; when no leg is servable it answers the first leg's refusal verdict. The empty key `""` writes no header, so an answer of `{ apiKey: "", headers: { Authorization } }` is the header's credential and is not refused. The deployment arm is unchanged (`config.apiKey` beside a same-named `config.headers` entry: the key wins, #1848).
380
+ - **BREAKING (one answer shape; the old form, the new form, the migration).** Old: a per-model auth hook (`TaskSpec.getApiKeyAndHeaders`) answering `{ apiKey: K, headers: { Authorization: … } }` on the OpenAI or Open Responses lane, or `{ apiKey: K, headers: { "x-api-key": … } }` on the Anthropic lane, was sent with the key winning and the header silently dropped. New: that answer is refused (`[invalid_request] route.credential_answer_conflict: …`, the run fails, nothing is sent). Migrate: drop the header from the answer or drop the `apiKey` — the lane writes the key's header itself (answer the key as `apiKey`, or as the header, not both). A header of another name beside the key (`Helicone-Auth`; `x-api-key` on an OpenAI lane) is unaffected.
381
+ - **The refusal is a verdict, not a throw (#1859; closed sets: `RouteRefusalCode` +1 `route.credential_answer_conflict`, `RoutePairingStatus` +1 `broken:credential_answer_conflict`; exports: none; members: none).** Before (the 7.52.0 stage): the refusal above was a `BrainError` thrown out of `resolveRouteCredential`, so each caller improvised its own handling — the read face `routePairingStatus` (typed as a closed set of status words) threw on a well-typed answer; a derived seat's pre-flight read the throw as "no judgment" and neither fell back nor said so, where the same seat falls back on any other refusal; a failover chain's judge caught `BrainError` from a leg, so "no leg serves" surfaced as a throw or as a `broken:*` status depending on leg order; and a `before_provider_request` handler that added the key's header after the pre-flight got a sentence blaming "the per-model auth answer", which never held it. Now `resolveRouteCredential` records the conflict beside the credential it resolved (a resolution fact, as the credential's identity is — never a member of the returned object) and `adjudicateModelRoute` rules it first, before any origin is read: one refusal shape for every caller, no branch of its own anywhere. The request gate turns it into `[invalid_request] route.credential_answer_conflict: …` exactly as it turns every refusal; `routePairingStatus` answers `broken:credential_answer_conflict`; a derived seat (compaction summary, auto-mode classifier, prompt suggestions) falls back with `route.fallback_to_primary` (`cause: "route.credential_answer_conflict"`), or, where its deployment declined a fallback, keeps the seat and announces `route.classifier_unroutable`; a failover chain reads a refusing leg as a verdict again (first OK leg wins; all refused ⇒ the first refusal in leg order; an unjudgeable leg without an OK ⇒ `undefined`), and a throw from a leg's judge — a `BrainError` included — escapes, as before #1850. The fix names what the request carries, not who put it there. A consumer holding a closed table over `RouteRefusalCode` or `RoutePairingStatus` (a roster annotator, a refusal renderer, a contract test forwarding the conformance corpus's `expect.code` — the corpus itself has no vector for it, as it has none for the origin binding: both are facts of a credential the engine resolved) adds one row. Pins: `test/backlog1859-answer-conflict-verdict.test.ts` (15 cells: V1 / V1b / V1c / V2 the law and the gate, the conflict ruled before the origin, the optional `keyHeader`, nothing bound; R1 ×3 lanes the read face; D1 a real Runner's compaction seat falls back and the task completes on the primary, D2 both seat postures; F1–F4 the failover chain in every leg order, a `TypeError` and a `BrainError` from a leg's judge escaping; S1 the side-query verb returns the failure with the code; P1 a `before_provider_request` handler adding `Authorization` beside the hook's key — the build refuses with the neutral sentence, nothing sent); H4 / W10 / F8b and the request-params resolver combo re-meant to the verdict.
382
+ - **Who breaks.** A hook that answers both forms above (server's key resolver answers `{ apiKey }` only — inferred from its source, not run). A consumer that keys on the old silent supersession. Anything that read a header bag through `hasAuthCarrier` (the public export whose answer moved; `isCredentialHeaderName` and `isSensitiveRecordKey` are internal, not package exports) and took a vendor-prefixed `…-cache-key` / `…-idempotency-key` / `…-partition-key` header for a credential: it is none now. No redaction consumer moved: the egress structure walk, the argument summary and the terminal account read a record member's name whole, so `pagerduty_routing_key` / `x-routing-key` / `cf-aig-cache-key` members are masked or omitted by name as before (#1858). **Export surface:** names unchanged; `resolveRouteCredential`'s input gains one optional member `keyHeader`; no removal; minor-compatible (`isSensitiveHeaderName` is added to the internal credential vocabulary module, not to the package).
383
+ - **Pins.** `test/backlog1850-vendor-header-names.test.ts` (13 cells; V2d the rule is one linear pass, a member name made of thousands of tails is answered without recursion): V1 the three vendor spellings are no credential to any consumer, V1b every declared name under any prefix depth / `-` / `_` / case, V2 real credential names (`Helicone-Auth`, `x-api-key`, `Authorization`, `api-key`, `x-goog-api-key`, `Cookie`, …) still are, V2b the boundary (`xcache-key`, `cache-key-id`), V2c a front part holding a credential word keeps the name a credential (`secret-cache-key`, `auth-token-range-key`), V3 / V3b the env view reads whole names and the three readings differ on `CF_AIG_CACHE_KEY` only where the header reading says so, V4 a vendor-tailed record member is masked by its name in the egress walk, V5 `streamOptions.headers` accepts the three names and still refuses `Helicone-Auth` / `x-api-key` / `Authorization` / `X-Routing-Key` at both seats; `test/backlog1821-credential-origin-binding.test.ts` F1 ×3 (a real OpenAI brain, a real listener: the deployment key still goes out with the entry header, with a headers-only answer, and a per-model key rides beside it), F1b the gateway account header still takes the deployment key off, F7 the law reads names only (a credential-shaped value under `X-Partition-Key` is withheld from children and rides beside a per-model key), H4 ×3 lanes (any case, empty value, judge and build one sentence, the lock name read off the wire = the name refused, controls: no key beside the header, the empty key, the other lane's key header, `x_api_key`), W10 the Runner failure with zero requests and nothing bound, F8b a failover chain (OpenAI contradicting + Anthropic fine: servable and the stream moves on; every leg contradicting: the first refusal, judge and stream in one sentence); `test/request-params.test.ts` the OpenAI-lane resolver combo is refused.
384
+ - **Correction (#1858; security; the tail exemption was first written into the reading the redaction consumers share).** The first form of the tail exemption lived in the record-key reading that the egress structure walk, the activity argument summary and the terminal account also use, so a structured record showed an opaque value verbatim under `pagerduty_routing_key` / `pagerduty-routing-key` / `x-routing-key` / `cf-aig-cache-key` / `x_partition_key` (egress, both tiers; the activity argument) where the tree before it masked or omitted it. The exemption now belongs to the HTTP header-name reading alone (`isSensitiveHeaderName`, one implementation, the name's source as its parameter); the record-member reading takes a declared non-secret name whole (`partition_key`, `idempotency_key`, `CACHE_KEY` are shown, as before). Pins: `test/backlog1858-redaction-keeps-whole-name-exemption.test.ts` (names × 4 redaction consumers; the header reading's cells; the two readings differ by the tail exemption and nothing else). One consequence of reading a header NAME inside a record as a member: a `headers` object in a tool result masks `X-Partition-Key` / `cf-aig-cache-key` by name (it over-masks a non-secret; a whole-name `Idempotency-Key` is shown). No earlier release carries the wider reach (inferred: the section above is the first to describe it).
385
+ - **Known limits.** KL-1850-a (a credential-shaped value under a name that is no credential is not bound by the law); KL-1837-a restated (the tail exemption is the header-name reading's).
386
+
387
+ ### A routing key is a secret: `ROUTING_KEY` leaves the list of non-secret names, so every spelling of it is masked wherever a name decides (#1865, first leg; P2; security; behavior narrowing — open to veto; **hard-breaking**: a bare `X-Routing-Key` header is now a credential header name (a model entry carrying it is per-model; `streamOptions.headers` refuses it; one value sent to two origins is refused at the second), and an environment variable named `ROUTING_KEY` is no longer handed to a child; closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
388
+
389
+ - **Before.** `ROUTING_KEY` was one of the credential vocabulary's exact non-secret names, beside `PARTITION_KEY`, `SORT_KEY`, `RANGE_KEY`, `IDEMPOTENCY_KEY` and `CACHE_KEY` (written for a message queue's routing key). A paging service's events API takes its integration key as the request-body member `routing_key`, so a record carrying one — a tool call's arguments, a tool result, an error's detail — showed the value verbatim in the activity argument summary and at both egress tiers (`routing_key`, `ROUTING_KEY`, `routing-key`, `Routing-Key`: all four on the tree before this entry; read by tsx probe on the 7.52.0 stage, not by a release). The same list feeds three readings and moved all three: the record-member reading, the HTTP header-name reading and the environment-variable reading.
390
+ - **Now.** The name is off the list. `routing_key` and its three spellings are credential record members (the egress walk at both tiers returns the marker whole, the activity summary omits the field, the terminal account masks it); nested members too. `pagerduty_routing_key` is masked as before; `partition_key` and the other four declared names are shown as before. **The cost, accepted and open to veto:** the one list cannot tell a paging service's integration key from a message queue's routing key, so (1) a queue's routing key in a record is masked in the activity summary and at egress; (2) a header named `X-Routing-Key` (or any `…-routing-key`) is a credential header name — on a model entry it makes the entry per-model (the deployment key stops riding with it), its value is bound to the first origin it is sent to, and `streamOptions.headers` refuses it (`must not carry an auth header`); (3) an environment variable named `ROUTING_KEY` is withheld from a child process (a finding like any credential name). Reading the declared non-secret names by SOURCE — an HTTP header, a record member, an environment variable — is #1865 itself and is left to 7.53.0 (KL-1865-a).
391
+ - **Who breaks.** (1) A deployment that sends a bare `X-Routing-Key` header (a queue gateway): on a model entry's `headers` the entry is per-model now (the deployment key stops riding with it, F9); in `streamOptions.headers` it is refused at construction and at `setStreamOptions` (`not an auth channel`). Until the names are read by source (#1865, 7.53.0, KL-1865-a) there is no way to send it beside the deployment key: either (a) rename the header, where the gateway accepts another name, or (b) let it be that model entry's credential header and send no deployment key with that entry — the two cannot ride together. (2) The same routing-label VALUE handed to two origins in per-model answers is one credential at two hosts: the law binds it to the first origin it goes to and refuses the second before a byte leaves, whatever apiKey that one carries (`route.credential_origin_mismatch`, F9b; found by the review round, reproduced on the wire) — give each origin its own label. (3) A child process that reads a bare `ROUTING_KEY` variable no longer receives it — rename the variable to a name that holds no credential word, or read it from the parent. (4) A reader of a tool result or an activity line that expected a queue's routing key in the clear sees the marker. Nothing that worked for a secret changes: a value that was shown verbatim under these names is hidden now, never the reverse.
392
+ - **Pins.** `test/backlog1865a-routing-key-is-a-secret.test.ts` (R1–R3: four spellings × the activity summary and both egress tiers, and the nested body at both tiers; C1–C3 controls; N1–N5 the list, the member reading, the header reading and the environment reading). Re-meant cells: `test/backlog1850-vendor-header-names.test.ts` (the vendor spellings lose `X-Routing-Key`; V5 refuses it), `test/backlog1858-redaction-keeps-whole-name-exemption.test.ts` (the routing-key family moves to the credential rows; `X-Routing-Key` is a credential header in H1), `test/backlog1821-credential-origin-binding.test.ts` (F1 and F7 use the names still declared; new F9, a real brain and a real listener: `X-Routing-Key` on the entry takes the deployment key off the request; new F9b, two real listeners: one routing label in two per-model answers is refused at the second origin, a label of its own per origin is served), `test/rb219-secret-env-exemptions.test.ts`, `test/backlog1072-secret-env-plurals.test.ts`, `test/secret-env-name-table.test.ts` with its fixture (the name delta gains the row `ROUTING_KEY` keep → drop; the 7.34.0 oracle keeps its own frozen exemption list).
393
+ - **Known limits.** KL-1865-a.
394
+
395
+ ### A `.catch` that reuses the run its handler started below waits on it with its own clock stopped (#1818; behavior change — a `.catch` the kernel failed closed after its own second while it waited on the body's run now answers; closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
396
+
397
+ - **Before.** A handler that called `next(e)` without waiting and then threw left that run going; its `.catch`, asking `next(e)`, got the same run — and the `.catch`'s own 1 s budget kept running while it waited. On `tool.check` (a refusing station) a judge below that took longer than a second failed the dispatch closed at about 1011 ms (`its .catch ran past its 1000ms budget`), where the same `.catch` over a body that had not started anything answered after the same wait.
398
+ - **Now.** The `.catch` pauses its clock for as long as the run it reused is in flight and resumes when that run is on record, the pause a handler's own `next` takes (`runCatch`'s `replay`, `src/core/mods/kernel/dispatch.ts`). A reused run costs a `.catch` no more than a run it started itself; its own work still counts, before and after the wait.
399
+ - **Who breaks.** Nothing that worked: a dispatch that was failed closed for waiting on the body's run now ends with the `.catch`'s answer. A `.catch` that waits on a run that never settles waits as long as that run does (the dispatch's own abort and the run's own bounds apply, as for a handler's `next`).
400
+ - **Pins.** `test/mods-kernel-296.test.ts`: the reusing `.catch` over a 1.2 s judge answers; the control with a fresh `next(e)` answers the same; the clock starts again when the run is on record (a `.catch` that then spends more than its second is failed closed, named), and when the run failed. Mutations: no pause (the reuse cell red); no resume (both overspend cells red); resume on a fulfilled run only (the failed-run cell red).
401
+ - **Known limits.** KL-1818-a.
402
+
403
+ ### A `*` in a stored row's `toolAllow` (rule grammar 0) is one entry that reads as nothing, not a verdict on the whole list (#1790; behavior change — back to the 7.50.x reading, open to veto; closed sets: none; exports: none) (@server @cli @test)
404
+
405
+ - **Before.** A session-rules row stored before the grammar stamp (a row carrying a store's `rev` and no `grammar`) whose `toolAllow` held a `*` was read with the whole allowlist voided: the list's other entries read as nothing too, the allowlist stayed present and empty, and every call of the session was refused with only "it is not in the session-rule allowlist" — no word of `*` or of the grammar — and the one advisory the run raised was the generic name audit (`config.toolpolicy.unmatched_names`: `"*"` matches no tool, "enforcement is unchanged"), which said nothing of the voided list. 7.50.x compared each entry as a tool name as written: `*` matched no tool and the entries beside it admitted what they named (`v7.50.2:src/core/runner/session-rule-policy.ts` — `new Set(rules.toolAllow)` and the name match).
406
+ - **Now.** One reading, the one `earlier_grammar` already had: in grammar 0 a `*` (or a `*(content)` spelling) is an allow entry that names no tool and admits nothing, by itself (`earlierGrammarReads`, `session-policy-store.ts` — the predicate `compileSessionRuleBucket` and the write judge both ask); the entries beside it are read as written. `["Read","*"]` admits `Read` and refuses the rest; `["*"]` alone is an allowlist that is present and admits nothing, so every call is refused. The entry is announced like every other entry its grammar reads as nothing — `config.session_rules_earlier_grammar`, once per distinct set per layer (`earlierGrammarAnnouncer`; no second sentence for a host to mint). The advisory says what `*` is: no allow rule in any grammar (a write holding it is refused), so the way out is to leave it out; a parenthesized entry beside it keeps the rewrite way. A run-time refusal under an allowlist its earlier grammar reduced now ends with the clause `; the session's rules are stored in rule grammar 0, where "*" names no tool and admits nothing` (a parenthesized entry is counted, never quoted — a stored entry's text is data). The write judge answers the same for the same entry: a stored grammar-0 row handed back is refused `earlier_grammar` ("leave the entry out"), and the tighten comparison reads the row's effective entries, so writing the entries a row enforced is no widening. The engine's own grammar is unchanged: a stamped row or a fresh value holding `*` still voids the list (and a fresh write holding `*` is refused `wildcard_all`).
407
+ - **Who breaks.** Nothing that worked. A session whose old row held `*` beside other entries runs those entries again (the 7.50.x reading) where 7.51.0 – 7.51.2 refused all of it; a host that stripped `*` out of such rows before upgrading (the SQL twin's migration) can drop that step — the engine now reads them. `KL-1761-b` is closed. `wildcard_all` stays in `AllowEntryInertReason`: it is still the reader's word for `*` in the engine's grammar, in the deny direction, and in the skill / request / deployment allow lists, none of which this changes; the closed set is not touched.
408
+ - **Pins.** `test/backlog1790-grammar0-star.test.ts` A1–A7 (the layer, the compile, the refusal sentence, the stamped control, the real Runner lane over a stored row, the advisory, the write and the tighten comparison); `test/backlog1761-rule-row-grammar-version.test.ts` unchanged and green.
409
+
410
+ ### A recursive shell read is judged by the root it names, never by walking the tree below it (#1714; ruled C-R478 — zero friction; behavior change, loosening; closed sets: none; exports: none) (@cli @server @test @web-client)
411
+
412
+ **What changed.** `grep -r` / `rg` / `diff -r` and the other contents-disclosing recursive forms used to be judged by what the station could enumerate under the root (256 directories / 8192 entries): a credential-class file or a deny row met on the walk refused the command whole, a tree past the bound asked a mandated question on every face and mode. Both arms are gone. A traversal root is judged by what the command NAMES: the root itself (an operand, judged like any other), and the one thing a deny table can say about a subtree without opening it — the PLACED rows (a home-anchored or rooted deployment row, and where it really leads) whose location the root contains. Which files exist below the root is never asked, so a small tree and a wide tree get the same answer, and the answer no longer depends on the node's type at judgment time (the placed-row relation is a relation on locations and is consulted for every traversal root). The read-only face and the full shell mount the same deny seat, so they answer alike.
413
+
414
+ **Who breaks.** Three things a recursive read now reaches that it did not: ① a credential-class file somewhere below the root that only a SPELLED row covers (`.ssh/`, `id_rsa*`, `.netrc`, `.git-credentials` — rows with no place meet only what a command names; `cat sub/.ssh/id_rsa` and `grep pat sub/.ssh/id_rsa` stay refused), ② a deployment deny row written in the spelled form (`secrets/**`, `**/.env`) for a directory or a file somewhere BELOW the root: `grep -r x sub` does not name `sub/secrets`, so the walk reaches it (`grep -r x secrets` and `grep -r x secrets/inner` name it and stay refused; a rooted or home-anchored row, `<root>/secrets/**` / `~/secrets/**`, is placed, so any root that contains its location is refused as before), ③ a link below the root that leads outside the project, including to a home-anchored credential file: `grep -R` / `-S` follows it and the target's bytes come back (the containment judgment, like the deny rows, is made on the root, not on members). A bind mount of the home below the root behaves the same way: the identity seat answers only for a path it is asked about, and judging a root asks about none of its members, so `grep -r x .` reads the home's `.npmrc` there (KL-r2-a; a host-declared mount point that places the home-anchored rows at the mount's spelling is #1814). The Grep TOOL is not affected: it prunes by path before reading. The traversal forms read this way are every contents-disclosing walk the station models: `grep -r` / `-R` / `--recursive` / `-d recurse` (and `egrep` / `fgrep`), `rg` / `ag` / `ack` (with no path operand they walk `.`: `rg pat` — #1786), `diff` (member-wise, `-r` or not), `tar`'s archive-building keys (`c` / `r` / `u`), `git grep`, a container mount source (`docker run -v <src>:…`, `podman … --mount source=<src>`), and each root a glob expands to — the glob's own expansion keeps its bound (256 directories / 8192 entries), unchanged. A deployment that relied on the walk refusing ① or ③ has no equivalent switch (the enumeration arm is retired, not disabled); the structured Grep tool and named sub-directory roots are the remaining shapes.
415
+
416
+ **Type member renamed (mechanical BREAKING, no implementer downstream).** `ReadDenyMatcher.placedRowUnder?` → `ReadDenyMatcher.rowUnderRoot?`: the relation widened from placed rows to every segment row the root names or contains (rule rows still never answer it), so the name follows the meaning; a hand-built matcher that implemented the old member compiles unchanged (optional) and simply names no row.
417
+
418
+ **How to migrate.** Nothing to change for the common case — the friction this removes (a refusal in a small project and a question in a large one for the same `grep -rn TODO .`) is the point. Deployments: spell directory deny rows rooted or home-anchored. Reviewers of a recursive read's result: a link below the root is followed, so a project that links out of itself exposes what it links to.
419
+
420
+ ### A home view nobody could read is no reading: every structured face refuses past it, as the shell station already asks (#1686; security; behavior narrowing — open to veto; hard-breaking: none; closed sets: none; exports: none; members: none at the package root)
421
+
422
+ **What changed.** The home-anchored deny rows (`~/.npmrc`, `~/.sema/settings.json`, …) meet a path through a bind mount or firmlink of the home by its HOME VIEW — the path respelled through the home's directory chain, matched by the environment's identity seat (`fileIdentity`). When an identity lookup the view stands on does not answer (it throws, times out, or fails with an error that is no conclusive "not there"), the view is unknown. The shell station already read that as a judgment it could not finish (`unanswered`: the mandated question on the full shell, refused on the read-only face). The structured faces read it as "no view" and judged the spelling: `Read <alias of the home>/.npmrc` served the home's token, as did a Grep or Glob over a tree holding such an alias. Now there is one answer per path, `{ view?, unread? }`, and every structured place that judges a path by its home view reads it. The target judgment of Read / Glob / Grep / RepoMap (and the write tools' write-deny judgment) refuses with `read_path_denied` / `write_path_denied`: no pattern is named (no row matched), and the sentence names the lookup that did not answer. A Glob / Grep / RepoMap walk, or ripgrep's output check, that meets such an entry refuses the whole call. A search scope whose view is unread refuses the search before either leg reads. The explicit-file lane in particular reads its target without judging it again. A missing file's sibling suggestion is not offered past it. A deny row the spelling or the view still meets refuses by that row, as before.
423
+
424
+ **Who breaks.** While the DECLARED home's chain does not answer, every structured read whose view is asked is refused until it answers (the chain is asked again by each judgment). While one level under a path does not answer, that path and everything below it are refused. An AUXILIARY home (the account home beside the declared one, or a host's declared configuration root) that does not answer stays its placement's best-effort loss, as documented: paths through an alias of it are judged as spelled, and this is said once. The shell station now reads that same answer, so a command whose reads do not pass through such a home is no longer asked on its account. That is a loosening of the 7.49 reading, one reading with the structured faces (`backlog1672` ⑩ ③ re-pinned).
425
+
426
+ **How to migrate.** Nothing for an environment whose identity seat answers. A host adapter whose `fileIdentity` fails for reachable paths should return `not_supported` (the lexical reading, unchanged) or a conclusive `not_found`, not a generic error.
427
+
428
+ ### An environment with no home holds the home-anchored rows wider on a recursive read's root too: the root may hold them (#1803; ruled C-R480 — open to veto; security; behavior narrowing; hard-breaking: none; closed sets: none; exports: none; members: `ReadDenyMatcher.rowUnderRoot?` may answer `{ pattern, unplaced: true }`, `BashReadonlyRootBoundary.denyUnder?` (the member `ShellReadBoundary` inherits from it) may answer `{ unplaced }`) (@server @cli @test)
429
+
430
+ **What changed.** When the environment declares no home (`homeDir` absent: an `inheritEnv` allowlist that withholds `HOME`, a remote adapter that declares none), or its placement did not answer, the home-anchored rows cannot be placed, and a direct read already met their run anywhere (`cat .npmrc` refused). On a recursive read's root, though, an unplaced row was read as a spelled row: `grep -r X .` with the working directory holding the home's files ran unasked and returned `.npmrc` / `.sema/settings.json` bytes, while `grep -r X .sema` was refused. The root relation now has three answers. A row the root names or contains is a deny, as before, and an unplaced row whose spelled run the root names (`.sema`) is one of these. Otherwise, an unplaced home-anchored row is one the root MAY hold (`unplaced`, after every named row). The shell station reads `unplaced` as its `unanswered` finding: on the full shell it is the mandated question on either read face (a person answers it; no allow, mode or bypass releases it), and on the read-only face it is refused. A re-entered text (`sh -c '…'`) counts it as it counts a deny hit.
431
+
432
+ **Who breaks.** In an environment with no home, every contents-disclosing recursive read (`grep -r`, `rg`, `diff -r`, `tar c`, `git grep`, a mount source, …) is asked on the full shell and refused on the read-only face, whatever its root. This is the stated cost (C-R480). A deployment that declares a home is not affected. A host that consumes `ReadDenyMatcher.rowUnderRoot` or `BashReadonlyRootBoundary.denyUnder` (also read through `ShellReadBoundary`, which extends it) itself now sees the third answer. Reading it as a deny is the stricter side.
433
+
434
+ **How to migrate.** Declare the home (`ExecutionEnv.homeDir`; for `NodeExecutionEnv`, pass `HOME` to the child) to place the rows and lift the question. Test fixtures that compile the table under no home to test something else compile it placed (`test/read-deny-tiers-shared.ts` `HOME_ELSEWHERE`).
435
+
436
+ ### A walker given only a pattern walks `.` — the root is judged there, on every reader (#1786; security; behavior narrowing — open to veto; closed sets: none; exports: none; members: none) (@cli @server @test)
437
+
438
+ - **Before.** `rg` / `ag` / `ack` declared no pattern operand and no options, so the pattern was read as a traversal root and the walk of the working directory a pattern-only invocation makes (`rg pat`, `rg -e pat`, `ag pat`, `ack pat`) was never judged: a placed deny row under `.` (rooted or home-anchored) let the walk run and the file's bytes came back, where `rg pat .` was refused. The same root reading hid `.` where the deployment does not list `grep` (`grep -r pat`), where a pattern FILE was given (`grep -r -f pats`, `rg -f pats`: the file was the only root) and where an option written after the pattern took a separated value (`grep -r pat --include '*.ts'`, `rg pat -g '*.ts'`: the value was the only root), on the listed and the unlisted reading alike. Present before #1714.
439
+ - **Now.** One root rule for both readings (`RootsGathered`, `shell-read-argv.ts`; the two copies are one): the operand in the row's first-operand slot — the PATTERN, a lone `-` there too — is no root; a value that fills that slot (`-f <file>`) is a file the program reads, still judged as a read, and no root; with no root the walk is `.`; a word an option written after the pattern may own is read both ways — a root, and `.` beside it (a path operand no option can own keeps the walk on that path alone: `rg pat -g '*.ts' sub`). The walker rows declare their pattern slot (`rg -e` / `--regexp` / `-f` / `--file` / `--files`, `ag -g`, `ack --match` / `-f` fill it) and only the value options whose arity is not in doubt (ack's context counts are declared attached-only and read both ways — #1792 —, ag's short counts require their value and its long names take theirs attached only; `ack --ackrc` / `--files-from`, `ag -p`, `rg --ignore-file` values are reads); an option a row leaves undeclared is read as taking no value — the reading that swallows no path — and, on a listed reader, puts the PATTERN slot word in doubt: it is judged as a path too (it may be that option's value). Where the options end is the walk's own reading (`program-read-model.ts` `walkArgs`): a `--` an option takes as its value (`rg -r -- -e x <file>`, `grep --label -- -e x <file>`) ends nothing, so the `-e` after it still fills the slot and `<file>` is judged — a grep defect of the same kind, closed with it. An unlisted program's words are all still candidates (the verb-blind over-approximation is unchanged); a listed walker's pattern is no path, as grep's.
440
+ - **What narrows.** `rg pat` / `ag pat` / `ack pat` / `grep -r pat` (grep unlisted) / `grep -r -f <file>` / `rg pat -g '*.ts'` are judged on `.`: a placed row under the working directory refuses them on both faces, before anything runs. A listed reader's pattern behind an unexplained option meets the deny rows and the roots as a path. On the read-only face with ack on its allow list, `--files-from` (what it opens is not in the command line, as find's `-files0-from`) is refused, and so is an unquoted glob or expansion among ack's words (it may expand into it).
441
+ - **Known residue.** (Closed since, as three forms of one hole: a count the row declares attached-only written apart from its option — `ack -C 3 pat`, `ack --context 3 pat`, `grep -r --context 3 pat` — #1792; a switch letter in front of a value letter in one short cluster — `rg -ie pat`, `rg -nC 3 pat`, `ag -iC 3 pat` — #1800; ag's short counts, which REQUIRE their value and were declared attached-only — `ag -C 3 pat` — #1792.) rg's options follow `rg --help` (ripgrep 15.1.0), ag's / ack's only the ones whose arity is certain, and the binary gate runs the walkers' walk cells, not their options; `rg --files` / `ack -f` list names but are still read as a contents walk (the stricter side); rg `--pre` / `--hostname-bin` and ag / ack `--pager` (a program the walker runs) are undeclared on purpose; a value they take written before the pattern (`ack --pager less pat`) moved the slot onto the value and left `.` unjudged — closed by #1819 (see the #1800 section: a long name the row declares nowhere may take the next word; a short letter it declares nowhere is still read as a switch, KL-1819-a).
442
+ - **Pins.** `test/backlog1714-literal-root.test.ts` #1786 ①–⑥ (both seats, listed and unlisted, the real shell lanes; ⑥ = the review round's forms); re-pinned `backlog222` ①, `backlog945-plan` F2 (the pattern is no root; `rg KEY` walks `.`), `backlog764` G-2d / G-6-c (`grep -r -f <file>`: the file is read, the walk is `.`); the equivalence tables' classes `1786-walker-pattern-slot` / `1786-traversal-roots-one-rule` and `1786-pattern-slot` / `1786-cwd-root` / `1786-slot-in-doubt` / `1786-ack-files-from` (18 grep-family entries leave the table — `egrep -Q /v1 a/b`, `fgrep --efm /v1 a/b` and their kind: behind an unexplained option that may take the next word, the slot word reads as the old tables read it; an option carrying its own value, `grep -i.txt pat f`, owns no next word and leaves the slot the pattern's).
443
+ - **#1792 · Before.** `ack -C 3 pat` / `ack -A 2 pat` / `ack --context 3 pat` / `ag -C 3 pat` / `grep -r --context 3 pat`: the rows filed a context count attached-only (what ag's long names and BSD grep do), so the separated `3` was read as the pattern and `pat` as the root. ack (an optional integer), ag's SHORT counts (`A:B:C:` — required) and GNU grep (`--context` requires its value) take that `3` as the option's value — the pattern is `pat` and the walk of `.` was judged by no reader, so a placed deny row under `.` let `ack -C 3 pat` run where `ack -C3 pat` was refused.
444
+ - **#1792 · Now.** A BARE option that some grammar of the row declares as taking a count attached-only, followed by a word spelled like a count (`3`, `+3`, `3_0`), may own that word: the argv walk (`bareOptionValueOf`, `program-read-model.ts`) hands the word over as a value read both ways, and the one root rule (`traversalRoots`, `shell-read-argv.ts`; the listed and the unlisted reading alike) judges every reading of who owns it. A word no option can own is a root as before; when the pattern-slot word may be owned, the first word no option can own is the pattern in the other reading, and when every remaining word may be owned the walk of `.` is a root too (`ack -C 3 pat`, `ack pat -C 3`, `ack -C 2 -A 3 pat`, `ack -C 3 -- pat`); the pattern of that reading may be a lone `-` (`ack -C 3 -` searches `.`: the `-` is standard input only in the reading where the 3 was the pattern); `ack -C 3 pat sub` is still judged on `pat` and `sub` alone. An option the row does not declare (`rg -n pat sub`, `ack -i 404 sub`), a count attached (`-C3`, `--context=3`), a word another option took as its value (`ack -C 2 --match -C 3`) and a non-count next word (`ack -C pat sub`) own nothing. Rows: ag's short counts (`-A` / `-B` / `-C`) are declared with the required value ag's short-option string gives them, so `ag -C 3 pat` reads like `rg -C 3 pat`; ag's long names stay attached-only.
445
+ - **#1792 · What narrows.** The forms above are judged on `.` as well: a placed deny row under the working directory refuses them on both faces. `ag --context 3 pat` (ag's long names take a count attached-only, getopt_long's optional argument) is judged on `.` too although ag leaves the `3` an operand — one grammar row cannot be both parsers and the stricter reading is taken, with no ag special case (KL-1792-a). Pins: `test/backlog1714-literal-root.test.ts` #1792 ①–⑪ (both seats, listed and unlisted; the ack / ag cells are judgement cells, neither binary being installed where the pins run); ⑦–⑨ are this paragraph's review-round forms (a pattern `-`, `3_0`, a word another option took).
446
+
447
+ ### A switch letter in front of a value letter in one short cluster no longer hides the walk of `.` (#1800, #1819, #1826; security; behavior narrowing — open to veto; closed sets: none; exports: none; members: none) (@cli @server @test)
448
+
449
+ - **Before.** `rg` / `ag` / `ack` declare their value options but not their switches, and the three readings of one short cluster disagreed about it: the option-slot reading stopped at the first letter the row does not declare (`-ie` = unexplained, read the speculative way), the pattern-slot reading stepped over it (`-e` supplies the slot, so `pat` was an ordinary operand), and the next-word reading looked only at the cluster's last unreadable letter. `rg -ie pat`, `-ne`, `-we`, `-nf pats`, `-nA 3`, `-nC 3`, `-im 1`, `-ig '*.ts' pat`, `-nt ts pat`, `ag -iC 3 pat`, `-ig`, `-ip x`, `ack -iC 3 pat` therefore took the value for an operand, found a root in it, and never judged the walk of `.`: with a rooted deny row under the working directory all 13 shapes were let through on both seats, listed or not, and the real `rg -ie KEY` / `rg -nC 1 KEY` returned the member's bytes, where the split spelling `rg -i -e KEY` was refused.
450
+ - **Now.** One reading of a bare option's value, in the argv walk (`bareOptionValueOf`, `program-read-model.ts`; the cluster is read as getopt reads it — a letter the row does not declare is a switch, the first value-taking letter owns the rest of the token, so the next word only when it ends the token — and a long name as any name it abbreviates): the word an option may own is handed over as a value read both ways, and both readers take their roots from that one stream (`traversalRoots`). `rg -ie pat` is `rg -i -e pat`: the value is the pattern, no word is left over, the walk is `.`; `rg -ie pat sub` is judged on `sub` alone. A `--` an option may have taken as its value (`rg -ir -- -e pat`, `rg elsewhere -r -- -e pat`: the `--` ends nothing, so the `-e` after it supplies the pattern and the first operand is a path) leaves the program's parse and the walk's apart for every word after it, which one walk cannot state twice: the slot word and `.` are judged beside the roots — stricter than either parse alone (`rg -ir -- pat sub` judges `.` too: KL-1792-c). An owned `-` is a value, not standard input (`rg -ie -`, `rg pat -g -` walk `.`; `rg pat -` still reads standard input). The speculative reading of an unreadable cluster (its payloads as candidates) is unchanged. The `readOptionSlot` / `suppliesFirstOperandSlot` / `selectedOptions` cluster scans are untouched; unifying them behind one split is not done here.
451
+ - **What narrows.** The 13 shapes and the clusters of their kind are refused where the split spelling was; a cluster whose last letter is a declared value letter and that leaves no operand behind walks `.` (`rg -nA 2 pat`). Nothing else changes: a switch cluster (`rg -n pat sub`), a cluster with an operand left over (`rg -nC 3 pat sub`) and an attached value (`rg -nC3 pat`) read as before.
452
+ - **Pins.** `test/backlog1714-literal-root.test.ts` #1792 ⑩ (the 13 shapes and their kind, both seats, listed and unlisted; the leftover-operand controls), ⑬ (the option-shaped and `-` forms above) and ⑫ (the real lanes: `rg -ie pat`, `rg -nC 3 pat`, `rg -uu -ie pat`, `rg -nf pats`, both seats, no byte of the member).
453
+ - **Also (#1819).** The same hole through an option the row does not declare at all (security, P1; behavior narrowing — open to veto). `rg --hostname-bin x MARK`, `rg --unknown-opt v pat`, `ag --foo x pat`, `ack --pager less pat`: the rows leave a long name undeclared where its option runs a program (`--pre`, `--hostname-bin`, `--pager`) and the walk read it as taking nothing, so `x` was the pattern, `MARK` the root and the walk of `.` was judged by no reader — with a rooted deny row under the working directory the command was let through on both seats, listed or not, and the real `rg --hostname-bin x MARK` ran, where `rg MARK` is refused. Now a long name that no grammar of the row declares — exactly or as the abbreviation of a declared name — and that carries no `=value` may take the next word (`bareOptionValueOf`): the word is read both ways, like every word an option may own, and the one root rule judges both parses (`rg --hostname-bin x MARK` walks `.` beside `MARK`; `rg --hostname-bin x MARK sub` is judged on `MARK` and `sub`). A dash word it may have taken is no option in the parse where it was taken — it supplies no pattern and takes no value of its own — so the parse is unsettled as for a `--` (KL-1792-c), and the slot word and `.` are judged beside the roots (a chain of unknown options, `rg --hidden --hostname-bin x MARK`; `rg --hostname-bin --files pat`, `rg -e pat --hostname-bin -r --hostname-bin x`, `rg --foo --regexp=pat sub`, and #1800's declared owner, `rg -ie --files pat`). The scan for an option that supplies the pattern no longer reads a word that is another option's value (`rg -r --files pat`, `rg pat -r --files`, `ack -m -f pat`: `-r` takes `--files`, so `pat` is the pattern and the walk is `.`; the scan read `--files` as the switch, made `pat` a root and judged no `.`). A declared switch (`rg --files sub`, `grep -r --line-number pat sub`), a long name with its value attached (`rg --unknown-opt=v pat sub`) and a short letter the row leaves undeclared (`rg -n pat sub`) own nothing, as before. What narrows: an undeclared long SWITCH in front of the pattern (`rg --hidden pat sub`, `rg --no-heading pat sub`, `grep -r --no-ignore-case pat sub`), and one in front of another option (`rg --no-heading -n pat sub`, `rg --hidden -g '*.ts' pat sub`), is in the same doubt, so `.` is judged beside `sub` and a rooted deny row elsewhere under the working directory now refuses where it did not (fail-closed; the rows declaring rg / ag / ack's switch sets, #1813, takes it back). Known residue: a short letter the row leaves undeclared is read as a switch, so ag's width option (`ag -W 80 pat`) still leaves `.` unjudged (KL-1819-a; the row cannot tell it from `-w`; rg's table of value letters is complete); KL-1792-b is closed. Pins: `test/backlog1714-literal-root.test.ts` #1819 ①–⑧ and ⑦b (both seats, listed and unlisted; ⑧ the real lanes, `rg --hostname-bin x MARK`, `rg --hidden --hostname-bin x MARK`, `rg --hostname-bin --files MARK`, `rg -r --files MARK`, no byte of the member, with `rg -n MARK sub` still run under a row elsewhere); #1792 ⑥ no longer lists `rg --hidden pat sub` among the commands that read as before.
454
+ - **Also (#1826).** The same hole through an option whose value is a FILE the program reads (security, P1; behavior narrowing — open to veto). `rg --ignore-file ig MARK`, `ag -p ig pat`, `ack --ackrc ig pat`, `grep -r --exclude-from ig pat`: the separated value counted as the root the walk was given, so the walk of `.` was judged by no reader — with a rooted deny row under the working directory the command was let through on both seats, listed or not, and the real lanes ran `rg --ignore-file ig MARK` unrefused; the attached spelling (`--ignore-file=ig`) was right only because an attached value was never a root. Now the rows say which path values are a place the walk works in — `OptionSpec.walks`, on exactly `tar -C` / `--directory` and `diff --from-file` / `--to-file` — and the one root rule reads that fact for the separated spelling and the attached one alike. Every other path value of a walker (an ignore, exclusion or config file, a pattern file, tar's archive `-f`) is a candidate of its own, judged against the deny rows as itself (`rg --ignore-file sub/.ssh/id_rsa pat elsewhere` and `tar -tf <denied file>` still refuse), and no root: `rg --ignore-file ig MARK` walks `.`, `rg --ignore-file ig MARK sub` is judged on `sub` alone. What narrows: the attached spelling of a place was no root and is now (`diff -r --from-file=sub other`, `tar -cf - -Csub x`, `--directory=sub` — a rooted row under `sub` refuses where it let them through; `tar -C` stays the over-approximation it was apart from its option: the operands resolve under it, so it may hold what they name). What loosens: nothing that was a root of a real walk — tar's `-f` archive (`tar -cf - d`) is no longer judged a second time as a root (it stays a candidate); a list an option delivers the operands from (`ack --files-from LIST`, `tar -T LIST`) is a root of the walk in either spelling — the program works on what the list names — so `ack --files-from=list pat` no longer judges `.` as well, as `--files-from list` never did (a rooted row elsewhere under `.` no longer refuses it); and diff's `-S NAME` / `--starting-file NAME` (a member name to resume at, which diff matches inside the compared directories and never opens at the working directory) is no longer a root when written apart (`diff -r -S sub other`: a rooted row under `sub` does not refuse; the operands are the roots), nor when written inside the word, where it never was, and its value is a datum now, handed to the deny judge as no path at all (`diff -r -S sub/secrets/token.txt a b` is not refused by a row on that spelling; `diff -r a sub/secrets` still is). Pins: `test/backlog1714-literal-root.test.ts` #1826 ①–⑨ (both seats, listed and unlisted; ⑤ the real lanes, no byte of the member; ⑥ places in either spelling, ⑨ the rows' `walks` invariant); the equivalence tables carry two classes (`1826-read-value-no-root`, `1826-place-either-spelling`).
455
+
456
+ ### Operands that arrive in a file are not in the command: a segment that takes them from a list is asked on the full shell and refused on the read-only face (#1834; security; behavior narrowing — open to veto; closed sets: none; exports: none; members: none) (@cli @client-core @server @test)
457
+
458
+ - **Before.** `ack --files-from LIST`, `tar -T LIST` / `--files-from LIST`, `find -files0-from LIST` work on the names the list holds. Since #1826 the list is the root of the walk (and `.` is not), which is right — but the places the list names were read by no judge: with `sub/secrets/token.txt` written in `list` and a rooted deny row `<dir>/sub/secrets/**`, `ack --files-from list MARK` was `none/vouch` on both seats, listed and not (eight cells), and the real lanes ran it. **The attached spelling regressed against 7.51.2 on the merge tree and is fixed in the same version, unreleased:** `ack --files-from=list pat` was `deny_hit/refuse` at the 7.51.2 base (889c90a15; the walk of `.` met the row — the C-R99 astra leg's reading of that tree) and `none/vouch` after #1826 made an attached list a root as the separated one already was; no released version carries the vouch.
459
+ - **Now.** One reading for both spellings. The rows already say which options deliver the operands from a file (`OptionSpec.delivers`: `ack --files-from` and its switch `-x` — the list on standard input —, `tar -T` / `--files-from` (GNU tar's NUL-separated list is `--null -T LIST`, the same option), `find -files0-from`, and the launcher `xargs -a`); `deliversOperands(row, args)` (`program-read-model.ts`) answers whether an invocation selects one — over the merged row, as the classify face's guard arm reads `delivers`: the separated and the attached spelling, a cluster (`-cvT list`), an abbreviation (`--files=list`), the option after the operands, and the traditional key word letter by letter (`tar cTf list out.tar`, `tar cfT out.tar list`). Both readers (the listed reader and the verb-blind one) mark such a segment's reads **not established** with one sentence (`takes the files it works on from a list file, whose names this station does not read`) — the existing mark (`ShellReadSegment.unreadProgramText`, which program text the station could not read already hosts), so the fold gives the existing `unestablishable` finding and its cells are the table's: **asked** on the full shell (mandated on the narrowed read face, the engine default; the ordinary ask on the open face where only a deny list asks), **refused** on the read-only face. No new finding word, no new disposition. The list file stays a read of its own: naming a denied file as the list is `deny_hit/refuse` (the deny hit outranks the ask), a list outside the roots is `outside_roots`; the roots rule is unchanged (the list is the root, `.` is not walked). The list's content is never read — a list naming a denied path, one naming a path outside the roots and one naming nothing denied answer alike.
460
+ - **What narrows (cli, client-core, test).** The forms ask where they were vouched (`ack -x pat < LIST` included — a switch the row did not declare before; from ack's manual, ack is not installed on the gate hosts): a mandated card on the full shell whose account reads `"<command>": takes the files it works on from a list file, whose names this station does not read, so where it reads cannot be established …`, a refusal on the read-only face; also an empty list (`--files-from /dev/null`) and `tar -x … -T LIST` (member names; over-approximated, KL-1834-b). Where the station guards nothing (open face, no deny row) there is nothing to confirm and the command is vouched as before. `xargs -a LIST cmd` is unchanged (the launcher's stream-fed operands are already `declined`: an ordinary ask / a refusal). Nothing else moves: a pattern file (`rg -f`, `grep -f`), a config file (`ack --ackrc`) and tar's archive (`-f`) are files the program reads, not lists of operands (pinned unchanged).
461
+ - **Pins.** `test/backlog1714-literal-root.test.ts` #1834 ①–⑩ (the three forms × both seats × the default table / listed / unlisted readers × a row under `.`, one elsewhere and none; ② the list as an explicit read; ③ the key word, ④ abbreviations and positions, ⑤ the place in a line — three weakest points, one counter-example each; ⑥ the neighbours; ⑦ the real lanes, no byte of the member; ⑧ the closed enumeration of the `delivers` options; ⑨ both spellings, the content of the list and the open face, ⑩ the switch that reads the list from standard input); #1826 ⑦ re-pinned (not refused, not vouched). The equivalence table carries one class (`1834-list-operands-not-vouched`, 28 `tar` entries re-pinned).
462
+ - **Known limits.** KL-1834-a (programs whose row declares no list option: `du` / `wc` / `sort --files0-from`, `rsync --files-from`, `git add --pathspec-from-file`, `zip -@`), KL-1834-b (the over-approximations: extraction mode, an empty list, a value spelled like the option), KL-1834-c (the list's content is not read).
463
+
464
+ ### A prefix of a long option is that option only in a program that takes abbreviations: ripgrep's `--ignore` is a switch, not the start of `--ignore-file` (#1836; security; behavior narrowing — open to veto; closed sets: none; exports: none; members: none) (@cli @client-core @test)
465
+
466
+ - **Before.** Every reader that matched a long option against a row's table read ANY prefix of a declared name as that name (thirteen sites, each a prefix test on the declared name), whichever program the row describes. ripgrep's parser takes no abbreviation (ripgrep 15.1.0: `rg --ignore-f x` and `rg --igno MARK` exit 2 with `unrecognized flag`; 13.0.0, the gate hosts' container, rejects `--ignore-f` too — `Found argument … wasn't expected` — and both run `rg --ignore` as a switch of its own). So `rg --ignore pat sub other` read `--ignore` as `--ignore-file`, handed it `pat` as its value, took `sub` for the pattern and judged only `other`: with a rooted deny row `<dir>/sub/secrets/**` the verdict was `none/vouch` on both seats, listed or not (four cells; the real ripgrep prints `sub/secrets/token.txt` for it), and `rg --ignore pat sub/secrets/token.txt other` was `none/vouch` on both seats with ripgrep listed as a reader (the common deployment; unlisted it was refused) — the real ripgrep prints the member for that too. Found by the C-R99 round-3 opus leg; the first form's four cells are `none/vouch` on the 7.51.2 base as well (that leg's A/B reading, not re-measured here). The fifth form of the family #1800 / #1819 / #1826 / #1834: a word attributed to the wrong option hides a root.
467
+ - **Now.** One fact, one reading. `Grammar.acceptsAbbreviations` says whether the program reads an unambiguous prefix of a long name as that option (absent = it does: the `getopt_long` family — GNU and BSD grep, tar, diff, … — and the two other contents walkers, ag (`getopt_long`, its `options.c`) and ack (Getopt::Long's default, its `ConfigLoader`'s option parse); read from the upstream sources, neither binary is installed on the gate hosts); ripgrep's row declares `false` (`program-read-model.rows.ts`, `contentsWalker`'s `extra`). `isLongOptionSpellingOf(name, full, accepts)` (`program-read-model.ts`; it replaces the prefix-only `isLongOptionAbbrevOf`) is the one place a long name is matched against another, and each of the thirteen sites passes the fact (merged names carry it as `Merged.abbreviable`, a grammar's own readers ask the grammar; the first-operand slot carries `abbreviableLongNames`). A long name rg's row leaves undeclared is read as undeclared, with the doubt #1819 already gives such a name: it may own the next word, the roots of both readings are judged. `rg --ignore pat sub other` is `deny_hit/refuse` on both seats with a row under `sub`; the real lanes refuse both forms before the walk (`read_path_denied`, no byte of the member).
468
+ - **What narrows (cli, client-core, test).** The two forms above (and `rg --igno pat sub other`, `rg --ignore-f ig pat sub`, `rg --colo never pat sub` — spellings real ripgrep rejects — read as an undeclared name, never as the declared option owning its value). `--ignore` is now an undeclared switch like `--hidden`: `rg --ignore pat sub` also judges the walk of `.` (the stricter side the #1819 note already takes: a rooted row elsewhere under `.` refuses). `rg --pre cat pat sub` (`--pre` is a real flag, formerly read as `--pre-glob`'s prefix) keeps the same verdicts in the pinned cells.
469
+ - **What loosens.** One shape, the misreading's by-product: with `--ignore` taken as `--ignore-file`, the words behind it were parsed on from the wrong place, and a pattern could become a root — `rg --ignore -g glob -e <a protected file outside the tree> other` judged the pattern of `-e` as a root and was refused on a row for that file; it is vouched now (the pattern is no path; the roots are `other`, and the doubtful walk of `.` beside it), and still refused with a row on `other`. Pin ⑨. Everywhere else the undeclared reading judges the roots of both readings and also reads the next word as itself, which is a superset of what the abbreviation reading judged in every other pinned cell — a general superset is not claimed.
470
+ - **Pins.** `test/backlog1714-literal-root.test.ts` #1836 ①–⑦ (① the two leaks × both seats × listed / unlisted, with a row under `sub` and the exact-file row; ② the real lanes, no byte of the member, and the real ripgrep's own reading — the leak prints the member, a prefix is `unrecognized flag`; ③ the undeclared reading of a prefix; ④ the getopt_long contrast, grep and tar abbreviations still read, against the real binaries here; ⑤ the neighbours, and ag / ack / grep reading `--max` where rg does not; ⑥ nine readers asked the same question of one table, fact true vs false, a negated spelling and a mixed-grammar row; ⑦ the fact written once, on rg; ⑧ the stated boundary of KL-1836-a; ⑨ the one shape that loosens). The equivalence tables are unchanged.
471
+ - **Known limits.** KL-1836-a (the fact is written for rg alone: other rows whose program takes no abbreviation — measured on this machine: `curl`, `node`, `python3`, `docker`, git's top-level options — still carry the default).
472
+
473
+ ### What a judge read is what runs: a tool-gate pass holds one immutable copy of the call's args (#1773; behavior narrowing — open to veto) (@server @cli @client-core @test)
474
+
475
+ - **Before.** The gate handed the model's own args object to every judge that is not a person — the tool policy (`req.args`), the org face, the stored-rule lane, the auto-mode classifier — and ran that object. A deployment policy that kept `req.args` and wrote into it while another judge waited (`tool.check`, the classifier) made the call run bytes nobody judged (judged `/safe`, ran `/secret`). Only the person's path ran a snapshot: the approval seat's retained clone, which the gate adopted as `presentedInput` at five sites.
476
+ - **Now.** One value. `ownArgs` (`tool-policy.ts`) takes args into the engine's own copy — an exact structured clone (`tryCloneArgs`), deep-frozen — at the gate's entry, and every value that replaces it comes through the same function: a hook's or a policy's `updatedInput` at the decision receipt, a person's edit at the approver's answer and at the gate's adoption (the same value when the engine's resolver took it). Every judge reads that value; the call runs it. The approval seat's retained snapshot IS that value (no second copy) and `tool.check` / `classic.PreToolUse` get it as `e.input` (no per-raise copy); the gate adopts no resolver snapshot any more (five adoption sites retired; `ContentAskOutcome.presentedInput`, read by nothing after that, is gone — an internal type).
477
+ - **What narrows, per face.** (1) A write into the args any gate seat was handed (`req.args`, the org face's `args`, the stored-rule lane's request, the classifier's request, `e.input`) throws `TypeError` — a rewrite is a decision's `updatedInput`; a policy that edited its request in place now fails with the policy-error refusal instead of having its edit run. (2) `runToolGate`'s result carries `updatedInput` on every allow exit (the pass's copy), not only when something rewrote — a host that read "present ⇒ rewritten" compares values; the loop re-validates it as before and re-runs `validateInput` only on a value change. (3) A host's own `resolveAsk` answering a plain yes with `presentedInput` no longer chooses what runs: the pass's args run. (4) Args the gate cannot copy (a function, a shared buffer) are refused at the entry, before any judge — `deniedBy: "policy"`, `the arguments of "X" could not be taken into the gate's own copy (unclonable value: …)`; the runner's own calls are always clonable (the loop's validation clones them first). (5) Args are plain data — plain objects, arrays, primitives: a Map, a Set, a Date, a buffer or a typed array keeps its contents mutable under a freeze, so a value holding one is refused, naming where and what (`args.m is a Map, whose contents cannot be made immutable — args are plain data`; in a hook's or a policy's rewrite, the receipt's refusal `… (updatedInput.probe is a Date, …)`); such a rewrite could not pass a tool's own schema anyway, and a durable park no longer meets one (its codec projection still handles `-0`, `NaN`, an undefined-valued key). (6) The request a judge is handed — `req` of a policy and of each layer of a composed one, the classifier's `req` — is frozen like the args: re-pointing its `args`, `toolName` or `face` throws, and the policy's throw is its refusal.
478
+ - **Reference.** CC 2.1.296 holds one input variable from its PreToolUse hooks through the permission decision to the call (`cli296.js:249217`, bytes 17634161 / 17637907; `:249218`, 17644878) and its judges sit across a process boundary — same rule, our mechanism is the in-process copy; no divergence entry.
479
+ - **Pins.** `test/gate-judged-is-run.test.ts` J1–J11 (the caller's object, the org and stored-rule lanes, a received rewrite, a person's edit, the entry refusal, a host resolver's snapshot, an in-place policy edit and a re-pointed request on a real Runner, an edit the gate cannot own, a policy's and the classifier's re-pointed request, plain data), N1–N3 (the person's path unchanged); `test/mods-tool-check.test.ts` M-a / M-b / M-c now assert what ran; re-pinned to the law: the direct gate's `updatedInput` on a plain allow (`hooks` / `core-security-coverage` / `backlog1236` / `backlog286` / `gate-stations-wiring`), the approval factory's changed-request cells at the gate (`backlog595` A2 / A4 / A6 / A7 / E5″ — the factory's own refusal stays pinned on a direct call), the resume belt's in-place swap (`backlog1328` ⑨; ⑨b pins, on base and branch alike, that a belt policy's throw interrupts the run instead of being the belt's named refusal — a defect left open), the park's projection cells (`park-mint-backend-fidelity`: a Date / Map / Set rewrite is the receipt's refusal; the projection is driven by plain-data shapes — likewise `backlog1079` / `backlog1092` / `design384-slice2` / `design388-write-fences` / `park-bidi-presence`, whose projection trigger is now an undefined-valued key). `docs/INTEGRATION-CORE.md` §4.6w. KNOWN-LIMITS KL-1398c-c retired.
480
+
481
+ ### A refusal reports the args its refusing station judged (#1772; record change) (@server @cli)
482
+
483
+ - **Before.** `tool.check`'s refusal and a composed policy chain's accumulated rewrite carried the args they judged; an org deny, a stored deny row, a skill row, a classifier block, a mode refusal or the content refusal over a policy's rewrite reported the args before the rewrite, and every refusal on an approved-edit round (a PreToolUse re-screen, the re-check policy, the org layer, a stored row, the mode, the person's refusal of the round's question, the write-protection and read-boundary re-judges, the round cap) reported the args before the edit.
484
+ - **Now.** One mint (`refusalOf`, `tool-policy.ts`), read by the gate's lanes and by `combinePolicies`: a refusal carries the args its station or layer judged — the final args (after the policy's rewrite) on the first adjudication, the edited bytes on an approved-edit round, the denying layer's own args inside a composed chain (a nested chain's included: before, an outer composition's earlier rewrite overwrote an inner deny's); a deny that already carries the args its own layers judged keeps them. The exit reads that one member for `finalInput` (the call's `tool_end` record) and the deny observer's `input`.
485
+ - **Consumers.** A reader of a refused call's args (`PermissionDenied.input`, the `tool_end` record) now sees the redacted or edited bytes that were refused where it saw the raw or pre-edit ones.
486
+ - **Pins.** `test/gate-judged-is-run.test.ts` R1–R6 (with `test/mods-tool-check.test.ts` E1b / 9b).
487
+
488
+ ### An extension's `ask` on an approved edit puts the edit to a person again — the PreToolUse re-screen and `tool.check`, one form (#1774; behavior change — open to veto)
489
+
490
+ - **Before.** On an approved-edit round, `tool.check` answering `ask` put the edited call to a person again, while a `classic.PreToolUse` re-screen answering `ask` refused the edit ("raises a new PreToolUse approval requirement the original approval does not cover").
491
+ - **Now.** Both put it to a person again. The re-screen's `ask` folds over the edit's verdict by phase 1's law, now one function both call (`foldHookAsk`, `tool-policy.ts`) — an allow becomes the hook's question (its message), an ask keeps its members, takes the hook's provenance and carries the hook's message beside its own, the hook's `requiresRealApproval` rides — and goes to the edit's resolution behind the mode arbitration (a hook's ask is not released); the three-round cap bounds the chain. A re-screen `deny` still refuses.
492
+ - **Reference.** CC 2.1.296 runs PreToolUse once, before the permission decision (`cli296.js:249217`, byte 17634161), and a hook's `ask` puts the call to the prompt (`:249209`, 17595387); it has no re-screen of an approved edit (ours is the superset). The shared form is the reference's meaning of a hook's `ask`.
493
+ - **Pins.** `test/gate-judged-is-run.test.ts` A1–A3.
494
+ ### #1604 M1a (#1662) — prompt text changes through overlays only: a band table, three source classes in one order, the request-body sugar, a selector with a host role and form; the `PromptProvider` family retires; `prompt.compose` / `prompt.section` are the mods write ports (**BREAKING**; hard-breaking: four — H3, and H4 / H5 / H10 open to veto; closed sets: +3 `OverlayOp` / `HostRole` / `HostForm`; exports: see below) (@server @cli @client-core @test)
495
+
496
+ - **The rule.** Every system-prompt byte is a catalog message's selected variant; any source that wants different text writes an overlay — `replace` / `append` a message, `add` an entry to a band, `drop` a section — and the band table (`src/prompt-assembly/bands.ts`) says which source class may do what where. Classes apply in one fixed order, `bundled < task < center < deploy`, declaration order within; the later application on one target wins and the overridden one is named (`onError`, `phase: "prompt-constitution"`) and recorded (`Prepared.promptManifest.superseded`). INTEGRATION-CORE §12.2 / §12.6–§12.9, §10.8.
497
+ - **H3 — the `PromptProvider` family retires.** *Changed:* `PromptProvider` (`stableBlocks` / `stableSystem` / `replaceAll` / `section`), `TaskSpec.promptProvider`, `RunnerDeps.promptProvider`, `StablePromptContext`, `PromptTextDeclaration` (export), `analyzePromptCacheFriendliness` / `assertPromptCacheFriendly` / `PromptCacheReport`, `coworkPromptProvider` / `COWORK_PROMPT_SECTIONS`, `PromptSectionSeat` / `PromptSectionDisposition` are gone; a deployment writes prompt text through a `prompt.compose` registration on `RunnerDeps.mods` answering `{ overlays }`, and disposes of an MCP server's instructions through a `prompt.section` registration answering `{ text }` / `{ text: null }`. A wired `promptProvider` is refused by name (`config.spec_retired_key` / `config.deps_retired_key`; the sentence names the route). *Who breaks:* sema-server (origin/main `b3eb99f5`): `src/capabilities/scenarios.ts:172-230` (`semaIdentityProvider`, `codePromptProvider`), `src/capabilities/prompt.ts:17-21` (`stablePrompt`), `src/capabilities/memory-notice.ts:164-213` (`withNoPersistentMemoryNotice`), `src/capabilities/center-prompts.ts:130-147` (`centerPromptProvider`, `centerIdentityAssembled`), `src/task-settings.ts:728-760` (`hasConstitutionAnchors`, `providerDropsAppend`), `src/run-local.ts:1331`, `src/boot/resolve-spec.ts:443,1284,1644`, the TaskSpec key list in `src/trace/core-keyset-guard.ts:1180,1212`; any library host that wired a provider. cli / client-core / web-admin: no reference. *How to migrate:* the code scenario's role base → `TaskSpec.promptRole: "code"` (also `"coordinator"`, `"cowork"`); the brand line → `add` to the `role` band (it lands ahead of the role base); a role layer → `TaskSpec.systemPrompt` or a deploy `replace core/role.base`; the no-persistent-memory notice → a deploy `add` to `behavior`; the Cowork base → `promptRole: "cowork"` (the manifest names the variant `cowork` on `core/role.base` instead of `sema/cowork.*` sections). **Behavior change, open to veto:** a brand line added by the deployment now composes even when a center persona replaces the role base (before, a center identity replaced the scenario provider and the brand with it).
498
+ - **H4 — an already-assembled prompt is refused, never passed through (open to veto).** *Changed:* the "three constitution anchors ⇒ pass through un-doubled" heuristic is gone; any overlay whose text contains, verbatim, the canonical text of a `constitution` entry is a violation — the task's own `systemPrompt` / `appendSystemPrompt` (and a role preset) fail the task with `prompt.assembled_role_refused`, a mods answer carrying it is skipped and named. Every entry is compared whole (the survey `test/prompt-lock-text-survey.test.ts`: zero legitimate role texts contain one). *Who breaks:* a host still handing an assembled prompt as the role layer (server stopped at 1.310); a role text that quotes a safety sentence verbatim; server's `centerIdentityAssembled` / `providerDropsAppend` gates lose their subject. *How to migrate:* hand only the role layer.
499
+ - **H5 — `replaceAll` → `TaskSpec.promptForm: "minimal"` + the role text (open to veto).** *Changed:* the minimal form leaves the core-behavior band out; the locked sections (the mode sections of mechanisms in play among them) stay, and the task's `appendSystemPrompt` always composes. A fable-family model's three classic lines (core-behavior band) no longer compose on this shape. The form rides the parent spec seat (`ParentSpecSeat.promptForm`, a clamp-table row): every engine-built child composes in its parent's form, and `tightenTaskSpec` keeps it. *Who breaks:* a host that used `replaceAll` (server never did): with goal / orchestration / worktree active, or with an appended block, the prompt now carries those sections; the manifest (v3 — H8 below retired the top-level `constitution` member) reports block `role.base` in the `role` band where it said `replaced` / `provider.replace_all`. *How to migrate:* `promptForm: "minimal"` + `systemPrompt`.
500
+ - **H10 — a colliding center section is no longer dropped (open to veto).** *Changed:* center and deployment text are both overlays; on one target the deployment's applies after the center's and wins, and the center's is named superseded; the "every center section shadowed ⇒ do not pin" special case and `prompt.center_unmountable` are gone. *Who breaks:* operators keyed on the old "center section … not mounted" line (server maps it to `prompt_provider_needs_upgrade`). *How to migrate:* read the `prompt-constitution` line naming the superseded overlay.
501
+ - **Addition (non-breaking): `TaskSpec.promptRole` and `TaskSpec.promptForm`** — the host selector's two writers (`"general" | "code" | "coordinator" | "cowork"`, `"minimal"`); an out-of-set value refuses the task by name (`config.prompt_selector_invalid`).
502
+ - **The two stations take data and apply what they checked.** A `prompt.compose` answer is a plain `{ overlays }` with plain-data overlays, a `prompt.section` answer a plain `{ text }`; a class instance, an accessor or a Proxy is refused (that handler skipped and named). A `prompt.compose` handler that answers without awaiting `next(e)` is skipped and what is below it runs in its place. What applies is the copy checked at each handler's boundary; an answer handed up unchanged stands as taken. An overlay whose `message` has no selected variant this run is named superseded `(absent this run)`. `ParentSpecSeat` gains the optional member `promptForm` and the clamp table a `promptForm` row (restricting `"minimal"`, inherited only — a workflow script cannot declare it).
503
+ - **The center layer's text** reaches the composition through the version-2 artifact's overlays (the next section, #1604 M1b); the version-1 artifact is not read.
504
+ - **Exports.** Removed: `PromptProvider`, `StablePromptContext`, `PromptTextDeclaration`, `PromptCacheReport`, `analyzePromptCacheFriendliness`, `assertPromptCacheFriendly`, `coworkPromptProvider`, `COWORK_PROMPT_SECTIONS`, `PromptSectionDisposition` (each a breaking removal). Added: `PromptOverlay`, `SupersededOverlay`, `OVERLAY_SOURCE_ORDER`, `PROMPT_BANDS`, `OVERLAY_OPS`, `HOST_ROLES`, `HOST_FORMS`, `PromptOverlayOp`, `PromptOverlaySource`, `PromptHostRole`, `PromptHostForm`, `PromptVariantWhen`. `materializeMcpTools`'s trailing parameter is the `prompt.section` disposer (was a section seat).
505
+ - **Gates and pins.** New gate `gate:sugar-single-site` (the three role-layer input names have one reader in the assembly pipeline). `test/prompt-overlay-migration.test.ts` (P15: 136 cells against the dispatch base, byte-identical except H3 block views, H4 refusals, H5's declared delta); `test/prompt-golden-matrix.test.ts` unchanged (65 536 cells); `test/prompt-golden-1302.json` refrozen in two declared cells (`replace-all-provider` → minimal: manifest `constitution` / role block id; `provider-assembled` removed).
506
+
507
+ ### #1604 M1b (#1662) — the center layer has one wire form (artifact v2) and composes; "which prompt" has one answer (the run's identity); the manifest is v3 (**BREAKING**; hard-breaking: three — H6, H7, and H8 open to veto; closed sets: `PromptEpochActivation` −1 +2, +`PromptEpochChange`, +`ArtifactVerifyCode`; exports: see below) (@server @web-admin @settings-schema @sdk @client-core @cli @test)
508
+
509
+ - **The rule.** A config center publishes one thing: an artifact v2 — `{ payload: { schemaVersion: 2, requires, overlays, evidence }, artifactDigest, sourceRevision }` — whose overlays are the `center` source class (between task and deploy); `scope.binding` selects the subset a run's binding (`TaskSpec.promptBinding`) gets. A run's prompt has one identity, `H(catalog, renderers, H(center, deploy, task), binding)`; only the center layer is pinned (adopted at session start or a compaction, resolved by digest in between), code and inputs are current and recorded when they move. INTEGRATION-CORE §12.10–§12.13. No reference-implementation counterpart (the reference has no center artifact).
510
+ - **H6 — center wire v1 → v2 (no converter, no dual read).** *Changed:* `verifyPromptArtifact` reads schema 2 only; `compatibleCore` / the slot catalog / `closure` / `catalogDigest` are gone — `requires` (targets / ops / traits / bindings) is a stored field the verifier recomputes from the overlays and checks against this engine (`unmetRequirements`), every overlay passes the center class's band columns and the locked-text guard, two `replace`s of one target under one binding and condition are refused, `evidence.intent` is required and a core-behavior `replace` needs an `evidence.refs` entry; `buildPromptArtifact({ overlays, evidence, sourceRevision })` is the one builder (it returns the verifier's result). `CenterPromptSource` takes no `engineVersion`; `PromptArtifactSource.sourceState()` is required. A retired v1 body is refused by schema (`ArtifactVerifyResult.code` `"shape"` / `"schema"`). The envelope is closed like the payload: a top-level key beyond `payload` / `artifactDigest` / `sourceRevision` (a v1 field written at the top included) is `shape` (#1805). *Who breaks:* settings-schema `src/types.ts:2819-2927` (`EffectivePromptsWire` v1, `PublishedPromptArtifactEnvelope`, `PromptEpochArtifactPayload`, `PromptBinding.declarations`) and `src/prompts-digest.ts`; web-admin `src/app/api/config/effective/route.ts:66,316-334`, `src/lib/config/personal-face.ts:39-136`, `src/lib/config/prompt-artifact-cas.ts:13-32` (`buildPromptArtifactEnvelope`, `closure.promptCatalog.sections`); server `src/prompts-domain-validate.ts:18,100-176`, `src/capabilities/center-prompts.ts:22-147`, `src/boot/web-admin.ts:41-55,618-704`, `src/run-local.ts:57,962-969`, `src/config-provider.ts:545-550`, `src/web-admin-client/candidate-gate.ts:60`, `src/web-admin-client/http-client.ts:292-303` (origin/main `9ed93bb` / `21ff0f84` / `b3eb99f5`). *How to migrate:* the three steps, in order — settings-schema major (`EffectivePromptsWire` = `{ schemaVersion: 2, artifact }`, v1 deleted) with this release; web-admin W (the worker lane publishes `buildPromptArtifact` output only; a role card in append mode is `{ op: "append", target: "core/role.base" }`, a conduct card `{ op: "add", target: "behavior" }`, a scenario override an overlay with `scope.binding`); server S (the candidate judge reads v2 only and hands `wire.artifact` to `CenterPromptSource.applySnapshot`; `centerPromptProvider` / `providerDropsAppend` / `hasConstitutionAnchors` / `centerIdentityAssembled` retire; the scenario name becomes `TaskSpec.promptBinding`). Roll back W first, S second. Migration pre-pins (server): **Pre-1** an old server (7.125.x) refuses a `schemaVersion: 2` candidate whole and keeps its LKG — two black-box cells on 7.125.x: a refresh tick receiving a v2 body leaves the live config and `promptSource.current()` unchanged and logs one `config_candidate_rejected`; a boot receiving a v2 body falls back to the disk LKG. **Pre-2** a new server with no v2 artifact composes with an empty center layer — unreachable in this order except: the boot fetch exceeds `CONFIG_BOOT_FETCH_BUDGET_MS` while the disk LKG is an old server's v1 ⇒ the new judge refuses the LKG ⇒ env fallback until the late fetch lands; pinned and stated in the pickup slip.
511
+ - **H7 — `PromptEpochDescriptor` is reshaped; a retired-shape record is answered by name.** *Changed:* `artifactDigest` / `packId` / `assemblyApi` / `centerArtifactDigest` are gone; a record is `{ epoch, identity, layers: { catalog, renderers, center?, deploy?, task?, binding }, activatedBy, changed?, sourceRevision? }` (`identity` must equal `promptIdentityOf(layers)`); `activatedBy` is `session_start | compaction | code_change | selection_change` (`legacy_migration` → `code_change`, which names the moved layers in `changed`). `normalizePromptEpoch` judges the v3 shape only; every reader of a stored record — `Session.getPromptEpoch()` and the session import door — reads it through `readStoredPromptEpoch`: a v3 pin, or `{ kind: "legacy", centerPinned }` for a record recognized by its retired field names (nothing in it parsed but whether `centerArtifactDigest` is a digest — the one value the retired reader took for a pin; `null` or a malformed value there is no pin, #1805); a record that is neither is skipped by the walk and refused at the import door (an upgraded session keeps its retired records in its history, never rewritten, so its own export imports). A session whose nearest record is a retired center pin is handled by the center source's tri-state at its next run: `active` ⇒ the current candidate composes and a new record says `code_change` + `changed: ["published_schema"]` (a mid-conversation replacement of the center text, recorded; the prefix cache misses once); `disabled` ⇒ an empty center layer, recorded the same way; `unavailable` (or no source) ⇒ the run fails `prompt.snapshot_unavailable` and the old record is kept (retry once the source has a verified state). A retired bundled record ⇒ `code_change`, no center layer until a compaction adopts one. `SessionStorage.getEpochAnchor()` returns the anchor record AS STORED (`Promise<unknown>`; a backend must not normalize it first, or a retired center pin would read as no record). *Who breaks:* server `src/plugins/pg-session-storage.ts:29-30,174-185` and `src/plugins/tidb-session-storage.ts:6-7,39,74-98` (they normalize the anchor — return it raw), `src/audit.ts:34-36,117-125` (the epoch shape it exports), test files that mint the v2 shape; sdk `packages/sdk/src/types.ts:2624-2632` (`PromptEpoch`) + `spec/openapi.yaml:13907-13923`; web-admin `src/lib/chat/prompt-epoch.ts:6-41`, `src/components/chat/chat-page.tsx:587,894,2054-2067` (the badge). *How to migrate:* store records opaquely and judge them with `normalizePromptEpoch`; read `identity` / `layers.center` / `activatedBy` / `changed`.
512
+ - **H8 — the manifest is v3 (open to veto).** *Changed:* `prompt.assembled` is `version: 3`: top-level `constitution` is gone, `identity` (`{ artifact, layers, epoch, activatedBy }`) and `selectors` (`{ binding?, traits, host, facts }`) are added; each section is `{ id, band, rank, variant?, renderer?, chars, locale: "canonical" | "overlay", contentHash, chain }` — `hash` is gone, `contentHash` is `sha256:<hex>` for published text and `salted:<hex>` for text carrying the task's or the run's own inputs, `chain` lists the catalog text and every overlay applied (superseded ones marked); `superseded` (the overridden overlays) rides the frame. `explainPromptAssembly` takes that shape (`ExplainInput`); `describeCatalog().sections[].locked` is replaced by `ops: { center, deploy, task }` (the band's columns as they are — a center editor reads `ops.center`). *Who breaks:* server `src/observability/prompt-manifest.ts:11-58` (`promptManifestRecordOf` whitelist), `src/budget.ts:304-315` + `src/observability/metrics.ts:375` (`prompt_constitution_total{mode}` — retires: its three values have one), `src/trace/project.ts:183,2349-2356`; sdk `packages/sdk/src/types.ts:2605-2618` (`PromptManifest`) + `spec/openapi.yaml:16895-16911`; client-core `src/adapter/downstream/eventToSdkMessage.ts:1897-2007` and `src/adapt/arms.ts:705-726` (`PromptManifestSectionView`; reads `id` / `chars` — unchanged — and `slot`, already gone since M0); cli `src/utils/analyzeContext.ts:321-325,1242-1257`, `src/sema/upstreamBridge.ts:3381-3394` (reads counts only — unchanged); web-admin `src/lib/trace/prompt-manifest.ts:35-136,184` + `src/components/analytics/prompt-composition-card.tsx:65-152` (`constitution` badge, `LOCKED_SLOTS`). *How to migrate:* project the new members; observe overridden overlays through `superseded` / `chain`; read `ops` for editability.
513
+ - **Additions (non-breaking).** `TaskSpec.promptBinding` (the deployment's scenario name; out of grammar ⇒ `config.prompt_selector_invalid`; rides the parent spec seat to every engine-built child — `ParentSpecSeat.promptBinding`). Every top-level session now adopts the current candidate at a compaction (a session that started with no center layer included); a child composes its parent's layer and never adopts one at its own compaction (before, a center-pinned child adopted newer candidates there).
514
+ - **Exports.** Removed: `artifactDeclarations`, `buildPromptArtifactEnvelope`, `computeCatalogDigest`, `computeBundledArtifactDigest`, `CenterOverlaySection`, `CenterOverlaySlot`, `CoreCompatRange`, `PromptCatalogSnapshot`, `PromptDependencyClosure`, `PromptEpochArtifact`, `PromptEpochArtifactPayload`, `PublishedPromptArtifactEnvelope` (each a breaking removal). Added: `buildPromptArtifact`, `requiresOf`, `unmetRequirements`, `ArtifactVerifyCode`, `PromptArtifact`, `PromptArtifactPayload`, `PromptArtifactRequires`, `PromptArtifactEvidence`, `PublishedPromptArtifact`, `promptIdentityOf`, `PROMPT_EPOCH_ACTIVATIONS`, `PROMPT_EPOCH_CHANGES`, `PromptEpochChange`, `PromptIdentityLayers`, `StoredPromptEpoch`, `PromptChainLink`, `PromptManifestIdentity`, `PromptManifestSection`, `PromptManifestSelectors`. Reshaped: `ARTIFACT_LIMITS`, `verifyPromptArtifact`, `ArtifactVerifyResult`, `CenterPromptSource`, `PromptArtifactSource`, `PromptArtifactStore`, `PromptEpochDescriptor`, `PromptEpochActivation`, `ExplainInput`, `CatalogDescription`, `CompiledSection` (`chain` / `renderer` in, `origin` / `declaredContentHash` out), `MaybeCompactOptions` (`promptIdentity` replaces `epochDeclaredSections`; `centerAdoption` carries the identity after the change).
515
+
516
+ ### Every model call made beside the main loop is on the run's account: the permission classifier's replies and a mod's `$.model.complete` are booked into `stats`, through one function with the compaction summarizer (#1413; behavior addition — a run's reported tokens and cost grow by what these calls spent; additive optional member `stats.costBreakdown.sideCallMicroUsd`; hard-breaking: none; closed sets: none; exports: +0 / −0) (@server @cli @test)
517
+
518
+ - **Before.** The auto-mode classifier's reply kept only its verdict — its usage was dropped — and a mod's `$.model.complete` handed its usage back to the caller only. A run that classified every tool call reported the cost of its main loop alone.
519
+ - **Now.** `recordSideCallUsage` (`side-call-account.ts`) is the one place a side call reaches the account, with a lane — `compaction`, `classifier`, `mod`. The compaction summarizer's own accounting is lane `compaction` of it (its separate function is retired). Each reply is re-priced in core (the deployment's table, the cache family of the model that ANSWERED — a reply a degrading brain served from its fallback is priced as the fallback; never the brain's own `usage.cost`) and added to `tokens`, the prompt / output / cache counters and `costMicroUsd` — so the reported total, the budget gate at the next turn end and the trace agree. Every call is one `brain.call` trace row without a loop-turn index (as the summarizer's already was). The classifier books each paid reply, the re-ask included, before it judges the reply a failure; a classification cut by its deadline (or a cancel) before its reply is in hand marks `stats.usageMissing` at the cut — a brain that settles later books into an account the result has already left. The mods door is `Prepared.sideCallBookRef.book` / `bookSideCall`, the same function.
520
+ - **`sideCallMicroUsd` is an "of which" line.** `stats.costBreakdown.sideCallMicroUsd` (optional; present once a classifier or mod call was booked this leg, absent otherwise) is part of `costMicroUsd` and stays INSIDE `llmRootMicroUsd`: `llmRootMicroUsd + compactionMicroUsd === costMicroUsd` still holds, and a reader that sums the four partition lines must not add it. Compaction keeps its own partition line. The cumulative stats of a repair loop and a cascade carry the line as the sum of their legs'.
521
+ - **Server billing, check your table.** A run in `auto` mode now reports a higher `costMicroUsd` / `tokens` than before by exactly its classifier spend (the identity above is unchanged; nothing is double counted by reading the four lines). A side call whose model has no price table taints the run as unpriced spend — `costMicroUsd` and `costBreakdown` are then absent, as for any unpriced spend. A reply that carried no usage sets `stats.usageMissing` (the totals are a lower bound) instead of adding a 0. Across a multi-slice `resource_limit` resume the prior slices' side-call spend is inside the exact `costMicroUsd` and `llmRootMicroUsd` but not in this line (the same per-leg limit `compactionMicroUsd` has).
522
+ - **Pins.** `test/side-call-usage-account.test.ts` (the function: lane routing, re-pricing, unknown-is-not-free, unpriced taint, the trace row; the classifier lane through a real Runner: one booking per call, the re-ask, a billed failed reply, the total, the "of which" arithmetic, absent-when-none; the mods chain bottom: one reply = one booking; the import graph: one definition, one call site, no second door), the cumulative-stats pins, and the mutation table in the car receipt.
523
+
524
+ ### Internal: the multi-package release base — a `packages/` workspace, `gate:packages` / `gate:packages:pack`, package rows in the layering registry, a package dimension in the published-tag check (no engine behaviour; `src/` and the root package's `files` / `exports` / dist untouched)
525
+
526
+ - `packages/mods-kernel/` is a private package cut at the root version (it began as a shell with no source; the next section records the kernel landing in it); `docs/RELEASE-TIERS.md` "多包发版底座" is the publish procedure, and names what the base deliberately leaves open (per-package wire manifest, the package → core build resolution).
527
+
528
+ ### Internal: `@sema-agent/mods-kernel` builds from the kernel source — the package is no longer a shell (no engine behaviour; the root dist gains nothing from this step; the package stays `private`)
529
+
530
+ - **Wiring.** `packages/mods-kernel/tsconfig.json` compiles `src/core/mods/kernel/*.ts` — ES2023 + DOM libs, `types: []` (no ambient node types), strict, `rootDir` the kernel directory, `outDir` the package's own `dist/`; the entry is `index` (`exports["."]`). The package has no dependency of any kind and its `docs/LAYERING.json` row lists no bare import at all (not even a builtin). `gate:packages` now reads it as a package with source (7 files: tsc, the frozen export surface, the smoke table, the layering row), `npm run build:packages` leaves `packages/mods-kernel/dist/` (the 7 modules, `.js` + `.d.ts`), and `gate:packages:pack` builds, packs, installs the tarball offline and smokes it.
531
+ - **Pins.** `test/mods-kernel-package-wiring.test.ts` reads the committed files (the gate does not read `lib` or `types`; the pins do) and runs the gate over a copy of the real tree — static arms green, pack arm green with the built entry, red for an include that points at the wrong directory, a bare import in the kernel source, and a `private` that is no longer an explicit boolean. The package README no longer calls the package a shell.
532
+
533
+ ### A project's own `.npmrc` / `.pypirc` are read like any project file; the home's pair keeps the credentials line — one table, two columns (#1741; behavior change: a READ refusal is lifted, open to veto, C-R455 / C-R456)
534
+
535
+ **What changed.** The built-in read-deny table's registry pair is now home-anchored — rows `~/.npmrc` and `~/.pypirc` (`credentials` tier, in the default selection) replace the unanchored `.npmrc` / `.pypirc` — so `cat .npmrc`, `Read <project>/.npmrc`, `grep -rn TODO .` in a project that carries either file are served in every permission mode (bypass included), while the home's own pair, by any spelling that lands there (absolute, `~`, `$HOME`, `..`, a link from a project), is refused exactly as before; the table now reads: a name that is a credential wherever it is found is unanchored, a name that is the person's file in the home but the project's own configuration elsewhere is home-anchored. **Who breaks.** Nobody loses a protection the home had; a project's registry file whose lines carry a real token now reaches the model and the session record when read (the cost is stated: a recursive `grep -rn registry .` prints those lines), and a deployment whose `readDenyBuiltinExclude` names `.npmrc` / `.pypirc` gets a loud refusal at wiring that names the new row (`~/.npmrc`); an environment that declares no home still refuses the project's pair (the anchored rows hold wider where they cannot be placed — on both relations a path has to them: a direct read meets their run anywhere, and a recursive read's root may hold them, so `grep -r X .` there is asked, #1803), as does a command-substitution spelling such as `cat $(pwd)/.npmrc` (KL-r2-b). **How to migrate.** Rename an exclude entry to `~/.npmrc` / `~/.pypirc`, or delete it if it only existed to read a project's file; a deployment that wants the project's pair refused again adds `.npmrc` / `.pypirc` to `readDenyPatterns`.
536
+
537
+ - **Pins.** `test/backlog1741-project-rc-reads.test.ts` (① table and matcher, ② both direct-read faces × five modes, ③ the home column × five modes × four spellings and a link, ④ a project outside the home, ⑤ Grep, ⑥ no declared home and the win32 fold, ⑦ the recursive read of a project carrying the pair, ⑧ the declared bound fork, ⑨ the unplaceable-spelling residual); the fixtures of `backlog245` / `763` / `764` / `889` / `1179` / `read-deny` / `rev7340` / `R-2` that used `.npmrc` as "a deny-row filename" now use `.netrc`.
538
+ - **Recursive reads (#1714).** Superseded in this same release: a recursive shell read is judged by the root it names, never by enumerating the tree below it — see the #1714 section above.
539
+
540
+ **#1398 wave one, the kernel (with #1733 folded in) —** hard-breaking: none in the kernel itself (the `Hooks` retirement and its three hard-breaking items are the stations section below); behavior: none from the kernel for a host that registers nothing — with no handler on the bus for an event nothing is dispatched: each station returns at its first guard (`observeClassic`, `dispatchClassic`, `raiseToolCheck`, `raisePromptCompose`: `src/core/mods/stations.ts`) and the engine goes on with its own answer; the two caller-object receipts (#1733) keep their readings. Exports (the kernel's own): +29 / −0 — values `KERNEL_CONTRACT_VERSION`, `MOD_TIERS`, `MOD_SEATS`, `MOD_EVENT_SIDES`, `MOD_EVENT_TABLE`, `MODS_INVALID_CODE`, `MODS_REGISTRATION_REFUSED_CODE`; types `ModTier`, `ModRegistrationTier`, `ModSeat`, `ModEventSide`, `ModEventName`, `ModEventRow`, `ModStation`, `ModRegistration`, `ModUses`, `On`, `ModHandler`, `ModHandlerOptions`, `ModNext`, `ModOrigin`, `ModTraceEntry`, `ModCaughtError`, `ModDollarBase`, `ModEventPayload`, `ModMatcher`, `ModAdmissionRecord`, `EngineDollar`, `ModelCompleteBlock`. Closed sets: +9 — `ModTier`, `ModSeat`, `ModEventSide`, `ModFailureAnswer`, `ModAbortPosture`, `ModEventName` (the event table's keys: the reference's 142 names), `ModFailureCause` (`threw` / `timeout` / `violation` / `reentry`), `ModTraceOutcome`, `ReceiptFaultKind` (kernel-internal). Notice codes: +1 — `mods.registration_refused` (audience `user`, the seat in `detail`). Configuration code: `config.mods_invalid`. Type members: `RunnerDeps.mods?` and `TaskSpec.mods?` (both optional).
541
+
542
+ ### The mods kernel: one bus, one chain, one failure law (#1398 car A; additive) (@server @cli @client-core @test)
543
+
544
+ - **Before.** An extension point was a single slot (`Hooks`, `promptProvider`); several extension authors on one point were composed outside the engine.
545
+ - **Now.** `src/core/mods/kernel/` — registration and its two seats (`RunnerDeps.mods`, `TaskSpec.mods`, kept side by side), the seat × tier table, admission through `plugin.register`, the `uses` upper bound, the chain `($, e, next)` with `next.to` (CC's allow table), `next.origin` / `trace` / `signal` / `budget`, re-entry frames, a top-level exact matcher, site rules (pinned keys, the station's answer form; a violation is judged by the failure column and never reported as a crash), `observe`, `.catch`, the per-handler self budget (paused in `next` and `$`, not in `$.clock.*`), and the per-event failure / abort / pinned columns in one event table (`MOD_EVENT_TABLE`: 33 `classic.*` names, ten with a sema station; the wave-one engine events; the shell's surface names with `side: "surface"`). `$` on the engine bus carries eleven methods whose bottoms are existing seats (`src/core/mods/dollar.ts`). INTEGRATION §10.
546
+ - **The reference's event set (CC 2.1.296).** `MOD_EVENT_TABLE` holds all 142 names — 33 `classic.*`, 46 engine events, 63 `$` methods, generated once from the reference into `test/fixtures/cc296-mod-events.json` and pinned against it; the shell's names (`ui.*` including `ui.notify`, `command.*`, `config.*`, `audio.*`, `prompt.fill` / `suggest` / `edit` / `mention` / `autocomplete`, `prompt.read`) are `side: "surface"`. Stations in this version: eleven `classic.*` (`classic.SubagentStop` beside `classic.Stop`, same columns), `tool.check` (pins `ceiling` too), `prompt.compose`, `prompt.section` (pins `name`, `key`, `mountedTools`), `plugin.register`, and the eleven methods; `prompt.context` and `prompt.attachment` are named, not raised.
547
+ - **Selection.** An `on` pattern is checked against `uses.events` on every name it selects, raised or not, before the station filter — an undeclared `on("tool.call")` is refused now, not first in the version that raises it; a pattern that selects only names with no station refuses the prepare.
548
+ - **Re-entry asks `.catch` (CC 2.1.296).** A handler left out for re-entry that sits on a frame above the raiser, is not the raiser's, and has a `.catch` answers through it (`next.error.kind = "reentry"`); the raiser's own handlers stay silent.
549
+ - **A deny after `next` (CC 2.1.296).** On a `$` method, a handler whose `next(e)` came back with a result and that then answers `{ deny }` fails (named on `onError`); the caller gets the real result. Handing up a deny from below is not one; a judging event (`tool.check`) tightening after reading the engine's verdict is ordinary use. The bus names every handler it skips on its own methods.
550
+ - **Also (#1794): a `.catch`'s `next` counts as running, like a handler's.** The run a `.catch` starts below with `next(e)` is now counted in flight by the one start both paths share; a `.catch` that calls `next(e)` without waiting and answers `{ deny }` at once fails exactly as the handler form does (named on `onError`, the handler skipped, the caller waits for and gets the real result). Before, that deny was taken: the caller got the refusal while the call below ran on and finished. A `.catch` that awaits `next(e)` and then denies was a late deny already. The same start also puts a settled run on record in the same turn it stops counting as running (a deny answered a turn or two after a fast run finished was taken, for a handler's `next` as well), and no longer returns the failure it records (a run below that fails with a thenable as its reason left an unhandled rejection).
551
+ - **Also (#1806): a `{ value }` answered while `next` still runs fails like a deny does.** On a `$` method, a handler (or its `.catch`) that starts `next(e)` without waiting and answers `{ value }` at once stands in place of a call that is still running: it fails (named on `onError`, `a value while its next() still runs`), and the caller gets the real result. Before, the value was taken: the caller got the substitute while the call went on to finish, with nothing reported. A value written after `next(e)` was awaited is the handler rewriting what it read, and stands.
552
+ - **Payloads and answers received whole (CC 2.1.296 fixes a truncated hook input).** What a handler hands on and answers goes through the engine's one receipt of a caller's object (`src/core/mods/kernel/receive.ts`, zero-dependency): copied at every depth, cycles and shared references kept, a function or class instance handed as it is, no getter ever run — an accessor, or a payload that cannot be copied whole, is a site violation; never a shorter payload, no depth limit. The observer's copy detaches data containers at any depth.
553
+ - **One receipt (#1733).** The configuration receipt (`receiveOwnProperties`: the top record, each getter once) and the station-answer receipt (owned data to eight levels, nothing of the caller's run) are now two modes of that one receipt; both read as before.
554
+ - **`$.model.complete` (CC 2.1.296).** `prompt` and `system` take a string or a list of blocks `{ text, cache?: true }`; a list travels on the event as its joined text beside `promptBlocks` / `systemBlocks`. `cache: true` on a system block ends a span on the brain's existing system-block cache seat (`Context.systemBlocks`); on a prompt block it is the caller's throw (the user message has no cache seat). Every reply is booked once on the run's side-call account (`ModSeatView.book`, a required seat) as well as returned.
555
+ - **Budget constants** are the reference's (10 s own, 1 s `.catch`, runaway 5 s after), re-anchored on 2.1.296.
556
+ - **Wired.** `RunnerDeps.mods` / `TaskSpec.mods` are read: each leg's prepare admits both seats once (`admitModChain`; `plugin.register` runs at admission) and the stations raise on the leg's bus — the eleven `classic.*` stations, `tool.check` (and an ancestor's chain on a delegated child), `prompt.compose` and `prompt.section` (#1604 M1a); the sections below carry each station's law.
557
+ - **Package.** `@sema-agent/mods-kernel` compiles `src/core/mods/kernel/` on its own — zero dependencies, ES2023 + DOM globals only, one entry `index.ts`, version = core's — for the shell and client-core (its shell is wired with the multi-package base). `KERNEL_CONTRACT_VERSION` (1) is an integer compared for equality and moves only when the chain semantics, the registration form, the event closed set (names and side) or the failure columns change. The package name is open to veto.
558
+ - **Divergences from the reference.** `budgetMs: 0` is a zero budget (the reference reads ≤ 0 as unbounded); a handler failure on `classic.PreToolUse`, `classic.UserPromptSubmit` or `tool.check` is final (the reference skips every failure; `plugin.register` follows the reference: a failing judge is skipped, only `{ refuse }` refuses); a matcher compares top-level fields only (the reference walks nested objects); a task registration may not refuse a deployment one (the judge is skipped; the mounted record lists the skipped judges in `skipped`).
559
+ - **Divergence (stricter than the reference):** `cache: true` on a prompt block is refused (`mod-model-complete-prompt-cache-refused`; the reference's handling not yet read; retires when a message-level cache seat lands).
560
+ - Pins: `test/mods-kernel-registration.test.ts`, `test/mods-kernel-next-to.test.ts` (25 cells), `test/mods-kernel-dispatch.test.ts`, `test/mods-methods.test.ts`, `test/mods-kernel.layering.test.ts` (leaf imports, no `instanceof` / module identity, the standalone kernel build, two built copies interoperating, one integer contract number, one receipt), `test/mods-host.test.ts`, `test/mods-kernel-296.test.ts`; the receipts' own `test/own-property-receipt.test.ts` (Q1–Q7) and `test/backlog1709-host-verdict-one-reading.test.ts` (H1–H9) unchanged.
561
+
562
+ ### The classic stations raise `classic.*` on one bus per leg; `Hooks` retired; both seats run; a delegated child's stop is `classic.SubagentStop` (#1398 car B; **BREAKING**; hard-breaking: three, open to veto) (@server @cli @client-core @test)
563
+
564
+ **Hard-breaking (open to veto).**
565
+ 1. **What breaks:** the `Hooks` family is gone — `Hooks`, `TaskSpec.hooks`, `RunnerDeps.hooks`, `HOOK_FAMILIES` / `HookFamily`, `HOOKS_INVALID_CODE` (`config.hooks_invalid`), `preToolUseObservational`, `Hooks.timeoutMs` and the hook context types (`HookToolContext`, `UserPromptSubmitContext`, `PostToolBatchContext`, `StopHookContext`, `StopFailureContext`). A `hooks` key present on either seat at run time (plain JavaScript, or past the type checker) is refused by name, as every retired seat is — `new Runner({ hooks })` throws `config.deps_retired_key`, a task spec carrying it fails the leg `config.spec_retired_key` (the root and a delegated child alike), and a resume carrying it is refused before the claim as `checkpoint.config_refused` carrying `config.spec_retired_key` — instead of being ignored. **Who goes red:** every host that builds a `Hooks` record: a TypeScript build fails; a run that still passes `hooks` is refused with a sentence naming the retirement and `mods`. **How to fix:** register on `RunnerDeps.mods` / `TaskSpec.mods` (`ModRegistration`; the recipe below).
566
+ 2. **What breaks:** the host-side folding tools retire and the constraint mint changes its input — `receiveHookResult` / `isEngineRefusal` are no longer exported (INTEGRATION §4.6f is gone); `createPreToolUseConstraintPolicy(hook, env, onCrash, timeoutMs)` is now `createPreToolUseConstraintPolicy(chain, env)` over a bus (an inherited entry carries `mods`, the installing leg's chain, where it carried `preToolUse`). **Who goes red:** a host that folds several hooks into one before handing it to the engine (server `composeHooks` and its slot table), and a host that rebuilds a screening entry for a cross-process resume. **How to fix:** hand each party its own registration — the engine's chain composes them; rebuild an entry as `createPreToolUseConstraintPolicy(await admitModChain({ deployment, task }), env)`.
567
+ 3. **What breaks:** the whole-slot override becomes two seats side by side, and four shapes follow — a task's registrations no longer replace the deployment's (`spec.hooks ?? deps.hooks`; the core-internal slot multiplexer of `runSpec` and `tightenTaskSpec`'s "both set ⇒ throw" arm retire with it); `WiringManifest.hooks[]` becomes `WiringManifest.mods[]` (`{ name, seat, tier, events, observe?, skipped? }` mounted, `{ name, seat, tier, refusedBy, reason }` refused); the `hook` member of `allowRuleShadowed[]` carries the allowing registration's name in `entries` (it was always empty); `ToolGateInput.preToolUse` / `hookTimeoutMs` become `ToolGateInput.modBus`; a delegated child's stop station raises `classic.SubagentStop` (a resumed child too), any other leg `classic.Stop`. **Who goes red:** a host that set a task record to silence the deployment's hooks; a projection reading `wiring_manifest.hooks` (server `trace/project.ts`, client-core `wiringManifestView.ts` / `eventToSdkMessage.ts`, cli `wiringManifestStore.ts`); a host driving `runToolGate` with a raw `preToolUse`; a registration hung only on `classic.Stop` that relied on it to see a child's end. **How to fix:** a deployment handler that must not act for some task branches on `e`; projections read `mods[]`; a direct gate driver passes a bus (`admitModChain`); hang the same handler on `classic.SubagentStop` too.
568
+
569
+ **Migration recipe (a host's own; the engine ships no converter).** One old record = one `ModRegistration` (`name` unique in the run; `tier` `"user"` on the task seat, `"prepend"` / `"user"` / `"append"` on the deployment's; `uses.events` lists every event hung), each seat one handler: `preToolUse` → `on("classic.PreToolUse", …)` (`e.tool`, `e.input`, `e.tool_use_id`; return `next(e)` where the seat returned `undefined`), `postToolUse` / `postToolUseFailure` / `postToolBatch` → `classic.PostToolUse` / `classic.PostToolUseFailure` / `classic.PostToolBatch`, `userPromptSubmit` → `classic.UserPromptSubmit` (`e.prompt`, `e.source`, `e.inputId`), **`stop` → the same callback on both `classic.Stop` and `classic.SubagentStop`** (byte for byte the old reach: the old seat ran on every leg), `stopFailure` → `classic.StopFailure`, `preCompact` / `postCompact` → `classic.PreCompact` / `classic.PostCompact`, `permissionDenied` → `classic.PermissionDenied` (the three observer seats — `postCompact`, `stopFailure`, `permissionDenied` — hang `{ observe: true }` and return nothing: an observe handler gets a writable detached copy, as the old seats did; any other handler's `e` is a frozen received copy); `timeoutMs` → the registration's `budgetMs` (the old default was 600 000 ms, a registration's is 10 000 — carry the old value to keep the old bound); `preToolUseObservational: true` → `{ observe: true }` on the PreToolUse handler. INTEGRATION §10.6 has the station table.
570
+
571
+ - **Before (v7.51.0).** Ten callback seats on one `Hooks` record, one record per task (`spec.hooks ?? deps.hooks`), each seat with its own crash / timeout / abort posture; several extension parties folded outside the engine; the stop seat ran on every leg under one name.
572
+ - **Now.** Each station raises its event on the leg's bus (one admission per leg, at that leg's prepare — root, delegated child, resume): `classic.PreToolUse` (the gate's phase 1, the approved-edit re-screen, the resumed leg's re-screen), `classic.PostToolUse` / `PostToolUseFailure` / `PostToolBatch`, `classic.UserPromptSubmit` (objective, live steer, parked redelivery, wake message), `classic.Stop` / `classic.SubagentStop` (by the delegation fact, never by the leg kind — a resumed leg's kind reads `resume`), `classic.StopFailure`, `classic.PreCompact` / `PostCompact`, `classic.PermissionDenied` (the gate's deny exit; a host driving `runToolGate` itself has its raw `permissionDenied` callback run as one more observe handler of that event). Both seats run, deployment first within a tier. A delegated child dispatches its parent's task seat as its own task seat (the parent spec seat carries `mods`; a child's own `mods`, `[]` included, wins), its events carrying the child's identity and `agentId`; the parent's chain still rides the delegation fold as one constraint, which collapses into the child's phase 1 only when the child mounted the same chain (registrations admitted, each having hung the same handlers — pattern, matcher, observe, `.catch`, in order) under the same installation context (approver, durable mandates, environment, classifier instance). The constraint is minted from the installing leg's `classic.PreToolUse` handlers that judge (an observe handler never enters it). A thrown value in any failure sentence is named by the engine's egress-redacted renderer (the one the gate's own refusals use), handed to the bus as `describeThrown`.
573
+ - **Behavior changes beyond the shape.** (B2) a task with its own registrations no longer silences the deployment's. (B15) a registration hung only on `classic.Stop` is no longer called at a child's end. A decision station already cancelled is not consulted at all. A failed handler is named in the model-facing refusal (`a PreToolUse hook crashed (extension "x" threw …)`). An `observe` PreToolUse handler that throws or overruns is skipped (the old observational face failed the call closed). A `classic.PostToolUse` / `PostToolUseFailure` handler that throws is skipped and reported on `onError`. `runSpec`'s oracle stop gate is a task-seat registration (budget 600 000 ms) — a deployment's own user-tier `classic.Stop` handler now runs outside it.
574
+ - **Exports (`handoff:diff` against the release base).** Removed (each BREAKING, all in hard-breaking 1–2 above): `HOOKS_INVALID_CODE`, `HOOK_FAMILIES`, `isEngineRefusal`, `receiveHookResult`, `type HookFamily`, `type HookToolContext`, `type Hooks`, `type PostToolBatchContext`, `type StopFailureContext`, `type UserPromptSubmitContext`, `type WiringManifestHookEntry`. Added: `admitModChain`, `type ClassicEventBase`, the classic events' `e` types (`PreToolUseEvent`, `PostToolUseEvent`, `PostToolUseFailureEvent`, `PostToolBatchEvent`, `UserPromptSubmitEvent`, `StopEvent`, `StopFailureEvent`, `PreCompactEvent`, `PostCompactEvent`) and `type StopHookResult`. Configuration code: `config.hooks_invalid` retires (a `hooks` key is refused by the retired-key codes, `config.deps_retired_key` / `config.spec_retired_key`; `config.mods_invalid` stays a malformed registration's). Closed sets: `HookFamily` retires. Wire: `wiring_manifest.mods[]` replaces `wiring_manifest.hooks[]`; a run may now carry `classic.SubagentStop` in a projection that names the event.
575
+ - **Kernel (one host-facing addition on the bus input).** `ModBusInput.isProxy` — the host's Proxy probe: a Proxy a handler answers or hands on through `next()` is refused before any of its traps runs (a violation of the site); the engine passes `types.isProxy`, so a `classic.PreToolUse` handler answering a Proxy is refused fail-closed as it was before this release (the deny observer fires). Without it the bus read the Proxy through its traps and took the copy.
576
+ - **Who is hurt / compensation.** The three hard-breaking groups above; compensation = the recipe, the retired-key refusal (`config.deps_retired_key` / `config.spec_retired_key`) for a forgotten `hooks` key (loud, never a silently dropped deny), `wiring_manifest.mods[]` naming what each leg mounted.
577
+ - **Known limits.** KL-1728-a closes: a handler's failure reaches `onError(phase: "hook")` as the bus's sentence, the thrown value named by the redacting renderer — never the raw thrown object (pin `test/mods-classic-redo.test.ts` T1). KL-1398b-a (runSpec's gate inside a deployment's user-tier stop handler), KL-1398b-c (`$.session.messages()` reads the leg's latest model request) and KL-1398b-d (a `register` that hangs the same handlers on two legs but branches inside them collapses — the handler body is a per-leg closure, not compared) are new.
578
+ - **Divergences (registered):** `child-legs-read-ancestor-task-mods-as-constraints` (a child runs the same chain, CC form; only a different chain or a different installation context adds one constraint judgment), `mod-observe-declaration`, `mod-hook-failure-fails-closed`.
579
+ - **Pins.** `test/mods-classic-stations.test.ts`, `test/mods-child-legs.test.ts`, `test/mods-classic-redo.test.ts` (stop naming, both seats, the chain-collapse cells, the redaction of a thrown value, `hooks` refused at the three entrances, runSpec's gate beside a deployment stop); the pre-mods hook pins migrated through the test-only `test/helpers/legacy-hooks.ts` (one record ⇒ one registration; `stop` hung on both names).
580
+
581
+ ### `tool.check`: an extension reads the gate's verdict once per adjudication round — it tightens freely and widens only through the allow layer (#1398 car C; behavior addition for a host that registers on `tool.check`, none otherwise; hard-breaking: none; closed sets: none; exports: +0 / −0; members: none) (@server @client-core @test)
582
+
583
+ - **What changed.** The gate raises `tool.check` (`e` = `tool`, `input` — the round's final args, after the Write spelling repair and every rewrite —, `tool_use_id`, `agentId?`, `ceiling?`, all pinned) at most once per adjudication round and never over a standing deny: in front of the ask branch over a standing allow (behind the org layer and the stored deny / ask rows); else behind the read-only arm, ahead of the classifier, over the standing ask or the allow the branch cleared it to; and once in each approved-edit round. The chain's bottom answers the verdict (`{ decision, reason?, classes?, mandated?, ceiling? }`). One comparison reads the answer: handed up unchanged ⇒ the verdict stands; `deny` (or a handler that fails, overruns, answers out of form, or a cancel) ⇒ refused, `deniedBy: "hook"`, the handler named, the refusal reporting the args the check judged (a policy's redaction, an approved edit); `ask` ⇒ the extension's question under the hook provenance (`decisionReason: "hook"` — over a standing ask, that ask keeps its members) — no mode releases it, no classifier answers it, no sandbox admits it; `allow` over an ask ⇒ cleared (`decisionReason: "hook"`) only behind the read-only arm and only where a member of the allow layer may clear it, otherwise the ask stands with `allowRuleShadowed` `{ member: "hook", entries: [<registration>] }`; an approved-edit round only tightens. A clear does not end the classifier's denial streak. INTEGRATION §10.7.
584
+ - **Who breaks.** Nobody: with no handler on `tool.check` nothing is raised and the gate reads as before (the existing gate pins are unchanged). A deployment that registers gains the event. The existing words carry the new source — `decisionReason: "hook"` on an ask or an allow, `deniedBy: "hook"` on a refusal, the `hook` member of `allowRuleShadowed` — so a projection that reads them as "a PreToolUse hook" now also sees an extension's verdict (the `entries` name which registration).
585
+ - **How to migrate.** Nothing to do. server — the registering side: a settings-hook adapter that judges a call after the permission rules hangs on `tool.check` (answer `{ decision, reason? }`; `next(e)` = no opinion), not on `classic.PreToolUse`.
586
+ - **Known limits.** KL-1398c-a — a defect carried by #1769: a delegated child that authors its own task seat did not raise its parent's task-seat `tool.check` (retired in this release: the #1769 section below). KL-1398c-b — an extension's question is answered like a PreToolUse hook's own ask: a blanket `onAsk: "allow"` or a deployment policy's approval answers it; the answer form follows the reference (`{ decision, reason }`) and carries no real-approval bit. KL-1398c-c — a defect carried by #1773: a judge that is not a person (`tool.check`, as the classifier already) judges the live args and the gate runs the live args, so a deployment policy that keeps `req.args` and edits it during the wait moves what runs (retired in this release: the #1773 section above).
587
+ - **Divergence (registered):** `mod-tool-check-widens-through-the-allow-layer` — the reference lets a hook's `allow` replace the engine's ask or deny for any tool not marked tighten-only; here a deny raises nothing and an `allow` widens only through the allow layer's predicate (CC 2.1.296 `_ht` / `BYn`, the tighten-only list `I8r`).
588
+ - **Pins.** `test/mods-tool-check.test.ts` (the two raise points and the round's flag, a deny raises nothing, the comparison over the mandated family and the class column, the hook-provenance ask under bypassPermissions / dontAsk, the event's final args and read-only `ceiling`, the approved-edit round, and on a real Runner: a deny rule's refusal, an ordinary ask cleared, the question face and the plan pause kept, a throwing handler named, a resumed leg raising nothing, the Write repair); the mutation table in the car receipt.
589
+
590
+ ### An ancestor's `tool.check` judges a child that authored its own task seat — one reading of "the parent's guard constrains its children" (#1769; behavior narrowing — open to veto; hard-breaking: none; closed sets: none; exports: +0 / −0; members: `ToolGateInput.inheritedToolChecks?` (optional, new)) (@server @cli @client-core @test)
591
+
592
+ - **Before.** A parent's task-seat guard reached a delegated child two ways at once: its `classic.PreToolUse` handlers rode the inherited constraint into the child's fold, its `tool.check` handlers did not — a child whose task seat was authored for it (`mods`, `[]` included: a deployment baseline, a trusted script, a host's own spawn with the trusted chain) ran calls its parent's `tool.check` guard would refuse. A parent chain with only `tool.check` judges handed its children no constraint at all.
593
+ - **Now.** One reading: an ancestor's chain the child does not itself mount is a constraint on both judging events. The screening entry a leg hands down is minted whenever its chain judges a call (a judging `classic.PreToolUse` OR `tool.check` handler — `chainJudges`), and the chain travels on the minted policy. At each `tool.check` raise point (in front of the ask branch, behind the read-only arm, each approved-edit round) the child's gate raises its own bus, then each inherited installation in the chain's order (the order the PreToolUse constraints fold in), each over the verdict the previous left standing, all read by the one comparison: a `deny` refuses (`deniedBy: "hook"`, the registration named, a refusal ends the raise), an `ask` is the extension's question put to the child's approver (the hook provenance — no mode releases it), an `allow` clears nothing (a constraint only tightens; over an ask it is disclosed as the hook member's shadow). `e` is the same as the child's own raise (`agentId` = the child). An installation whose chain the child already raised (its own — a travelled task seat — or an earlier installation's) is not raised again; any other is raised whole, because a handler's answer depends on the chain around it (a child's own handler that answers first, or a deployment wrapper ruling on an inner answer, makes the same registration answer differently), so a registration the child also mounts — a deployment registration included — judges again inside the ancestor's chain, as the `classic.PreToolUse` constraint already judges it (a second judgment, never a lost one). Observe handlers never run as a constraint.
594
+ - **Who is affected.** ① A child spawned with an authored task seat under a parent whose task seat hangs on `tool.check`: its calls now meet the parent's guard — a refusal (`denied by extension "<parent registration>"`) or a hook-provenance question on the child's card where it ran before. server / cli / client-core: a child's card or refusal can now name a registration of an ancestor's task seat (same words, same members — `decisionReason: "hook"`, `deniedBy: "hook"`, the `hook` member of `allowRuleShadowed`). ② A durable park of a child records one more inherited constraint where an ancestor's chain (either seat) has only `tool.check` judges (`parentConstraintCount` +1, as a judging `classic.PreToolUse` chain already does); a cross-process resume re-supplies it like any screening entry (§4.6). server: a revive leg that supplies a fixed layer count (`boot/parked-revive-gate.ts` supplies the caller policy only) meets `resume.parent_constraint_mismatch` on such a row once a deployment or task registration judges on `tool.check` — today none does. ③ A host re-supplying a screening entry: hand back the policy `createPreToolUseConstraintPolicy` minted, as it is — an entry whose `mods` names a judging `tool.check` handler beside a policy the mint did not make (a wrapper, a spread copy) is refused: before the claim on a resume (`resume.parent_constraint_mismatch`, the row stays pending), `config.mods_invalid` at a fresh leg's prepare. ④ The UNGATED write warning no longer fires for a deployment whose only adjudicator is a judging `tool.check` handler (it judges every call). ⑤ A deployment `tool.check` handler with side effects (an audit line, a counter) sees a call of a child that authored its own seat twice — once in the child's chain, once in each differing ancestor chain — as a `classic.PreToolUse` handler already does through the fold. A child whose chain is its parent's (the Agent, fork and Workflow lanes when the child authors no seat) is judged exactly as before — once, on its own bus.
595
+ - **Reference.** CC 2.1.296 runs one module chain per session and raises `tool.check` for a subagent's call on that chain with the subagent's `agentId` (`cli296.js:246803` `_ht` / `hht`, bytes 15804187–15804771; `"tool.check"` 29× in the corpus); there is no per-leg task seat, so the guard always judges a subagent's call — the outcome sema now has; no divergence entry.
596
+ - **Pins.** `test/mods-tool-check.test.ts` K3 (flipped: refuses), A1–A13 (deny / ask / allow-never-widens / three generations in order / the parent's chain raised whole / the approved-edit round / an observer never a constraint / a host spawn with no parent seat / a host re-supply, minted and refused / a child's own first-answering handler beside the shared guard / a durable park refused pre-CAS then redeemed / an append-tier deployment guard the child's chain skips / a prepend deployment wrapper's ruling kept), N1–N3 (the Agent, fork and Workflow lanes unchanged: once), G1–G4 (the fold at the gate: the minted ask's evidence stamp, an inherited `allow` disclosed, both shadows, a refusal ends the raise); the mutation table in the car receipt. `docs/INTEGRATION-CORE.md` §10.7 / §4.6, `docs/GATE-OUTCOME-STATIONS.md` 36 / 37. KNOWN-LIMITS KL-1398c-a retired.
597
+ - **Also (#1801 / #1802) — one installation identity, one place an ancestor's question goes (behavior narrowing — open to veto; exports: +0 / −0; members: the unreleased `InheritedToolCheck` gains `settle`).** ① An inherited entry's chain identity is read off the bus its policy was minted over (each engine admission — a leg's, or `admitModChain` — records the chain it mounted), never off the `mods` list the entry carries: before, a host re-supplying an entry with `mods: []` (or a list equal to the child's own chain) on a fresh Runner or a cross-process resume skipped the ancestor's `tool.check` guard (#1801, P1). `mods` is now read only as a declaration — a judging `tool.check` handler named beside a policy the mint did not make is still refused, and such an entry licenses no ask grant. ② One predicate (`sameInstallation`: that chain plus the frozen approver, the two durable mandates, the env and the classifier instance) is read by all three places that asked "is this the same installation?": chain assembly (a leg already on the chain is not appended — a non-minted policy declaring the leg's chain no longer keeps the leg's own entry, and so its screening, from its descendants), the `classic.PreToolUse` pass-through, and the `tool.check` skip (the pass-through's own test: same identity, no mandate, a judging own chain; before, the skip compared chains only). An installation with the same chain under another approver, env, classifier or mandate is now raised on `tool.check` too — a handler with side effects may see such a call once more. ③ An ancestor installation's `tool.check` `ask` is settled where the installation froze it, exactly as its `classic.PreToolUse` ask is in the fold (#1802, C-R481): the ancestor's classifier and approver, the child's mode arbitrating first; headless ⇒ refused; an approver answering `"unavailable"`, or a durable mandate, floats the call (marked) to the child's park — in the approved-edit pass too, over the edited bytes (the first pass's unavailable re-route; #1817); a person's approval there that edits the call takes the gate's approved-edit round (re-judged, `tool.check` raised again on the edit; the round cap holds; a question of the child's own still outstanding is put to the child's approver again over the edited bytes). The answer is read by the gate's settlement ledger as the fold reads an inherited answer: a person's refusal there is `deniedBy: "ask_resolution"` with the `human_refused` settlement (it was `hook` with none), a person's yes is the call's settlement; a floated question re-reads the marked-call seat, so no mode releases the marked call. Before, the question went to the child's approver (a child's blanket `onAsk: "allow"` or a child-side yes ran a call the parent's approver would refuse). The gate's approved-edit round now has one entrance for a person's approval that carries args, whoever's question it answered. Pins: `test/backlog1801-inherited-check-identity.test.ts` DD0–DD4 / DD1b / X1 / X3 / T1 / T1b / T1c / T1d / E1 / E2 / U1 / U2 / U2b / U3 / R1–R5 / GC1; `test/mods-tool-check.test.ts` A2 (flipped: the parent's approver), G1 / G1b / G5; `test/inherited-gate-pretooluse-hook.test.ts` (a non-minted entry declaring the leg's chain: the leg's own entry is appended); `test/prepare-inherited-gate-transcript.test.ts` (the ancestor entry handed as minted).
598
+
599
+ ### SendMessage's `summary` is optional: the first line of the message by default, an over-long one cut and told, never refused (#1632; behavior change, loosening — open to veto; closed sets: none; exports: none; members: none)
600
+
601
+ - **What changed.** A SendMessage call without a `summary` (or with a blank one) is no longer refused with `summary_required`; the label is the first line of the message (trimmed, CC 2.1.296 `pHn` @cli296.js:245008), and a summary over 200 characters — the caller's, or a long first line taken as the default — is no longer refused at the schema door ("Fix the arguments") but cut at 200 with the existing ` [summary truncated: N chars total]` marker, on the live frame, the notification line, the parked form, the uplink line and the receipt alike. The advertised schema loses `maxLength: 200` and the "required when message is a string" wording (new description = the reference's, cli296.js:277703), the cut keeps whole code points (the helper had been unreachable behind the schema bound; its surrogate-pair split would have gone live), and the operator continuation verb (`createAgentContinuationVerb`) no longer derives a label of its own (whitespace-collapsed first 200 characters, `operator follow-up` fallback) — it passes only an explicit `summary`, so its label is the first line like every other call's.
602
+ - **Who breaks.** Nobody is refused anymore; a caller or test that matched `details.error === "summary_required"` (the code is retired) or a schema validator that read `maxLength: 200` off the advertised parameters sees them gone; an operator-verb receipt's `details.summary` is now the content's first line instead of its collapsed head.
603
+ - **How to migrate.** Nothing to do; drop any handling of `summary_required`. Reference difference, kept on purpose: the reference cuts at 199 + `…`, this engine keeps its counted marker (`test/backlog1632-send-message-summary-optional.test.ts` D1–D6, L1–L4, C1, O1–O2; `test/prompt-golden-1302.json` refrozen, the entire delta is the `summary` property).
604
+
605
+ ### An objective whose attachments cannot ride one request is refused as a whole, before anything is sent (#1682; behavior change, narrowing — open to veto; closed sets: none; exports: none; members: none)
606
+
607
+ - **What changed.** After the attachments are read and before the human message is appended, the seat sums the frames (as the engine mints them) and the objective text in the request guard's own estimator and compares it with the line the guard enforces for the leg's model (`guardBudget(model, the deployment's live window overlay)`, window less headroom); over it, the run ends `failed` with `config.user_attachment_invalid`, naming the count, the estimated tokens and the line, and nothing is sent, appended or committed (a later run on the session reads the same file in full). Measured at the base on the Runner lane (20 host text entries of ~99 000 characters on a 128K-window model): no refusal and no model error — the run completed, but the first request held attachments 18–20 only (the guard's last-resort trim dropped the objective and attachments 1–17) while `human_input{applied}` reported all 20 as `attached`. A model with no declared window has no line, so nothing changes for it.
608
+ - **Who breaks.** A host that sends an objective whose text and attachments together exceed the model's request line (about 109K tokens on a 128K window, counted at ~3 characters per token) now gets a configuration refusal where it got a run that answered from the last few attachments; it has to surface the sentence or split the attachments.
609
+ - **How to migrate.** Attach fewer or smaller parts (a path entry can take `lines`), or run on a model with a larger window; read the line from the refusal, or compute it as `guardBudget`. The guard's silent trim itself is not changed here (`test/backlog1682-attachment-room.test.ts` P1–P3, Q1–Q3, R1–R4).
610
+
611
+ ### The system prompt is catalog data: every engine-authored prompt text is one catalog message, placed by a catalog section, chosen by traits and facts (#1604 M0, implemented as #1662; BREAKING exports; manifest shape)
612
+
613
+ - **What changed.** `src/prompt-assembly/catalog/` holds every system-prompt text the engine authors as literal data (messages with variants, sections with a rank, a band and a block, a nine-row band table). The composer reads the catalog; the six renderers (role base, model guidance, MCP instructions, reminder mark, environment, memory) are the only code that renders bytes beside catalog text and overlay text (the caller's appended block is a task-class `add` overlay the request-body sugar writes), each renderer with a version. The exported prompt constants (`DEFAULT_SYSTEM_PROMPT`, `CYBER_RISK`, `SUPERVISOR_PROMPT`, `COORDINATOR_ROLE_PROMPT` …) are projections of their catalog entries — the same text, one home. Every byte of the composed prompt is unchanged: a 65 536-cell matrix (2^11 facts × read/write faces × deferred × lean × fable, frozen before the move) and the 20 exported texts are byte-identical. The catalog data's digest and the renderer version vector are elements of the run's prompt identity (the whole identity is in the #1604 M1b section below), so every existing session records one `code_change` record on its next resume (one prefix-cache miss) — the same path any prompt-changing upgrade takes.
614
+ - **BREAKING — retired exports (H1), can be vetoed.** `harnessContext`, `constitutionBlocks`, `composeConstitution`, `defaultPromptProvider` and `SEMA_DEFAULT_PACK` are gone. Who breaks: a host that called them to preview a prompt (they never produced the bytes a run sends). How to migrate: preview with `describeCatalog()` for structure and `prompt.assembled` for what a run sent; a host that wanted "the default provider" wires nothing — the catalog is the default prompt.
615
+ - **BREAKING — pack shapes become the catalog (H2), can be vetoed.** `compose`, `validatePack`, `PackSectionDeclaration`, `PromptPack`, `PromptSlot`, `PromptOwner`, `PromptMutability`, `PromptRenderCadence`, `PromptCacheClass`, `describeDefaultPack` and `DefaultPackDescription` are gone; `describeCatalog()` / `CatalogDescription` / `SEMA_DEFAULT_CATALOG` / `catalogText` and the catalog types (`PromptCatalog`, `PromptBandId`, `PromptTrait` …) are added. A section's `slot / owner / trust / mutability` are replaced by its `band`; what each source class may do to a section is read off the band table (`describeCatalog().sections[].ops`, the three columns `{ center, deploy, task }`): the core-behavior band's sections — the ten simple-roster sections, now writable, and the classic fable paragraph — admit replace / append / drop through the center and deployment columns (the task column is empty), the five mode sections are locked (the constitution band: no source holds any op). Who breaks: the center tier's default-pack route and its `LOCKED_SLOTS` copy; a host linting a custom pack with `compose`. How to migrate: read `describeCatalog().sections[].band` and `.ops` (a center editor reads `ops.center`).
616
+ - **BREAKING — manifest projection (H7a), can be vetoed.** `prompt.assembled.sections[]` drops `slot / carrier / cadence / cacheClass` and gains `band` (always) and `variant` (the selected variant ids, for a message-bodied section; the host role's variant id for the role base); `context_usage.sections[].kind` is now the band id (a section's full v3 member list is the manifest item of the #1604 M1b section). Who breaks: the server's manifest whitelist projection, client-core's two section-view readers, the SDK's `PromptManifest` / `ContextUsageSection.kind` documentation values. How to migrate: read `band`; group by band.
617
+ - **New gates.** `gate:catalog-literal` — English prose of 40+ characters under `src/prompts/` or `src/prompt-assembly/` outside the catalog is red unless it is an operator diagnostic or an identity-registered exemption (renderer inputs, standalone assets, the Cowork base template carried verbatim as the role base's `cowork` variant). `gate:prompt-docs` — `docs/PROMPT-SECTION-MAP.md` is generated from the catalog and `docs/PROMPT-SENTENCE-EVIDENCE-INDEX.md` carries a generated message index; the generator fails on any catalog expression it cannot read.
618
+ - **Composition refuses a duplicate section id or rank** among the rows it may render (the catalog's own and the added entries) before rendering, as the retired pack validation did; two `add` overlays reusing one id are one entry — the later one composes and the earlier is named superseded (`test/prompt-catalog-m0.test.ts`, "composition identity").
619
+
620
+ ### The simple roster, the subagent notes and the `# Memory` instruction re-anchor to the current reference; the fable preset stops repeating the fable roster (#1607; model-visible text, can be vetoed)
621
+
622
+ - **Re-anchored text.** The fable arm of "Communicating with the user" takes the reference's current punctuation and "the change merges"; action caution drops "if what you find contradicts how it was described, or you didn't create it, surface that instead of proceeding"; the autonomy section's parenthetical becomes "(such as restarts, deletes, or config edits)"; the delegated child's notes say "always absolute"; the `# Memory` instruction's frontmatter example sits in a markdown fence with "one-line summary, used to decide relevance".
623
+ - **Delivering-work and corrections stay on the fable axis.** A fable-family model on the simple shape receives both sections, as before. The reference lights them on capabilities of their own; that writer arrives with model traits, and until then the fable axis is their gate.
624
+ - **The fable preset says each thing once (behavior change, open to veto).** `fable5Model()` no longer carries `promptGuidance`: its three lines are catalog text (`core/behavior.fable-classic-guidance`) and compose exactly where the simple roster does not — the classic profile (the `lean_prompt` trait absent) in the standard form (the minimal form leaves the core-behavior band out). On the simple profile a run on the preset loses only that repeated paragraph (each of its three topics was sent twice per turn; the roster's wording now says it once), and every other shape is byte-identical for the preset and for a non-fable model (held per cell across the overlay move by `test/prompt-overlay-migration.test.ts`: the preset, a plain model, a fable-family model and an opus model on both profiles). A fable-family model that is not the preset (a deployment's own `claude-fable-*` / `claude-mythos-5` entry) used to receive no such paragraph on the classic profile and now receives it exactly as the preset does, nothing else moving; an entry's own `promptGuidance` still composes, after it.
625
+ - **Not followed (registered divergences):** the tool-parameter JSON line stays on for every simple run (the reference gates it on a remote quirk config, default off); the `# Memory` index bound stays 25 KB in bytes (the reference counts 25 000 characters).
626
+
627
+ ### Two release-tooling gates: the commit log's tickets are the floor of the release notes, and a ledger row's ticket id stands in its first cell (#1742, #1775; no engine behaviour — `src/` and the package surface are untouched)
628
+
629
+ - **`gate:changelog-coverage` (#1742, new; in `gate:all`'s static tier).** Every `#N` a commit names between the previous `v*` tag and HEAD (subject and body; the commits a merge brings in count, but a merge's own message and the message of a commit whose diff touches only bookkeeping paths — the ledger, the limits list, release criteria, wire manifests, the CHANGELOG, plans; one table in the script — make no fix claim and are not read, and the verdict line counts them) must appear in the CHANGELOG's newest section, or be exempted there on a line that begins `no-notes:` — `no-notes: #101(ledger bookkeeping), #102` (comma-separated, `#N` or `#N(reason)`). The exemption is written by a person, per ticket, in the release's own section; no commit prefix, author or path exempts anything, and a malformed item is red. Red names each missing ticket with the commits that carry it. A tag-less checkout (the remote gate host's throwaway git index) is announced and skipped, exit 0; git that cannot run, or a shallow history (its range would read smaller than it is), is a refusal (exit 2); a commit record that does not frame (a control character in a message) is refused, never read short. The ticket-set reader (`ticketsOf`) is exported for a post lint to read the same set. Why: 7.51.0 shipped two commits naming #1728 that no notes, criteria file or post mentioned — the notes had been written from the cars' dispatch list, not from `git log`. On this tree the gate also found the two #1604 M0 / #1607 sections filed under 7.51.1 by a three-way merge (moved back by the stage head).
630
+ - **`gate:backlog-ledger` (#1775).** A table row that carries a bare `#N` cell anywhere but its first cell (`| | #9001 | … |`, a doubled leading cell), or a second one behind a good first, or a first cell that carries the number dressed (`#9001 (dup)`, `**#9001**`), or a row that ends like a ticket row (a status word, then a window) under a first cell that shows no id at all (so no spelling of the id — markup, an entity — hides a row), is red and named by line (eight progress-table labels that predate the rule are frozen in a list, whole-cell match). Before, only a row whose first cell was the id was read at all, so such a row's status, window and a CHANGELOG mention that shipped it were never judged. One row shape, no cell-count rule: the head cells differ from era to era (169 of the ledger's 1,320 ticket rows have a seventh cell) and the gate reads the tail two.
631
+ - **Known limits.** KL-1742-a — in a checkout with no `v*` tag reachable the coverage gate says so and passes (the remote gate host's throwaway git index is one); the judgment is made where the tags are, in the local release-candidate `gate-all`. Any `#N` on any line of the newest section counts as mentioned — an example in prose included — a written range (`#101–#104`) names only its two ends, a token glued to a word (`#1398a2`) is read as no ticket, and the newest section ends at the next column-0 `## ` line (no markdown container is modelled: a `## ` line inside a code fence ends it early — a false red, never a false green).
632
+ - **Pins.** `test/changelog-coverage-gate.test.ts` (the reading cells, the `no-notes:` grammar, the newest section, the verdict, and the script over throwaway repositories: which tag is the base, a tagged HEAD, a merge, a body, a suffix, no baseline), `test/backlog-ledger-gate.test.ts` ("the id cell is the first cell"), `test/gate-git-unavailable.test.ts` (the new gate refuses on a broken git and outside a repository).
633
+
634
+ ### `gate:import-cycles`: a load-order cycle can no longer enter `src/` unseen — a ratchet over the value-import graph, on one import reader shared with `gate:layering` (#1791; no engine behaviour — `src/` and the package surface are untouched)
635
+
636
+ - **What it reads.** Every `src/**/*.ts` is a node; an edge is a **value** import — a default / named / namespace / side-effect import, an `export … from`, an `import x = require()` — the statements that survive into the emitted JavaScript. `import type`, `export type … from`, inline `import("…").T`, a `declare module "./x.js"` augmentation and a dynamic `import()` are not edges (erased, or resolved after the static evaluation order is settled). The repo compiles with `verbatimModuleSyntax`, so "not marked `type`" and "loaded at run time" are the same set: `import { type A } from "./x.js"` is emitted as `import {} from "./x.js"` and is a value edge.
637
+ - **What is red.** The baseline `scripts/import-cycles-baseline.json` lists today's cyclic strongly connected components (4: sizes 13 / 6 / 4 / 2). A module in a cycle but in no baseline component is a **new member**, named with the shortest import path that closes the loop back to it and the line of each import on it; two baseline components joined into one by a new edge are a **merge**, named with the joining edge; a baseline component the tree no longer reproduces whole (fewer members, split, gone) is **STALE** until banked. What the reader cannot follow is red too — a graph with an edge it could not follow cannot be judged: a relative specifier that lands on no file, climbs above the tree's root, or is spelled with a percent escape, a backslash or whitespace (Node reads `./b%2Ec.js` as `b.c.js` and strips a trailing space; TypeScript looks for files named `b%2Ec.ts` and `b.js .ts`); a `#` subpath import, an absolute path or a non-`node:` URL; a value import that lands on a declaration file or outside `src/`; a value import of the package's own name (it resolves through the exports map to built output); a symbolic link under `src/`. A root with no `src/` is exit 2, and without `--baseline` the baseline is the one inside `--root`.
638
+ - **How the baseline moves.** `npm run gate:import-cycles -- --write` banks a shrink and only a shrink: it refuses (exit 1, the file untouched) while any new member, merge or unreadable edge is present, rewrites nothing when the tree is already within the baseline, and creates the baseline only when the file does not exist. `--root <dir>` / `--baseline <file>` point it at another tree (the pins use them).
639
+ - **One reader.** The import-edge reader moved out of `gate:layering` into `scripts/import-edges.mjs` (`scanImports` → `{ specifier, kind, line }`, `readImportEdges` → resolved edges with `kind` / `resolved`); `gate:layering` calls it and its verdict is unchanged (2112 registered edges, 0 violations, before and after). `verify-layering.mjs` exports its own judgment only; the pins that read the reader (`specifiersOf` / `resolveSpecifier` / `UNRESOLVED_DYNAMIC_IMPORT`) import it from `import-edges.mjs`, and `npm run car:gate` runs `gate:import-cycles` beside `gate:layering`.
640
+ - **First reading.** 670 modules, 4 cyclic components, 25 members — the same member sets as an independent regex reading of the tree; the AST reader additionally sees 16 edges the regex missed (`export { … } from` blocks whose comments contain a `;`), none of them on a cycle. Run against the commit before the `extensionWords` move (the 23-module `fs-shared → mcp → prompt-section-seat → stations → tool-policy → tools/fs/index → fs-shared` loop), the gate is red and names it.
641
+ - **Known limits.** KL-1791-b — the reader is syntactic and models plain relative specifiers only; every other spelling it meets is reported, not guessed, and the compiler's own module resolution (`ts.resolveModuleName` with the NodeNext options: `#imports`, exports maps, symlinks) is the route to following them. A load that goes around the import syntax (`createRequire(import.meta.url)("./x.js")`) is no edge; the one `createRequire` in `src/` reads `../../package.json`. KL-1791-a — the baseline records membership, not edges: a new value import between two modules already in one baseline component is not seen. A hand edit of the baseline (or deleting it and running `--write`) banks growth as a visible diff in a tracked file — no script can forbid the file's author. Other scripts that read import declarations for a different purpose (binding-level analyses in `gate:phase-api`, `gate:closed-sets`, `gate:field-liveness`, `gate:spawn-sites`, `wire-changes`) are not on this reader.
642
+ - **Pins.** `test/import-cycles-gate.test.ts` (142 cells: every spelling's kind held against the compiler's own emit under the repo's tsconfig; the gate as the real script over throwaway trees — new member, shortest path, merge, stale / vanished / shrunk / split, `--write` refusing growth with the file byte-for-byte untouched, unreadable edges, an 8,000-module chain; the repo's own tree and baseline; roster and one-reader wiring) and `test/load-order-fs-shared-first.test.ts` (loads `tools/fs/fs-shared.js` before the barrel — the entry order that exposed the loop).
643
+
644
+ ### A command cut for silence has its own error code: `liveness_no_output` joins both execution error sets, and the Bash tool names the window that cut it (#1795; closed sets: `ExecutionErrorCode` +`liveness_no_output`, `RemoteExecutionErrorCode` +`liveness_no_output`, `RETRYABLE_REMOTE_ERROR_CODES` +`liveness_no_output`; members: `ExecutionError.noOutputSec?` added, optional; exports: none) (@server @cli @sdk @test)
645
+
646
+ - **Before.** An execution env that cut a command for producing no output answered the same code as a command that ran out of its wall-clock budget — `timeout`, by the written contract — and the Bash tool projected that code as "Command timed out after N s (timeout limit: N s)" with N the budget the caller REQUESTED (`details.timedOut` and `details.timeoutSec` likewise): a number that had not elapsed, beside advice (a larger timeout) that could not help.
647
+ - **Now.** `liveness_no_output` is a member of both error-code sets and of the retryable family. It is ran-then-cut like `timeout`: the output captured before the cut rides the error, and zero captured bytes is an error result. The env states the window it enforced as `ExecutionError.noOutputSec` (a new optional member, the fifth constructor argument); the Bash tool says "Command was killed after producing no output for N s (… this is not the timeout you requested)" with N that window — an env that states none gets the same sentence without a number — and its details are `{ livenessNoOutput: true, noOutputSec? }`, with no `timedOut` / `timeoutSec`. `timeout` is unchanged: the wall-clock budget, projected as before. The remote contract text now says which bound answers which word (`readTimeoutMs` → `liveness_no_output`; the command's `timeout` and the lifecycle ops' idle bound → `timeout`). Both code sets are now registered in `docs/CLOSED-SETS.md` (`gate:closed-sets` reads them). The tools that recover a cut command's partial output read the two time-cut words as one (`isTimeCutCode`, `src/engine/harness/types.ts`): `Grep`'s ripgrep leg keeps the matches it captured (fenced, with its incompleteness caveat), and the PDF text and page legs of `Read` keep the leading text and the contiguous complete pages — a silence cut recovers what a budget cut recovers. The PDF legs' failure sentence for a silence cut names the silence ("was cut after producing no output for N s", no number when the env states none), not the 120 s budget.
648
+ - **Who breaks.** Nothing changes until an env answers the new word; the server's execution lanes switch codes in the same window. A consumer that holds a closed copy of either code set (cli, sdk) adds the word. A reader of a Bash result's `details.timedOut` / `timeoutSec` no longer sees them on a silence cut — it sees `livenessNoOutput` / `noOutputSec`. A retry whitelist keyed on `timeout` that meant to cover silence cuts adds the word (`RETRYABLE_REMOTE_ERROR_CODES` already carries it). **Known residue:** when the PDF legs recover a PARTIAL result from a silence cut, `Read`'s card still words it "pdftotext / pdftoppm timed out after N s" (`fs-pdf.ts`), with N the window the env stated (the 120 s budget when it stated none) — the partial content is complete and flagged; only that sentence's verb and, in the unstated case, its number are the budget's.- **Pins.** `test/backlog1795-liveness-no-output.test.ts` L1–L8 (the projection with and without a stated window, a window that is not a positive number, zero-output, the sets, the constructor, the unchanged `timeout` arm), `test/backlog1795-time-cut-consumers.test.ts` C1–C6 (both PDF legs with and without recovered output, the ripgrep leg, the unchanged budget arm, the other codes staying unrecovered, the predicate's total table), `test/rb439-remote-env-lifecycle.test.ts` (the retryable family).
649
+
650
+ ### A parked resume whose child transcript is damaged names its cause: `transcript_corrupt` (#1796; closed sets: `RESUME_STARTUP_CAUSES` +`transcript_corrupt`; exports: none; members: none — `details.cause` gains a value) (@server @cli @sdk @test)
651
+
652
+ - **Before.** `RESUME_STARTUP_CAUSES` held five words and none for a transcript the store holds but cannot read: a parked resume over such a transcript failed startup with `details.error: "parked_resume.startup_failed"` and no `cause`, so a consumer could not tell it from any failure the table does not name.
653
+ - **Now.** A sixth word, `transcript_corrupt`, read from the session store's own codes: `corrupt_session` (the log has no header its store wrote) and `invalid_session` (a leaf or parent entry is missing from the log). The resume rewind joins them: a checkpoint cut at an entry the transcript no longer holds (a lost tail — the log is a clean prefix) used to surface the store's bare `not_found`, which is also the word for a missing session; it is now raised as `invalid_session` there, and only there, so the receipt carries the cause. It reopens as `env_failed`, exactly as an absent transcript (`session_gone`) does, so the reopen compensation and its sentences are unchanged; only the receipt gained its reason. `ResumeStartupCause` is now registered in `docs/CLOSED-SETS.md` with its reopen table (`ReopenReasonTableCoversEveryCause`), so a seventh cause is a compile error until it has a row.
654
+ - **Who breaks.** A consumer that switches over the five words (the server's passthrough, the sdk's closed copy, cli) adds the sixth; one that read "no `cause`" as "something else failed" now reads the word for this case.
655
+ - **Pins.** `test/backlog1796-transcript-corrupt-cause.test.ts` T1–T5 (a real file backend, a cold second generation, the store's own refusal for both codes, the lost-tail prefix, and the absent-transcript neighbour), `test/backlog1448-resume-startup-cause.test.ts` R1 / R1b / R11 (a storage rejection with a hostile `code` getter keeps its own coded cause through the rewind's mapping).
656
+
657
+ ### The built-in memory tools' "were NOT mounted" advisories are told once per Runner, through one mint (#1797; behavior change — the unit moves from the session to the Runner; closed sets: none; exports: none; members: `AnnounceOnceSink.onErrorOncePerRunner` added to an internal contract) (@server @cli @test)
658
+
659
+ - **Before.** The four stations that decline to mount a built-in memory pair (the shared-memory pair and the engine memory trio; selector excluded, name occupied, or the ToolSearch retraction) announced on the once-per-session ledger, each station with its own copy of the sentence. A serving layer that opens a fresh session per request delivered the same standing configuration fact (`memory-tools-not-mounted`, `shared-memory-not-mounted`; `onError`, `phase: "config"`) on every request.
660
+ - **Now.** One mint, `announceMemoryNotMounted`, writes the sentence frame, the classification word and the scope for all four stations. The scope is the Runner: the first prepare that meets the fact tells the operator sink, every later run of that Runner — the same session or another — is silent, and a second Runner (its own sink wrapper) is told once itself. A changed sentence (the excluded name moved) is a new line; the identity is the redacted `(code, message)` digest, as before. The sentences are byte-for-byte what they were. The seat is `AnnounceOnceSink.onErrorOncePerRunner`: a memo keyed on the Runner's sink object, bounded like the session record, writing nothing to the transcript.
661
+ - **Who breaks.** An operator that counted these advisories per session now sees one per Runner. A process restart over an existing session tells the new Runner once again (the session record no longer suppresses it). The shared-memory pair's advisory moved with the engine trio's — one family, one scope. A test fixture that builds an `AnnounceOnceSink` by hand adds the member (two fixtures here did). Free-function `runTask`, which builds a Runner per call, tells once per call.
662
+ - **Pins.** `test/backlog1797-memory-advisory-per-runner.test.ts` P1–P7 (both pairs, both station kinds, one Runner and two, a changed sentence), U1–U5 (the seat), S1 (one sentence frame, four stations through it); `test/backlog560-memory-advisory-once.test.ts` ① ④ re-pinned (the digest and changed-text cells keep their meaning).
663
+
664
+ ### `BackgroundChildEvent.parentToolCallId` documents the Workflow lane (documentation only — no behavior, no member)
665
+
666
+ - The field's note named only the `Agent` call. A workflow's `wa*` rows (spawn, every tick, the terminal frame) have always carried the `Workflow` call's id, one id shared by every row of the run — one card to N children, so only `taskId` tells the rows apart — and carry none when the workflow was started with no launching tool call. The note now says so; `test/hardening-cls-c-field-mismatch.test.ts` CLS-C-7 gained the fan-out cell.
667
+
668
+ ### Documents brought to what the code does; `composeSystemPrompt` retired; the PDF partial-result sentences name the cut that happened (#1811, #1795 follow-up; BREAKING export: −1; behavior change: model-facing sentences on a silence cut only; closed sets: none)
669
+
670
+ - **Documents (no behavior).** `docs/PROMPT-CUSTOMIZATION-GUIDE.md` taught the retired `PromptProvider` family (`stableBlocks` / `stableSystem` / `replaceAll`, the slot picture, `assertPromptCacheFriendly`, the v1/v2 manifest faces); it now routes a deployer to the doors that exist — `TaskSpec.systemPrompt` / `appendSystemPrompt` / `promptRole` / `promptForm`, a `prompt.compose` registration on `TaskSpec.mods` or `RunnerDeps.mods`, the center layer, `prompt.section` — and points at INTEGRATION-CORE §12 / §10.8 and `docs/sdk/06` for the contract and the sample instead of carrying its own copy; the retired doors are one table. INTEGRATION-CORE: the H2 migration rows no longer say the catalog view's `locked` (it carries `ops`, §12.3), and the §10.3 "a deny after `next`" bullet states the value arm of #1806 (a `{ value }` answered while `next(e)` still runs fails like a deny does; after `next(e)` answered, a value that rewrites what the handler read stands). `docs/RELEASE-TIERS.md`: `car:gate` runs four steps (six with `--full`), `gate:import-cycles` among them. Two `RunnerDeps` JSDoc links named the retired `promptProvider`; they name `mods`. Three more documents taught the retired provider family and the pack (`stableBlocks` / `stableSystem` / `describeDefaultPack()` / `assertPromptCacheFriendly`): `docs/THIRD-PARTY-INTEGRATION.md` §2.1, `docs/ARCHITECTURE.md` §3.1 and `docs/SERVICE-INTEGRATION-GUIDE.md` §6 now say catalog / overlays / `describeCatalog()` and link the customization guide. INTEGRATION-CORE §10.8 and §12.7 no longer say "the later one on the same target wins" for every op: what a later overlay does to an earlier one is the op's rule — `replace` of a message won by the later, `append` accumulates, `add` keyed by overlay id (same id replaces and moves to the end of the band's additions, a different id stands beside it), `drop` removes a section or a named entry (`foldOverlays`, `overlay.ts`).
671
+ - **BREAKING — retired export, can be vetoed.** `composeSystemPrompt` (and its private helper) is gone from the root export (−1; `test/export-surface.snapshot.json` regenerated, the diff is that one name). Who breaks: a host that called it to join a base prompt and a memory block. No engine path called it (the composer places the memory tail), sema-server has no source reader (its consumption ledger lists the name as `skip`), and no other neighbour imports it. How to migrate: join the two strings yourself, or stop assembling a prompt by hand and write overlays (INTEGRATION-CORE §12.6).
672
+ - **The Read tool's PDF partial-result sentences (#1795 follow-up, KL-1795-a).** A `pdftotext` / `pdftoppm` run cut by the env's silence window used to be reported "timed out after N s" in its five model-facing sentences (the partial text note in both Read shapes, the partial and the complete-at-teardown page headers, the vision-fallback note), with N the 120 s budget when the env did not state its window. The partial results now carry the cut itself (`cut: { kind: "budget", sec } | { kind: "silence", sec? }`, replacing `timedOutAfterSec` on the two internal result types) and the sentences read it through one function (`pdfCutWords`): "was cut after producing no output for 30s", or "was cut for producing no output" when no window is stated, and "before the cut" for the later clause. A budget cut reads byte for byte as before. The `timedOut: true` card markers stay: a silence cut is a time cut in the engine's one reading of the two codes (`isTimeCutCode`).
673
+ - **Pins.** `test/backlog1795-time-cut-consumers.test.ts` C1 / C1d / C2 / C4 (the result carries the cut; no number when the window is unstated) and R1–R5 (the five sentences through the real `readPdfFile`, and the budget arm unchanged); `test/prompt-cache.test.ts` and `test/memory.test.ts` join their strings locally; `test/export-surface.test.ts` green against the regenerated snapshot.
674
+
675
+ ### The engine layer imports nothing from `tools/`; the tools reach it through the `internal/harness` facade again (#1829; internal layering — no behavior change; closed sets: none; exports: none; members: none)
676
+
677
+ - **Before.** The loop (`engine/loop/agent-loop.ts`), compaction (`engine/compaction/utils.ts`), the symlink-chain walk (`engine/execution-env/symlink-chain.ts`) and one inline type in `engine/loop/types.ts` imported from `tools/fs/` (`image-admission`, `safety`). Any tool that imported the facade (`internal/harness`) then closed a load cycle (facade → harness → loop → tools → facade), so five tools (`dir-entries`, `pdf`, `search`, `fs-bash`, `fs-read`) deep-imported `engine/harness/types.js` instead — which `test/vendor-insulation.test.ts` reads as a facade bypass.
678
+ - **Now.** The shared pieces sit below both layers, each defined once, every importer pointed at the new place (no re-export left behind): `fileArgPath` in `src/core/tool-registry.ts` (the `file_path` / `path` slot read by `pathTargetValue`), `isUncSpelling` in the new dependency-free `src/core/unc-spelling.ts`, and the media admission in `src/core/image-admission.ts` (moved from `src/tools/fs/image-admission.ts`) together with the parameters it checks — `MAX_IMAGE_READ_BYTES`, `MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES`, `PDF_TARGET_RAW_SIZE`, `imageMagicMatches`, `pdfMagicMatches` (from `fs-shared` / `pdf` / `safety`). The five tools import the facade; `gate:domain-lexicon` names the UNC leaf as the second home of the path pattern.
679
+ - **Who breaks.** Nothing at run time: same code, same answers (`fileArgPath` over a function-typed `args` object is the one input class whose reading differs — not reachable from model-supplied JSON). The root export surface is unchanged (`handoff:diff` empty). A deep import of the moved names from their old files (a copy-paste demo, a fork) must follow them.
680
+ - **Pins.** `test/vendor-insulation.test.ts` — no engine file names a `tools/` module (static, `export … from`, dynamic, inline `import("…").T`), the facade's value-import closure holds no `tools/` module, and the scan's own positive controls; `test/backlog1829-engine-below-tools.test.ts` — `fileArgPath` read against the body it replaced (17 shapes, a throwing accessor), the UNC recognizer and the family reading on it, each moved name defined once with no re-export, the admission leaf's closure inside `core/`.
681
+
682
+ ### `verify-debts`: a 待排 window's 依据 must be one the ledger's own checkout can find, an IN-PROGRESS row may not be pending, and the RC gate's cut must be X.Y.Z (#1899; release tooling only; ZERO runtime behaviour; +0 exports; @server @cli @client-core @test)
683
+
684
+ - **Before.** The 依据 of `待排(<依据>;<类别>[;<理由>])` was judged by shape alone — five regex arms (`C-R<n>`, `X-<n>`, `#<n>`, `[NNNN]`, `MM-DD`) — while the comment above them said "must carry an addressable coordinate". So `待排(UTF-8;其余)`, `待排(SHA-256;其余)`, `待排(ISO-8601;其余)`, `待排(GPT-6;其余)`, `待排(99-99;其余)`, `待排(00-00;其余)`, `待排([000];其余)` and `待排(#0;其余)` were valid pending rows: a due row dressed as one passed `gate:debts:rc` untouched (the 7.52.0 audit's 28-form attack found three, the neighbours five more). And an IN-PROGRESS row could be pending — green here, red in the ticket-ledger gate (`pendingWindowProblem`): two answers for one shape.
685
+ - **Now.** A 依据 is `C-R<n>` or `[NNNN]` and nothing else (two arms, same boundaries as before), and EVERY coordinate the cell names must resolve in the checkout the ledger lives in (the directory above `DEBTS_FILE`'s): `audits/CLAY-RULINGS.md` has a table row whose first cell is `C-R<n>`, `posts/<NNNN>-*.md` exists. A number that is not there, or a register / posts directory that cannot be read, is red and named (fail-closed) — each read once, and only when a row's window reads as pending. A date, a ticket id, an `#<n>` and a term are no longer a 依据. A pending window is OPEN-only: an IN-PROGRESS row that writes one is red with the BLOCKED row's sentence form. The look-up sits in the one reader (`readPendingWindow`); no second regex.
686
+ - **The cut must be a version.** `--rc-gate` compared every window to `--version` with no check on its form: `v7.52.0`, `next` turned each comparison into NaN (every versioned row "not due" — a row promised for 7.10.0 passed at a 7.52.0 cut), `7.52` lost its patch, and an empty or missing value fell back to package.json's version without a word. A version that is not three decimal segments (no prefix, no suffix) is now refused up front with exit 2 and `--version must be X.Y.Z, got …`, before the ledger is read; a flag that is present names the version, whatever follows it.
687
+ - **Who is hurt.** A DEBTS row whose 依据 is a date / ticket id / `#<n>` / unregistered number, or an IN-PROGRESS row with a pending window; a release script that passes `--rc-gate --version` anything but `X.Y.Z` (it was passing vacuously; in-repo callers pass `X.Y.Z` or nothing, which reads package.json). None live: DEBTS-core at the 7.52.0 cut has no row whose promise is 待排 (its three OPEN rows carry versions, one BLOCKED row; `gate:debts:rc --version 7.52.0` exits 0 before and after).
688
+ - **Pins.** `test/verify-debts-pending-layer.test.ts` (67 → 138 cells; the 23 old cells that flipped are re-aimed or given a checkout, the other 44 keep their intent): every fixture ledger lives in its own checkout (`debts/` + `audits/CLAY-RULINGS.md` + `posts/`), nothing reads a shared one; H the look-up (ruling present / absent, post present / absent, register or posts unreadable, first-cell-only, lettered sibling id, erratum row, post file-name traps, every-coordinate, exact number, the reader's root); I the audit's 28 forms verbatim plus 14 neighbours, one cell each; F4–F6 the IN-PROGRESS rule; J the version (`v7.52.0` / `7.52` / `next` / empty and six more refused, `7.52.0` the control). Eighteen mutants (`.gate-logs`): each one turns the pins red on named cells.
689
+
690
+ no-notes: #595(car-door 重钉 backlog595 四格〔人批路里「请求在等待中被他人改掉」不再可能〕,是钉随律改,不是修复)
691
+ no-notes: #1787(立票:stations 双读 handler 答形 → 内核「答案是数据」,7.53.0), #1788(立票:side-query 第二次读 role-base,7.53.0), #1789(立票:`resumesCheckpoint` 可选 wire 成员,7.53.0;本版零行为面)
692
+
693
+ no-notes: #1839(待排 7.53.0 —— `McpConnectionPool.keyOf` 公开面收回,本版只在 #1552 节披露第四参;ledger 提交 8b6c8ed58 顺带改了 #1552 的 probe JSDoc 才被计入)
694
+ no-notes: #1958(立票:tool-list digest 的集合语义需随行带格式位或一次性迁移,设计定,7.53.0;本版 digest 保持顺序敏感、不去重 —— 宿主按引擎写出的顺序原样递回即匹配,既有契约,倒序 / 重复递回按宿主自己的 TaskSpec 座位判)
695
+ no-notes: #1949(立票:无规则文法工具的问卡零候选零缺席词 → 裸名规则任何工具 = CC 形,C-R504,7.53.0;本版只在 INTEGRATION-CORE 写过渡句), #1950(立票:续跑 admission 在 claim 前跑配置门,7.53.0 设计)
696
+ no-notes: #1960(立票:委派名登记表 / 天花板读回四边角〔sealed globalThis / 换新 WeakMap / 占位拒范围与 detail.field / 成员读抛 TypeError〕,7.53.0;本版只改一行 JSDoc 与 KL-1947-b 句)
697
+ no-notes: #1843(待排 7.53.0 —— `onCommitted` 早于 stream-engine 本地校验的时序,本版未动;car-1821b 的 WIP 提交只点名了票号), #1864(JSDoc sentence correction in side-query.ts only; the pairing law is told in the #1850 / #1858 / #1859 section)
698
+
1
699
  ## 7.51.2 — 2026-10-14
2
700
 
3
701
  ### A stored session-rules row carries the rule grammar it was written in; a row from before keeps the reading it had (#1761; security; behavior narrowing open to veto; hard-breaking: none; closed sets: +1 member `AllowEntryInertReason` `earlier_grammar`; exports: +1 const, +1 function, +1 type) (@server)
@@ -1241,7 +1939,6 @@
1241
1939
 
1242
1940
  **#1383 car B + #1388 —** hard-breaking: **two** (`ToolPolicy.pathDenyRules` → `ToolPolicy.pathRules(anchors)` with `toolPolicyPathDenyRules` → `toolPolicyPathRules(policies, anchors)`; `adjudicatePersistedPathRules` takes the call's views, a per-row anchor function and the leg's head resolution); closed sets: +1 new (`ChildPlacement`: `honored` / `caller_tree` / `elsewhere`, reached through `ResolvedWorkspace.placement`), `ENGINE_NOTICE_CODES` +1 (`permission.rule_row_dropped`, audience user), `CheckpointErrorReason` +3 (`installation_record_absent` / `installation_record_misaligned` / `installation_record_invalid`, on `resume.parent_constraint_mismatch`); `unsupported.path_base` narrowed (only a declared base that is unusable); exports: +3 / −1 (+`toolPolicyPathRules`, +`BoundPathRule`, +`PathRuleSpec` (types), −`toolPolicyPathDenyRules`); new optional members: `ToolPolicy.pathRules`, `ToolCallRequest.pathRuleAnchors` (engine-internal seat — present only on the copy an installed policy hands its own inner `check`; the request the gate holds, an `AskRequest`, a hook's input and a parked row never carry it), `CallTargetReading.leads`, `InheritedGate.pathRuleAnchors` / `CheckpointState.inheritedGate.pathRuleAnchors` and `InheritedGate.pathRuleInstallations` / `CheckpointState.inheritedGate.pathRuleInstallations` (data half), `ResolvedWorkspace.placement`, `RunInternals.callerRoot` / `worktreeOffered` (core delegation callers only), `CheckpointState.handsHome` (no row format raised), `EffectivePermissionRules.droppedRows`; optional params `pathRuleReachOf(…, treeFamily?)`, `directoryRuleAdmits(…, treeFamily?)`, `scopeCoversCwd(…, identity?)`. **Behavior narrowing:** yes (see the section) — rules are anchored where they are installed (some cells reach more, some reach less), the allow direction admits only when every view is admitted, a project scope root must be a complete absolute path, a worktree child its environment left in the caller's tree is refused, a resume whose re-supplied chain lacks a usable installation record for an entry whose rules depend on its installing leg is refused.
1243
1941
 
1244
-
1245
1942
  **Package exports (the whole release):** +8 / −1 against v7.45.0 (`test/export-surface.snapshot.json` 2617 → 2624): +`BoundPathRule` +`CallTargetReading` +`CallTargetView` +`PathRuleSpec` +`toolPolicyPathRules` (#1383) +`ENGINE_BUILTIN_TOOL_NAMES` +`isEngineBuiltinToolName` (#1422) +`renderListEntry` (#1428); −`toolPolicyPathDenyRules` (#1383 car B, hard-breaking). The program read model stays internal; `judgeShellRead` stays its public reader. Two exported values change content, not shape: `SHELL_WRAPPER_TABLE` (+19 keys; `env` / `strace` / `ltrace` / `nsenter` rows change — #1102 car C) and `BARE_INTERPRETER_NAMES` (+`nocorrect`).
1246
1943
 
1247
1944
  **Closed sets (#1102 car C):** `ArgRoleKind` +1 (`binds-name`: 9 members), `PathEffect` registered (5: `read`, `names`, `write`, `create`, `edit-in-place`), `ShellWrapperName` +19 (the keys of `SHELL_WRAPPER_TABLE`: 42 → 61), `BARE_INTERPRETER_NAMES` +`nocorrect`; the gate's `NOT_RUN` list +7 spellings (`nodejs --inspect-brk` / `--test` / `--watch`, `env -P`, `grep` / `egrep` / `fgrep --exclude-from`), each with its reason; car C2: `NOT_RUN` +4 (`setsid -c` / `--ctty`, `find -fprintf`, `flock --command=`) and the gate's `SINCE` list (+2: `env -a` / `--argv0`, coreutils 9.5), each with its reason.
@@ -1634,7 +2331,6 @@
1634
2331
 
1635
2332
  **#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.
1636
2333
 
1637
-
1638
2334
  ### A workflow park entry names who stands on it — `parkedBy` / `generation` (#1319; additive record members) (@server @client-core @test)
1639
2335
 
1640
2336
  - **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.