@sema-agent/core 7.55.0 → 7.57.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 (502) hide show
  1. package/CHANGELOG.md +1054 -0
  2. package/LICENSE +1 -1
  3. package/dist/agents/cascade.js +1 -1
  4. package/dist/agents/cc-peer-directory.js +1 -0
  5. package/dist/agents/child-model-seat.d.ts +0 -11
  6. package/dist/agents/child-model-seat.js +0 -3
  7. package/dist/agents/list-agents-tool.d.ts +3 -1
  8. package/dist/agents/list-agents-tool.js +7 -7
  9. package/dist/agents/org-delivery-gate.js +3 -4
  10. package/dist/agents/peer-directory.d.ts +22 -4
  11. package/dist/agents/peer-directory.js +24 -2
  12. package/dist/agents/peer-held-queue.d.ts +69 -49
  13. package/dist/agents/peer-held-queue.js +255 -106
  14. package/dist/agents/peer-session-drain.js +65 -45
  15. package/dist/agents/repair-loop.js +2 -2
  16. package/dist/agents/send-message-tool.js +7 -6
  17. package/dist/agents/subagent.d.ts +6 -4
  18. package/dist/agents/subagent.js +72 -68
  19. package/dist/agents/tool-filter.d.ts +7 -5
  20. package/dist/agents/tool-filter.js +12 -9
  21. package/dist/agents/verify.d.ts +3 -2
  22. package/dist/agents/verify.js +3 -3
  23. package/dist/brain/anthropic.js +45 -80
  24. package/dist/brain/media-degrade.d.ts +18 -7
  25. package/dist/brain/media-degrade.js +13 -2
  26. package/dist/brain/model-capability.d.ts +37 -0
  27. package/dist/brain/model-capability.js +34 -0
  28. package/dist/brain/model-presets.d.ts +2 -8
  29. package/dist/brain/model-presets.js +1 -6
  30. package/dist/brain/open-responses.js +23 -34
  31. package/dist/brain/openai.js +25 -96
  32. package/dist/brain/provider-dialect.d.ts +3 -2
  33. package/dist/brain/provider-dialect.js +2 -2
  34. package/dist/brain/reasoning-request.d.ts +96 -0
  35. package/dist/brain/reasoning-request.js +174 -0
  36. package/dist/brain/reasoning.d.ts +11 -11
  37. package/dist/brain/reasoning.js +4 -3
  38. package/dist/brain/request-params.d.ts +13 -4
  39. package/dist/brain/request-params.js +21 -5
  40. package/dist/brain/status-sink.d.ts +20 -0
  41. package/dist/brain/status-sink.js +7 -0
  42. package/dist/brain/stream-engine.d.ts +9 -0
  43. package/dist/brain/stream-engine.js +24 -10
  44. package/dist/brain/stream-shared.d.ts +5 -4
  45. package/dist/brain/stream-shared.js +4 -4
  46. package/dist/core/a2a.d.ts +2 -1
  47. package/dist/core/a2a.js +19 -13
  48. package/dist/core/agent-definition.d.ts +10 -0
  49. package/dist/core/allow-entry.d.ts +23 -23
  50. package/dist/core/allow-entry.js +42 -46
  51. package/dist/core/allow-rule-shadow.d.ts +3 -3
  52. package/dist/core/approval-object.d.ts +21 -5
  53. package/dist/core/approval-object.js +44 -17
  54. package/dist/core/ask-origin.d.ts +66 -20
  55. package/dist/core/ask-origin.js +63 -23
  56. package/dist/core/ask-question.d.ts +51 -8
  57. package/dist/core/ask-question.js +3 -4
  58. package/dist/core/ask-unresolvable-notice.d.ts +8 -3
  59. package/dist/core/ask-unresolvable-notice.js +16 -2
  60. package/dist/core/auto-compaction.d.ts +71 -6
  61. package/dist/core/auto-compaction.js +42 -26
  62. package/dist/core/background-agent-store.d.ts +6 -2
  63. package/dist/core/background-agent-store.js +1 -1
  64. package/dist/core/checkpoint-generation.d.ts +53 -0
  65. package/dist/core/checkpoint-generation.js +35 -0
  66. package/dist/core/checkpoint-queries.d.ts +180 -0
  67. package/dist/core/checkpoint-queries.js +272 -0
  68. package/dist/core/checkpoint-requirements.d.ts +19 -1
  69. package/dist/core/checkpoint-requirements.js +6 -0
  70. package/dist/core/checkpoint-store.d.ts +105 -53
  71. package/dist/core/checkpoint-store.js +43 -20
  72. package/dist/core/cross-boundary-taint.d.ts +6 -5
  73. package/dist/core/cross-boundary-taint.js +5 -5
  74. package/dist/core/decision-receipt.d.ts +37 -3
  75. package/dist/core/decision-receipt.js +17 -17
  76. package/dist/core/delegation-not-started.d.ts +6 -0
  77. package/dist/core/delegation-not-started.js +17 -0
  78. package/dist/core/engine-attestation.d.ts +93 -0
  79. package/dist/core/engine-attestation.js +70 -0
  80. package/dist/core/engine-capabilities.d.ts +4 -1
  81. package/dist/core/engine-capabilities.js +1 -1
  82. package/dist/core/engine-notice.d.ts +32 -12
  83. package/dist/core/engine-notice.js +6 -0
  84. package/dist/core/file-history-store.d.ts +104 -22
  85. package/dist/core/file-history-store.js +37 -19
  86. package/dist/core/gate-fold.d.ts +1 -1
  87. package/dist/core/gate-fold.js +22 -10
  88. package/dist/core/gate-lanes.js +73 -65
  89. package/dist/core/gate-outcome.d.ts +26 -5
  90. package/dist/core/gate-outcome.js +26 -0
  91. package/dist/core/governance-codes.d.ts +5 -1
  92. package/dist/core/governance-codes.js +6 -0
  93. package/dist/core/hooks.d.ts +19 -18
  94. package/dist/core/hooks.js +3 -1
  95. package/dist/core/host-floor.d.ts +128 -0
  96. package/dist/core/host-floor.js +206 -0
  97. package/dist/core/host-target.d.ts +38 -3
  98. package/dist/core/host-target.js +38 -5
  99. package/dist/core/mailbox-store.d.ts +62 -1
  100. package/dist/core/mailbox-store.js +59 -0
  101. package/dist/core/mcp-name-judge.d.ts +46 -0
  102. package/dist/core/mcp-name-judge.js +61 -0
  103. package/dist/core/mcp-probe.d.ts +1 -1
  104. package/dist/core/mcp-probe.js +2 -5
  105. package/dist/core/mcp.d.ts +5 -1
  106. package/dist/core/mcp.js +70 -64
  107. package/dist/core/memory-engine/consolidation-driver.js +4 -3
  108. package/dist/core/memory-engine/delegation-settlement.js +15 -24
  109. package/dist/core/memory-engine/distiller.js +26 -17
  110. package/dist/core/memory-engine/engine.js +4 -4
  111. package/dist/core/memory-engine/layout.d.ts +60 -8
  112. package/dist/core/memory-engine/layout.js +70 -8
  113. package/dist/core/model-seat.d.ts +10 -3
  114. package/dist/core/mods/classic-events.d.ts +67 -0
  115. package/dist/core/mods/classic-events.js +1 -0
  116. package/dist/core/mods/dollar.d.ts +13 -0
  117. package/dist/core/mods/extension-call.d.ts +31 -0
  118. package/dist/core/mods/extension-call.js +43 -0
  119. package/dist/core/mods/kernel/dispatch.d.ts +20 -3
  120. package/dist/core/mods/kernel/dispatch.js +140 -69
  121. package/dist/core/mods/kernel/events.d.ts +47 -11
  122. package/dist/core/mods/kernel/events.js +66 -42
  123. package/dist/core/mods/kernel/index.d.ts +4 -4
  124. package/dist/core/mods/kernel/index.js +2 -2
  125. package/dist/core/mods/kernel/receive.d.ts +39 -5
  126. package/dist/core/mods/kernel/receive.js +290 -17
  127. package/dist/core/mods/kernel/registration.d.ts +33 -2
  128. package/dist/core/mods/kernel/registration.js +6 -7
  129. package/dist/core/mods/kernel/site-rules.d.ts +44 -16
  130. package/dist/core/mods/kernel/site-rules.js +24 -42
  131. package/dist/core/mods/spec-seats.d.ts +69 -0
  132. package/dist/core/mods/spec-seats.js +114 -0
  133. package/dist/core/mods/stations.d.ts +28 -8
  134. package/dist/core/mods/stations.js +29 -13
  135. package/dist/core/mods/tool-call.d.ts +79 -0
  136. package/dist/core/mods/tool-call.js +152 -0
  137. package/dist/core/namespaced-rule-name.d.ts +32 -38
  138. package/dist/core/namespaced-rule-name.js +29 -36
  139. package/dist/core/parent-spec-seat.d.ts +6 -1
  140. package/dist/core/parent-spec-seat.js +2 -0
  141. package/dist/core/park-selfcheck.js +3 -0
  142. package/dist/core/permission-rule-consent.d.ts +24 -3
  143. package/dist/core/permission-rule-consent.js +136 -41
  144. package/dist/core/permission-rule-model.d.ts +57 -5
  145. package/dist/core/permission-rule-model.js +90 -31
  146. package/dist/core/permission-rule-org.d.ts +6 -2
  147. package/dist/core/permission-rule-org.js +8 -4
  148. package/dist/core/permission-rule-provider.d.ts +5 -2
  149. package/dist/core/permission-rule-provider.js +41 -11
  150. package/dist/core/permission-rule-store.d.ts +3 -2
  151. package/dist/core/permission-rule-store.js +11 -2
  152. package/dist/core/permission-rule-sync.js +3 -2
  153. package/dist/core/permission-rule-syntax.d.ts +5 -4
  154. package/dist/core/permission-rule-syntax.js +2 -2
  155. package/dist/core/permission-rules.d.ts +4 -4
  156. package/dist/core/permission-rules.js +11 -20
  157. package/dist/core/persisted-rule-arms.d.ts +2 -2
  158. package/dist/core/persisted-rule-arms.js +10 -12
  159. package/dist/core/principal-name.d.ts +20 -0
  160. package/dist/core/principal-name.js +22 -0
  161. package/dist/core/prompt-section-seat.js +4 -6
  162. package/dist/core/protocol-http-auth.d.ts +40 -5
  163. package/dist/core/protocol-http-auth.js +168 -4
  164. package/dist/core/protocol-naming.d.ts +20 -0
  165. package/dist/core/protocol-naming.js +20 -3
  166. package/dist/core/read-only-shell.d.ts +20 -5
  167. package/dist/core/read-only-shell.js +29 -14
  168. package/dist/core/remote-env.d.ts +37 -1
  169. package/dist/core/remote-env.js +11 -0
  170. package/dist/core/resume-startup-cause.d.ts +24 -2
  171. package/dist/core/resume-startup-cause.js +20 -1
  172. package/dist/core/retired-keys.d.ts +16 -0
  173. package/dist/core/retired-keys.js +16 -4
  174. package/dist/core/rewrite-rejudge.d.ts +60 -0
  175. package/dist/core/rewrite-rejudge.js +36 -0
  176. package/dist/core/roles.d.ts +28 -1
  177. package/dist/core/roles.js +81 -26
  178. package/dist/core/rule-lane-reads.d.ts +20 -15
  179. package/dist/core/rule-lane-reads.js +45 -17
  180. package/dist/core/rule-owner.d.ts +3 -3
  181. package/dist/core/rule-owner.js +3 -2
  182. package/dist/core/runner/announce-once-ledger.d.ts +14 -0
  183. package/dist/core/runner/announce-once-ledger.js +5 -0
  184. package/dist/core/runner/assemble-result.d.ts +1 -0
  185. package/dist/core/runner/assemble-result.js +1 -1
  186. package/dist/core/runner/call-hook-stations.d.ts +3 -2
  187. package/dist/core/runner/caller-roster.d.ts +16 -4
  188. package/dist/core/runner/caller-roster.js +33 -6
  189. package/dist/core/runner/capability-withheld-seat.d.ts +5 -0
  190. package/dist/core/runner/capability-withheld-seat.js +24 -0
  191. package/dist/core/runner/classifier-ladder.d.ts +7 -9
  192. package/dist/core/runner/classifier-ladder.js +3 -3
  193. package/dist/core/runner/compact-session.d.ts +10 -0
  194. package/dist/core/runner/compact-session.js +202 -0
  195. package/dist/core/runner/compaction-call-options.d.ts +2 -2
  196. package/dist/core/runner/compaction-seams.d.ts +21 -1
  197. package/dist/core/runner/compaction-seams.js +11 -8
  198. package/dist/core/runner/construction-doors.js +3 -0
  199. package/dist/core/runner/contracts.d.ts +50 -40
  200. package/dist/core/runner/cross-boundary-lane.d.ts +17 -13
  201. package/dist/core/runner/cross-boundary-lane.js +40 -27
  202. package/dist/core/runner/denial-limit-arms.d.ts +2 -2
  203. package/dist/core/runner/denial-limit-arms.js +1 -1
  204. package/dist/core/runner/derived-route-fallback.d.ts +13 -0
  205. package/dist/core/runner/derived-route-fallback.js +6 -0
  206. package/dist/core/runner/env-capability-census.d.ts +3 -2
  207. package/dist/core/runner/env-capability-census.js +9 -3
  208. package/dist/core/runner/file-history-request.d.ts +18 -0
  209. package/dist/core/runner/file-history-request.js +21 -13
  210. package/dist/core/runner/file-history-restore.d.ts +45 -0
  211. package/dist/core/runner/file-history-restore.js +46 -0
  212. package/dist/core/runner/gate-exit.d.ts +3 -3
  213. package/dist/core/runner/gate-exit.js +4 -3
  214. package/dist/core/runner/image.d.ts +7 -17
  215. package/dist/core/runner/image.js +9 -60
  216. package/dist/core/runner/permission-rule-lanes.d.ts +11 -11
  217. package/dist/core/runner/permission-rule-lanes.js +39 -17
  218. package/dist/core/runner/person-path-rows.d.ts +12 -9
  219. package/dist/core/runner/person-path-rows.js +18 -7
  220. package/dist/core/runner/prepare-acquire-reconcile.d.ts +3 -0
  221. package/dist/core/runner/prepare-acquire-reconcile.js +2 -1
  222. package/dist/core/runner/prepare-ask-lane.d.ts +2 -2
  223. package/dist/core/runner/prepare-ask-lane.js +5 -2
  224. package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -6
  225. package/dist/core/runner/prepare-caps-and-workflow.js +4 -3
  226. package/dist/core/runner/prepare-config-doors.d.ts +35 -38
  227. package/dist/core/runner/prepare-config-doors.js +46 -28
  228. package/dist/core/runner/prepare-defer-classify.d.ts +4 -3
  229. package/dist/core/runner/prepare-defer-classify.js +7 -8
  230. package/dist/core/runner/prepare-file-history.d.ts +48 -8
  231. package/dist/core/runner/prepare-file-history.js +7 -39
  232. package/dist/core/runner/prepare-gate-stations.d.ts +1 -1
  233. package/dist/core/runner/prepare-gate-stations.js +10 -1
  234. package/dist/core/runner/prepare-hands-readface.d.ts +5 -9
  235. package/dist/core/runner/prepare-hands-readface.js +17 -14
  236. package/dist/core/runner/prepare-inherited-gate.d.ts +6 -3
  237. package/dist/core/runner/prepare-inherited-gate.js +5 -3
  238. package/dist/core/runner/prepare-memory-engine-session.js +1 -1
  239. package/dist/core/runner/prepare-park-ask.d.ts +2 -0
  240. package/dist/core/runner/prepare-park-ask.js +7 -7
  241. package/dist/core/runner/prepare-policy-chain.d.ts +6 -3
  242. package/dist/core/runner/prepare-policy-chain.js +67 -76
  243. package/dist/core/runner/prepare-prompt-assembly.d.ts +5 -7
  244. package/dist/core/runner/prepare-prompt-assembly.js +10 -8
  245. package/dist/core/runner/prepare-prompt-inputs.d.ts +11 -9
  246. package/dist/core/runner/prepare-prompt-inputs.js +3 -5
  247. package/dist/core/runner/prepare-protocol-tools.d.ts +2 -0
  248. package/dist/core/runner/prepare-protocol-tools.js +3 -3
  249. package/dist/core/runner/prepare-safety-scan.d.ts +1 -1
  250. package/dist/core/runner/prepare-safety-scan.js +0 -17
  251. package/dist/core/runner/prepare-suspend-saga.d.ts +2 -0
  252. package/dist/core/runner/prepare-suspend-saga.js +5 -6
  253. package/dist/core/runner/prepare-task.d.ts +5 -2
  254. package/dist/core/runner/prepare-task.js +48 -43
  255. package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +20 -4
  256. package/dist/core/runner/prepare-tool-disclosure-mount.js +33 -23
  257. package/dist/core/runner/prepare-turn-wiring.d.ts +0 -4
  258. package/dist/core/runner/prepare-turn-wiring.js +2 -7
  259. package/dist/core/runner/prepare-wiring-manifest.d.ts +6 -2
  260. package/dist/core/runner/prepare-wiring-manifest.js +3 -3
  261. package/dist/core/runner/resume-admission.js +1 -1
  262. package/dist/core/runner/resume-apply.js +11 -12
  263. package/dist/core/runner/resume-checkpoint-screen.js +3 -0
  264. package/dist/core/runner/resume-claim.d.ts +4 -2
  265. package/dist/core/runner/resume-claim.js +15 -8
  266. package/dist/core/runner/retired-runner-deps.js +1 -0
  267. package/dist/core/runner/rewind-files.d.ts +12 -0
  268. package/dist/core/runner/rewind-files.js +90 -0
  269. package/dist/core/runner/run-attachment-seats.js +2 -1
  270. package/dist/core/runner/run-brain-sinks.d.ts +4 -1
  271. package/dist/core/runner/run-brain-sinks.js +4 -2
  272. package/dist/core/runner/run-harness-handlers.js +12 -4
  273. package/dist/core/runner/run-leg.d.ts +1 -1
  274. package/dist/core/runner/run-leg.js +40 -39
  275. package/dist/core/runner/run-notification-lane.d.ts +2 -0
  276. package/dist/core/runner/run-notification-lane.js +2 -2
  277. package/dist/core/runner/run-recovery-lanes.js +3 -4
  278. package/dist/core/runner/run-settle-and-teardown.js +7 -12
  279. package/dist/core/runner/run-terminal-adoption.js +3 -4
  280. package/dist/core/runner/run-turn-boundary.js +4 -15
  281. package/dist/core/runner/runtask.d.ts +10 -0
  282. package/dist/core/runner/runtask.js +35 -42
  283. package/dist/core/runner/send-message-continuation.js +1 -1
  284. package/dist/core/runner/session-read-file-states.d.ts +15 -0
  285. package/dist/core/runner/session-read-file-states.js +28 -0
  286. package/dist/core/runner/session-rule-policy.js +4 -3
  287. package/dist/core/runner/session-verb-doors.d.ts +28 -0
  288. package/dist/core/runner/session-verb-doors.js +48 -0
  289. package/dist/core/runner/session-write-ledger.d.ts +30 -0
  290. package/dist/core/runner/session-write-ledger.js +101 -0
  291. package/dist/core/runner/shell-env-withheld.d.ts +33 -0
  292. package/dist/core/runner/shell-env-withheld.js +51 -0
  293. package/dist/core/runner/skills-seat.js +6 -2
  294. package/dist/core/runner/stream-settle-backstop.js +12 -15
  295. package/dist/core/runner/sub-agent-deny-lists.d.ts +25 -3
  296. package/dist/core/runner/sub-agent-deny-lists.js +25 -17
  297. package/dist/core/runner/synthetic-tools.d.ts +6 -0
  298. package/dist/core/runner/synthetic-tools.js +4 -1
  299. package/dist/core/runner/teardown-bounded.d.ts +1 -0
  300. package/dist/core/runner/teardown-bounded.js +1 -0
  301. package/dist/core/runner/tool-disclosure.d.ts +10 -12
  302. package/dist/core/runner/tool-disclosure.js +1 -3
  303. package/dist/core/runner/tool-end-body.d.ts +1 -1
  304. package/dist/core/runner/tool-end-body.js +10 -9
  305. package/dist/core/runner/tool-not-executed.d.ts +5 -0
  306. package/dist/core/runner/tool-not-executed.js +11 -0
  307. package/dist/core/runner/tool-occurrence.d.ts +8 -0
  308. package/dist/core/runner/tool-occurrence.js +7 -0
  309. package/dist/core/runner/turn-attachments.d.ts +33 -15
  310. package/dist/core/runner/turn-attachments.js +62 -37
  311. package/dist/core/runner-deps.d.ts +7 -7
  312. package/dist/core/safety-axis-vocab.d.ts +6 -4
  313. package/dist/core/safety-axis-vocab.js +6 -5
  314. package/dist/core/safety-merge-corpus.js +11 -3
  315. package/dist/core/secret-env.d.ts +30 -3
  316. package/dist/core/secret-env.js +47 -8
  317. package/dist/core/select-model.js +3 -3
  318. package/dist/core/session-policy-store.d.ts +32 -15
  319. package/dist/core/session-policy-store.js +156 -36
  320. package/dist/core/session-store.d.ts +29 -10
  321. package/dist/core/session-store.js +51 -19
  322. package/dist/core/session.d.ts +82 -16
  323. package/dist/core/session.js +53 -0
  324. package/dist/core/shell-read-silence.d.ts +11 -12
  325. package/dist/core/shell-read-silence.js +9 -5
  326. package/dist/core/shell-run-candidates.js +3 -1
  327. package/dist/core/shell-wrapper-projection.d.ts +1 -1
  328. package/dist/core/shell-wrapper-projection.js +1 -1
  329. package/dist/core/shell-wrapper-table.d.ts +14 -7
  330. package/dist/core/shell-wrapper-table.js +5 -4
  331. package/dist/core/side-query.js +10 -1
  332. package/dist/core/skill-grant.d.ts +7 -2
  333. package/dist/core/skill-grant.js +5 -0
  334. package/dist/core/skills-directory.js +6 -2
  335. package/dist/core/store-contracts/background-agent-store-contract.js +1 -0
  336. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +11 -1
  337. package/dist/core/store-contracts/checkpoint-store-contract.js +141 -2
  338. package/dist/core/store-contracts/file-history-store-contract.js +40 -1
  339. package/dist/core/store-contracts/mailbox-store-contract.js +49 -1
  340. package/dist/core/store-contracts/peer-directory-contract.d.ts +7 -2
  341. package/dist/core/store-contracts/peer-directory-contract.js +14 -2
  342. package/dist/core/store-contracts/session-repo-contract.js +51 -0
  343. package/dist/core/store-contracts/session-store-contract.d.ts +2 -1
  344. package/dist/core/store-contracts/session-store-contract.js +150 -14
  345. package/dist/core/stub-env.d.ts +2 -0
  346. package/dist/core/stub-env.js +1 -0
  347. package/dist/core/synthetic-read-verdict.js +3 -3
  348. package/dist/core/task-event.d.ts +32 -4
  349. package/dist/core/task-notification.d.ts +16 -12
  350. package/dist/core/task-registry-agent.d.ts +8 -4
  351. package/dist/core/task-registry-agent.js +14 -8
  352. package/dist/core/task-registry-shared.d.ts +35 -6
  353. package/dist/core/task-registry-shared.js +11 -0
  354. package/dist/core/task-registry.d.ts +10 -1
  355. package/dist/core/task-registry.js +12 -2
  356. package/dist/core/task-result.d.ts +18 -0
  357. package/dist/core/task-spec.d.ts +18 -13
  358. package/dist/core/tighten-task-spec.js +2 -1
  359. package/dist/core/tool-catalog-entries.d.ts +23 -0
  360. package/dist/core/tool-catalog-entries.js +14 -2
  361. package/dist/core/tool-check-reading.d.ts +11 -0
  362. package/dist/core/tool-check-reading.js +11 -6
  363. package/dist/core/tool-model-gate.d.ts +2 -2
  364. package/dist/core/tool-name-aliases.d.ts +48 -18
  365. package/dist/core/tool-name-aliases.js +36 -19
  366. package/dist/core/tool-name-readings.d.ts +6 -32
  367. package/dist/core/tool-name-readings.js +2 -9
  368. package/dist/core/tool-policy.d.ts +78 -204
  369. package/dist/core/tool-policy.js +152 -204
  370. package/dist/core/tool-result-store.js +3 -0
  371. package/dist/core/tool-roster.d.ts +14 -19
  372. package/dist/core/tool-roster.js +11 -44
  373. package/dist/core/tool-spec-members.js +7 -2
  374. package/dist/core/tool-spec.d.ts +19 -11
  375. package/dist/core/tools.js +49 -9
  376. package/dist/core/trace.d.ts +6 -3
  377. package/dist/core/trace.js +2 -1
  378. package/dist/core/types.d.ts +1 -1
  379. package/dist/core/untrusted-egress.d.ts +20 -0
  380. package/dist/core/untrusted-egress.js +77 -3
  381. package/dist/core/user-attachment.d.ts +8 -1
  382. package/dist/core/user-attachment.js +64 -4
  383. package/dist/core/wiring-manifest.d.ts +9 -0
  384. package/dist/core/wiring-manifest.js +1 -0
  385. package/dist/core/workflow-journal-store.js +1 -1
  386. package/dist/engine/compaction/compaction.js +3 -2
  387. package/dist/engine/execution-env/node-execution-env.d.ts +7 -3
  388. package/dist/engine/execution-env/node-execution-env.js +11 -2
  389. package/dist/engine/harness/agent-harness.d.ts +32 -6
  390. package/dist/engine/harness/agent-harness.js +65 -9
  391. package/dist/engine/harness/types.d.ts +29 -2
  392. package/dist/engine/llm/types.d.ts +28 -11
  393. package/dist/engine/loop/agent-loop.d.ts +23 -4
  394. package/dist/engine/loop/agent-loop.js +42 -30
  395. package/dist/engine/loop/types.d.ts +16 -6
  396. package/dist/engine/session/memory-repo.d.ts +9 -2
  397. package/dist/engine/session/memory-repo.js +11 -1
  398. package/dist/engine/session/repo-utils.d.ts +6 -5
  399. package/dist/engine/session/repo-utils.js +14 -6
  400. package/dist/index.d.ts +34 -17
  401. package/dist/index.js +22 -11
  402. package/dist/internal/harness-types.d.ts +1 -1
  403. package/dist/internal/harness.d.ts +2 -1
  404. package/dist/internal/harness.js +1 -0
  405. package/dist/internal/llm.d.ts +1 -1
  406. package/dist/orchestration/run-workflow-tool.d.ts +6 -4
  407. package/dist/orchestration/run-workflow-tool.js +5 -3
  408. package/dist/orchestration/workflow.d.ts +3 -0
  409. package/dist/orchestration/workflow.js +36 -18
  410. package/dist/primitives/credential-ref.d.ts +89 -0
  411. package/dist/primitives/credential-ref.js +125 -0
  412. package/dist/primitives/index.d.ts +2 -0
  413. package/dist/primitives/index.js +1 -0
  414. package/dist/prompt-assembly/artifact.d.ts +2 -2
  415. package/dist/prompt-assembly/artifact.js +5 -3
  416. package/dist/prompt-assembly/assemble.d.ts +3 -2
  417. package/dist/prompt-assembly/assemble.js +1 -1
  418. package/dist/prompt-assembly/catalog/schema.d.ts +43 -12
  419. package/dist/prompt-assembly/catalog/schema.js +34 -2
  420. package/dist/prompt-assembly/catalog/sections.js +0 -1
  421. package/dist/prompt-assembly/composer.d.ts +3 -1
  422. package/dist/prompt-assembly/composer.js +1 -1
  423. package/dist/prompt-assembly/event-registry.js +1 -0
  424. package/dist/prompt-assembly/overlay.js +3 -3
  425. package/dist/prompt-assembly/renderers.d.ts +1 -1
  426. package/dist/prompt-assembly/renderers.js +0 -3
  427. package/dist/prompt-assembly/role-base.d.ts +15 -0
  428. package/dist/prompt-assembly/role-base.js +9 -0
  429. package/dist/prompt-assembly/select.d.ts +39 -14
  430. package/dist/prompt-assembly/select.js +47 -16
  431. package/dist/prompt-assembly/sugar.d.ts +17 -11
  432. package/dist/prompt-assembly/sugar.js +8 -3
  433. package/dist/prompt-assembly/tool-catalog.d.ts +37 -3
  434. package/dist/prompt-assembly/tool-catalog.js +114 -4
  435. package/dist/prompt-assembly/turn-snapshot.d.ts +4 -10
  436. package/dist/prompt-assembly/turn-snapshot.js +0 -3
  437. package/dist/prompt-assembly/types.d.ts +0 -7
  438. package/dist/scenarios/full-body.d.ts +11 -11
  439. package/dist/scenarios/full-body.js +4 -3
  440. package/dist/shell/lex.js +10 -1
  441. package/dist/stores/file/adoption/adopt.js +4 -3
  442. package/dist/stores/file/adoption/marker.js +3 -2
  443. package/dist/stores/file/checkpoint-store.d.ts +21 -4
  444. package/dist/stores/file/checkpoint-store.js +38 -7
  445. package/dist/stores/file/file-history-store.js +18 -11
  446. package/dist/stores/file/index.js +2 -1
  447. package/dist/stores/file/mailbox-store.d.ts +2 -1
  448. package/dist/stores/file/mailbox-store.js +26 -1
  449. package/dist/stores/file/permission-rule-adopt.js +4 -3
  450. package/dist/stores/file/permission-rule-store.js +11 -5
  451. package/dist/stores/file/session-store.d.ts +29 -6
  452. package/dist/stores/file/session-store.js +42 -50
  453. package/dist/tools/artifact/artifact-tool.js +3 -2
  454. package/dist/tools/fs/bash-program-position.d.ts +19 -2
  455. package/dist/tools/fs/bash-program-position.js +56 -9
  456. package/dist/tools/fs/bash-readonly-classifier.d.ts +15 -19
  457. package/dist/tools/fs/bash-readonly-classifier.js +5 -10
  458. package/dist/tools/fs/fs-bash.js +15 -8
  459. package/dist/tools/fs/fs-glob.d.ts +86 -0
  460. package/dist/tools/fs/fs-glob.js +226 -0
  461. package/dist/tools/fs/fs-read.js +14 -14
  462. package/dist/tools/fs/fs-search-tools.js +7 -7
  463. package/dist/tools/fs/pattern-kill-guard.d.ts +23 -0
  464. package/dist/tools/fs/pattern-kill-guard.js +429 -0
  465. package/dist/tools/fs/pdf.d.ts +9 -6
  466. package/dist/tools/fs/pdf.js +3 -3
  467. package/dist/tools/fs/program-read-model.d.ts +57 -7
  468. package/dist/tools/fs/program-read-model.js +11 -1
  469. package/dist/tools/fs/program-read-model.rows.js +78 -21
  470. package/dist/tools/fs/read-deny-home.js +3 -2
  471. package/dist/tools/fs/removal-targets.d.ts +17 -7
  472. package/dist/tools/fs/removal-targets.js +47 -20
  473. package/dist/tools/fs/search.d.ts +10 -0
  474. package/dist/tools/fs/search.js +20 -26
  475. package/dist/tools/fs/shell-read-argv.js +89 -12
  476. package/dist/tools/fs/shell-read-bindings.d.ts +31 -0
  477. package/dist/tools/fs/shell-read-bindings.js +41 -0
  478. package/dist/tools/fs/shell-read-check.d.ts +10 -0
  479. package/dist/tools/fs/shell-read-check.js +13 -20
  480. package/dist/tools/fs/shell-read-environment.d.ts +5 -1
  481. package/dist/tools/fs/shell-read-environment.js +3 -2
  482. package/dist/tools/fs/shell-read-establish.d.ts +15 -0
  483. package/dist/tools/fs/shell-read-establish.js +19 -20
  484. package/dist/tools/fs/shell-read-identity.d.ts +12 -9
  485. package/dist/tools/fs/shell-read-identity.js +9 -4
  486. package/dist/tools/fs/shell-read-plan.js +24 -16
  487. package/dist/tools/fs/shell-read-start.d.ts +6 -1
  488. package/dist/tools/fs/shell-read-write-targets.d.ts +5 -1
  489. package/dist/tools/fs/shell-read-write-targets.js +2 -3
  490. package/dist/tools/fs/shell-reentry-text.d.ts +9 -0
  491. package/dist/tools/fs/shell-reentry-text.js +40 -2
  492. package/dist/tools/todo.js +63 -63
  493. package/dist/tools/web.d.ts +19 -14
  494. package/dist/tools/web.js +46 -61
  495. package/package.json +1 -1
  496. package/test/export-surface.snapshot.json +221 -7
  497. package/wire-changes/core-stores-sql@7.57.0.json +29 -0
  498. package/wire-changes/core@7.56.0.json +3891 -0
  499. package/wire-changes/core@7.57.0.json +1487 -0
  500. package/wire-changes/mods-kernel@7.56.0.json +303 -0
  501. package/wire-changes/mods-kernel@7.57.0.json +29 -0
  502. package/wire-changes/primitives@7.57.0.json +245 -0
package/CHANGELOG.md CHANGED
@@ -1,6 +1,1060 @@
1
+ ## 7.57.0 — 2026-10-25
2
+
3
+ ### A Model object the engine cannot read is refused by name at every seat that takes a model — `Runner.runTask` and `Runner.compactSession` read the one model-binding judgment (#2769; behavior narrowing, open to veto; hard-breaking: none; closed sets: none — the refusal is the existing `config.unknown_role` / `not_a_model_ref`; exports: none) (@server @cli)
4
+
5
+ - **Before.** The judgment of a model binding accepted any object as a Model. `{ id: "x" }` handed to `runTask` ended terminal `failed` with the bare `TypeError: Cannot read properties of undefined (reading 'startsWith')` (the prompt-assembly turn snapshot reads `api`); a Model with no `id` ran to `completed` unnamed; `compactSession` answered `compacted` on the same object and handed it to the summary call. A catalog entry a word resolved to, the `compactionModel` seat and a role seat were read the same way.
6
+ - **Now.** An object bound at a model seat reads as a Model when it carries `id` (a non-empty string) and `api` (a string) — the two members the engine has no designed reading for when absent. Otherwise the seat is refused by the existing code (`config.unknown_role`, `detail { field: <seat>, reason: "not_a_model_ref" }`), the sentence naming the member (``TaskSpec.model is a Model object whose `api` is absent``), before any model call and before the session's lock is taken. The same judgment reads the catalog entry a word resolves to, the entry a role's `select` criteria pick, and the catalog entry that shadows a tier word and is read as the tier's default. One reader — `assertModelObject` behind `modelOfBinding` / `assertModelBindingShape` in `roles.ts` — serves `TaskSpec.model`, `compactSession`'s `model` and `compactionModel`, role seats, tier bindings and every other `ModelRef` seat; no second judgment and no new code or reason was added. At a role seat an object is a config record only when every own key is one of `model` / `select` / `thinking` / `promptRole` / `systemPrompt` and neither `id` nor `api` can be read from it; every other object is read as a Model and judged, so a Model missing one or both identity members (or a record with a stray key) is refused instead of being read as a record that leaves the role on the default unannounced (a list or a function written there still reads as an empty record, as before). Every catalog entry a role's `select` criteria will read is judged before the selection reads any member of it. Reading is total: an accessor that throws, or a revoked Proxy, as the Model or as a member, reads as absent / unreadable and the refusal still names the member.
7
+ - **Not judged (stated).** The window, the endpoint and the output cap: a window the engine cannot evaluate is a disclosed state (`unevaluableWindow`), and an absent endpoint or output cap falls to the brain's own. A Model without them is accepted at both doors, as before.
8
+ - **Behavior narrowing (open to veto).** A Model object (or catalog entry) with no `id`, an empty `id`, or no string `api` — which ran, or crashed in the turn snapshot — is refused by name, and a partial Model written at a role seat (`roles.x = { id: "…" }`, `{ contextWindow: 1000 }`) or a role record with a key outside the five above no longer leaves that role on the default silently; a role's `select` over a catalog that holds a broken entry refuses by name (the entry's key in the sentence) instead of choosing around it. @server: a catalog entry you build must carry `id` and `api` (the type has always required both); an entry without them now refuses at first use with the sentence above instead of failing deep in a run (inferred: no server-side catalog is known to omit them). @cli: nothing to render beyond the existing `config.unknown_role` terminal.
9
+ - **Pins.** `test/backlog2769-model-door.test.ts` — ten bad shapes (no / empty / numeric / null / revoked-Proxy `id`, no / numeric / throwing `api`, a revoked-Proxy Model) × the two doors (refusal by name, no model call, nothing written, the lock free after), the catalog / `compactionModel` / role-seat / `select` / tier-shadow / tier-binding / bare-role-seat arms, and the controls (a whole Model, a Model without window / endpoint / output cap, a real role record, the earlier refusals keeping their words).
10
+
11
+ ### `@sema-agent/core-stores-sql` is published with this release — cut with core at the same version; its public face is fixed, and this first release carries no SQL stores (#2673; no behaviour change; hard-breaking: none; closed sets: none; exports: none in the root — the package's own entry is its fourteen names, new on the registry; a new public package name and a licence-text line, open to veto) (@server @test @cli)
12
+
13
+ - **Before.** `packages/core-stores-sql` was cut in 7.56.0 as a package that never reached the registry (`private: true`): its public face — neutral factories over a caller's pool and the neutral types they speak — and the way it is built, checked and released were fixed, but nothing could install it, so a deployment that owns SQL stores had nothing to code against.
14
+ - **Now.** The manifest says `private: false` with `publishConfig.access: "public"`, and the release publishes it with the root — the satellite packages go out after the root, in directory-name order — from the same plain clone of the tag, at the root's version, with that version as its exact `@sema-agent/core` peer. The public face is the fourteen names it already had (`createSqlStorageBackend`, `createSqlDriver`, `baselineDdl`, `publishedColumns` and ten types); nothing is added or removed, and no store class, no dialect class, no SQL statement list, no ensure leg and no SQL predicate generator is exported. The tarball holds `package.json`, `README.md`, `LICENSE` and `dist/` — pinned by packing the package directory. The README is rewritten for a stranger: first, that this release carries no SQL stores (the registry's one-line description says it too); then install, the version pairing (each factory refuses a core it was not cut with), the fourteen names and what is not exported. The package's build now runs `tsc` twice, as the root's does (the second pass writes the declarations again with their comments), so the JSDoc on each exported name — including the sentence that a factory refuses — ships in the `.d.ts`. The root `LICENSE`'s Licensed Work parameters now name the package beside `@sema-agent/mods-kernel`, and `packages/core-stores-sql/LICENSE` and `packages/mods-kernel/LICENSE` are byte copies of it (public text, open to veto).
15
+ - **Release machinery.** No release script changes: each leg reads the package as it reads any non-private one: `scripts/bump-version.mjs` already moves its version cell and its exact core peer; `gate:packages` and `gate:packages:pack` already read its tsconfig, its frozen snapshot (`packages/core-stores-sql/test/export-surface.snapshot.json`, 14 names) and its smoke table (4 runtime exports), pack it and install it beside core's own tarball; `scripts/publish-release.sh` takes every non-private workspace package; the release gate and the post lint read the claim from the tag's tree, so a tag cut from here owes the registry the package and the release owes `wire-changes/core-stores-sql@<version>.json` beside the root's. That manifest is based on the previous tag like every manifest (`gate:wire -- <previous tag> <release candidate> --package core-stores-sql --version <version> --to-tag v<version>`); the package already had these fourteen names there, so the manifest reports no export moved — that the package is new on the registry is a fact of the release post, not of the manifest.
16
+ - **Not here yet (named).** The stores. `createSqlStorageBackend` and `createSqlDriver` read their options (every wrong value refused by name) and then refuse; `baselineDdl(dialect)` resolves to an empty list and `publishedColumns()` returns one. The stores move in with a later release, from the deployment that owns them; until then the package is a published face to code against, not a backend to run. A removed or re-kinded name will be named as breaking in the notes of the release that does it.
17
+ - **Consumers.** No consumer is asked to change. @server: the package can be installed from the registry from this release (`npm install @sema-agent/core @sema-agent/core-stores-sql`, same version as core); code against the fourteen names and the neutral pool shapes (`PgPoolLike`, `MysqlPoolLike`), not against a store class; do not call a factory expecting a backend yet. @test: the package is one more thing a fresh install of this release must find on the registry (`scripts/verify-fresh-install.mjs` installs every non-private package and runs its smoke table). @cli: no action. The package name is public and permanent once published (open to veto).
18
+ - **Pins.** `test/backlog2673-core-stores-sql-publish.test.ts` — the manifest says published (`private` false, `publishConfig.access` public, the package gate's reading clean, `files` exactly `["dist"]`, the core peer exact, the build puts the declarations' comments back, the release gate's tree reading counts the package as a promise of the root's version and the manifest leg run over the committed tree owes this package's wire manifest); the licence line in the Licensed Work parameters and the byte copy; the README (install line, the version pairing, no store class sentence, the "this release carries no SQL stores" sentence tied to both factories' own refusals and to the registry description so they go red together when the stores arrive, no status of an unpublished package, an Exports table that is the frozen surface name for name); what `npm pack` lists over the package directory; the snapshot and the smoke table as the public face (a runtime export is judged by the gate's own kind set). Re-pinned: `backlog1468-n1-core-stores-sql` (the manifest cell says published).
19
+
20
+ ### A new package below the engine, `@sema-agent/primitives`, holds the credential reference — one closed set of kinds (`env` / `vault` / `handle` / `assertion`) and one reader, `readCredentialRef`, that every layer imports (#2293, slice S4 of the MCP credential seat; no behaviour change, hard-breaking: none; closed sets: none; exports: +6 root values (`readCredentialRef`, `CREDENTIAL_REF_KINDS`, `CREDENTIAL_REF_TRUST`, `CREDENTIAL_REF_TEXT_MAX`, `CREDENTIAL_REF_MAP_MAX`, `CREDENTIAL_REF_PROBLEM_REASONS`) and +6 root types, none removed; a new published package cut with core, public name open to veto)
21
+
22
+ - **Before.** The layer below the engine (the one every other layer may import) had no package: a shared definition lived in whichever layer wrote it first, and the next layer copied it. A credential reference — how a declaration names a credential without carrying its value — had no definition anywhere, so the surfaces that will have to validate one (the engine's declaration door, a deployment's acceptance gate, a settings validator, an admin form) had nothing to agree on.
23
+ - **Now.** `packages/primitives/` is a satellite package cut with core at the same version, compiled from `src/primitives/` (the folder the engine reads directly, as the mods kernel is), with no dependencies — not a builtin, not core — so it runs in Node and in a browser. It is the third published package (`@sema-agent/mods-kernel` and this one; `core-stores-sql`, private in 7.56.0, is published with this release as well — its own section above). Its first member is the credential reference:
24
+ - `CredentialRef` is a closed set of four kinds: `env` (header name → variable name for an http peer, child variable → variable name for a stdio peer; at least one), `vault` (`entry`), `handle` (`id`) and `assertion` (`audience`). `CREDENTIAL_REF_KINDS` is the set, frozen.
25
+ - `readCredentialRef(value)` is the one reader. It returns `{ ok: true, ref }` — a fresh frozen copy that the caller's later writes cannot reach; forward the copy, never the value read — or `{ ok: false, problem }` whose `reason` is one of `CREDENTIAL_REF_PROBLEM_REASONS`: `not_a_record`, `unknown_kind`, `missing_field`, `extra_field` or `bad_value`. The first fault in that order is reported, and a refusal is a value, never a throw: a value whose members cannot be read (a revoked or throwing Proxy) is `not_a_record`, or the `bad_value` of the map that holds it; the reader's catch clauses make no call, so a call made at the edge of the stack cannot throw out of the handler (a pin reads the source for it). With no stack left a valid value may be refused, under any reason, and an invalid one is never accepted; the call itself may throw a `RangeError` before it is entered, which no function can catch.
26
+ - What the reader reads. An ordinary record is an object (not an array) whose prototype is none or itself has none — the `Object.prototype` of any realm is one, so a record from another realm or from `JSON.parse` in one is read; a member is an own enumerable string-keyed data property, read off its descriptor with own-property tests, so a getter is refused and never called, nothing is read through a prototype (a polluted `Object.prototype` cannot turn a getter into a value), and an own `__proto__` key is a plain key. A key present with the value `undefined` is a bad value, not an absent member.
27
+ - What `bad_value` means. A text member (`entry`, `id`, `audience`) is 1 to `CREDENTIAL_REF_TEXT_MAX` (2048) UTF-16 code units, with no character that is not visible text — control (C0 and C1), format (zero-width, bidi override, soft hyphen, BOM), line and paragraph separator, default-ignorable (Hangul filler, combining grapheme joiner, variation selectors), noncharacter, private-use — no lone surrogate, and no leading or trailing white space; it is compared as written, nothing is normalised (two spellings of one visible string are two entries; visually confusable text — homoglyphs, a braille blank, stacked combining marks — is not judged; an emoji presentation selector and a zero-width joiner are refused, because these members are identifiers, not prose), and the bound counts UTF-16 units, up to 6144 UTF-8 bytes. The character classes are the host's Unicode tables: two surfaces on engines with different Unicode versions can differ for a character added recently. A name map (`headers`, `env`) holds 1 to `CREDENTIAL_REF_MAP_MAX` (64) entries; a header name is an HTTP token, a child variable and every variable name an identifier (`[A-Za-z_][A-Za-z0-9_]*`); two header names that differ only by case are one name named twice and are refused (variable names are not folded). The maps come back frozen and without a prototype, so an absent name reads `undefined`. A reference's size is bounded before it is copied, so a record of a million members costs the reader no more than listing its keys.
28
+ - `CREDENTIAL_REF_TRUST` says, per kind, what a request surface may carry: `vault` only for an entry that belongs to the submitting principal (`own_entry`); `env`, `handle` and `assertion` not at all (`refused` — an environment name would read any variable of the resolver's process, a handle names someone else's stored credential, an assertion is minted by the deployment). The table has a row for each kind and for nothing else (no prototype); look a kind up in it only after the reader has accepted the value. It is the shape an acceptance gate reads; it authenticates nothing.
29
+ - The reader judges shape and nothing else: it resolves no name, reads no environment and holds no state, and it carries no policy of any layer — which header names a deployment forbids (the protocol's own names, the principal header), which child variables a declaration may set, who answers a reference and whether this declaration may reach this destination stay with the engine's door and the deployment's seat.
30
+ - The root re-exports the same names from its own compiled copy of `src/primitives/` (`src/index.ts`): the root does not depend on `@sema-agent/primitives`, so a consumer that installs both packages holds two module instances compiled from the same source — plain-object values, no `instanceof`, no module identity (as with the mods kernel). A single instance is a design ticket (#2749).
31
+ - **Release machinery.** Nothing new is needed and each leg was run over the third package: `scripts/bump-version.mjs` moves its version cell (it has no core peer); `gate:packages` and `gate:packages:pack` read its tsconfig, snapshot (`packages/primitives/test/export-surface.snapshot.json`, 12 names) and smoke table (6 runtime exports) and pack it (built into its own `dist/`, installed offline, smoked); the release publishes it after the root and `mods-kernel` (`scripts/publish-release.sh` takes every non-private workspace package) and, from this release, owes `wire-changes/primitives@<version>.json` (`gate:wire -- <from> <to> --package primitives --version <version> --to-tag v<version>`; a package new in the range reports every export as an addition, which is the first cut). The root `LICENSE`'s Licensed Work parameters now name `@sema-agent/primitives` beside `@sema-agent/mods-kernel`, and the three satellite packages (`mods-kernel`, `primitives`, `core-stores-sql`) each carry a byte copy of it (public text, open to veto).
32
+ - **Not here yet (named).** Nothing in the engine reads a credential reference today: `McpServerSpec` still carries `headers` / `env` values and gains no `credentials` member in this change. The declaration door, the deployment seat that answers a reference, the retirement of the value members and the unauthorized state are the next slices of the same design (#2294 – #2296); the new package is their shared floor and ships first so the three consuming repositories can read it before the door exists.
33
+ - **Consumers.** No consumer is asked to change. @settings-schema / @web-admin / @server: the reference's validator you will write reads `readCredentialRef` from `@sema-agent/primitives` rather than a copy, and the acceptance gate reads `CREDENTIAL_REF_TRUST`; the package is published with this release and carries the release's version, and its name is now public and permanent (open to veto). @connectors: the shape of the reference is fixed, for your comparison table.
34
+ - **Pins.** `test/backlog2293-credential-ref.test.ts` — a positive control per kind and the frozen-copy law (and that a copy reads again as itself); the three named exits and the garbage exits each as their own reason, in one fixed order (unknown kind in fifteen spellings, missing, extra including an own `__proto__` key and a member in another case, not-a-record in fifteen shapes (two of them arrays wearing a record's prototype), a record of another realm accepted and an array / class instance / date of another realm refused, `kind` not text); no throw from a revoked Proxy, a Proxy whose `getPrototypeOf` / `ownKeys` / `getOwnPropertyDescriptor` trap throws; a getter never called at any depth, including under a polluted `Object.prototype`; the source's catch clauses holding no call; text members at, over and under the bound, control / format / separator / default-ignorable / noncharacter / private-use characters, lone surrogates, blank and padded text; name grammars, an empty map, a map over the bound, header names that differ only by case; a record of 50 000 members and a map of 50 000 entries each refused at once; the maps without a prototype, immune to pollution; echoed text bounded, fenced, never ill-formed; a map that cannot be read is that map's bad value; the closed sets and the trust table frozen and in step with the kinds, the table answering for its kinds only; the folder a leaf that compiles on its own with browser-and-node globals only; the root re-exporting the same values; the package gate (static arm and pack arm) over a copy of the real tree, and red for a bare import and for an export the frozen surface does not hold. Re-pinned for a third published package: `backlog1751-package-release-base` (the owed package manifests, the published packages at HEAD, the bump plan) and `backlog2104-mods-kernel-publish` (the licence copy).
35
+
36
+ ### `Runner.compactSession({ sessionId, model, … })` compacts a session no run holds — the one compaction pass, under the session's lock, `trigger: "manual"`; a session a leg of this Runner holds is refused `compact.session_busy` (#2451, design/2451; behavior widening — a new verb; open to veto: the session station's `$` facade, and a pass's credential wait now ends at the pass's signal on every lane) (@server @cli @client-core)
37
+
38
+ - **Before.** The only manual compaction was `TaskStream.compact()`, which parks a request for the next turn boundary of a live run. A person typing `/compact` while their session was idle had no engine verb: hosts armed the request and fired it after the next message's run started, and when that run was shorter than the host's delay nothing was compacted (`compact.not_running`).
39
+ - **Now.** `runner.compactSession(request)` runs the pass every lane runs (`maybeCompact`) on an idle session: no run, no user turn, `force` with no floor, `trigger: "manual"` — the reference's `/compact` on an idle session (a local command that runs no main-loop turn; its plugin API refuses while a turn runs). The request is `{ sessionId, model, instructions?, compaction?, compactionModel?, getApiKeyAndHeaders?, signal? }`: `model` (required) is the model the session's next turn runs on and bounds the pass; `instructions` replace the standing ones for this pass as `TaskStream.compact` does; `compaction` takes the five members a pass reads (`enabled`, `reserveTokens`, `keepRecentTokens`, `instructions`, `clampTolerance`); `compactionModel` / the `summarize` role, its route pre-flight (a role-derived model whose key↔URL pairing is broken falls back to the primary, `route.fallback_to_primary`), the settings, the provider seam, the PreCompact / PostCompact stations and the `compacted` mint are the readers a run's lanes use. The answer is `{ outcome, events, usage? }`: `outcome` is `TaskStream.compact`'s closed set; `events` are the pass's frames in order (`compaction_started`, then `compacted` or `compaction_outcome`, each `trigger: "manual"`, each with its own `eventId`, no task attribution) — returned, not streamed; `usage` books the pass's model calls as a run's side calls are booked. A summarizer failure is the outcome `"failed"` (`onError` phase `compaction`), never a rejection. The ToolSearch activation snapshot on the branch is restated on the new record. A later run reads the summary — on a session no checkpoint is parked on: a session durably parked at a checkpoint has released its lock, so the verb compacts it, and the resume rewinds the branch to the checkpoint's leaf, off which the new compaction entry falls (the answer is still `compacted`; KL-2451-a, #2701).
40
+ - **Refusals (thrown, coded, before any model call).** `compact.invalid_request` (not a plain data object read as one snapshot — a Proxy, an inherited or accessor member, an unknown key, a wrong type, `compaction` the same; `model` missing); the model door's `config.unknown_role`; **`compact.session_busy`** — a leg of THIS Runner is running or queued on the session, or a write one of this Runner's legs issued to it has not settled (the same one reading of "busy" `Runner.rewindFiles` refuses on; otherwise the pass holds the session's lock to the end, so a leg arriving meanwhile waits and reads the summary); `resume.session_not_found`. A `signal` already aborted answers `{ outcome: "mooted", events: [] }` with nothing taken.
41
+ - **Open to veto — the session station's `$`.** The deployment seat's (`RunnerDeps.mods`) PreCompact / PostCompact handlers run on the pass (a block blocks it). There is no leg, so the station carries no hook identity and its facade answers the seats a session has — `$.session.id()` / `messages()` / `model()`, `$.clock.now()`, `$.model.complete()` (booked into `usage`); `$.process.run`, `$.session.cwd()` / `root()`, `$.tool.list()`, `$.agent.list()` and `$.tool.call` throw naming the station. A handler that needs one fails, and the station's one failure rule applies (a failed handler neither blocks nor adds instructions) — the rule every manual pass has; whether a PreCompact failure should block a manual pass is a separate decision for both lanes.
42
+ - **Open to veto — every lane.** A pass's credential callback (`getApiKeyAndHeaders` for the summary model) is now awaited against the pass's own signal, as the summary provider's consult already was: a pass cut while the callback is answering ends with the cut (`compaction_outcome{mooted}` / the abort outcome) instead of waiting on a callback that may never settle. A pass that already appended is not undone.
43
+ - **Not on this verb (stated).** No post-compaction file restore and no instruction-file content (there is no execution environment out of a run), no fork-form summary (no main request), and ToolSearch activations made after the newest snapshot inside the summarized range are not restated. The busy check sees this Runner's legs and the writes they issued only: a leg of another Runner on a shared durable session is not refused, and the compaction's commit checks the leaf at append time, not the branch it summarized (an existing property of the pass on every lane).
44
+ - **Shared readers (no behavior change).** `Runner.rewindFiles` reads the same out-of-run doors (request snapshot, idle claim, the session that must exist; its refusal texts unchanged); the turn boundary reads `manualCompactOutcomeOf`; prepare reads `resolveCompactionModel` / `compactionSummaryIsDerived` / `compactionSummaryFallback` (the pre-flight is awaited only for a role-derived summary model, as before — a run with none takes no extra suspension point); the lanes read `summaryProviderOptions`; the leg seed reads `compactionActivationSnapshot`.
45
+ - **Consumers.** @server: `POST /v1/sessions/:id/compact` → `runner.compactSession({ sessionId, model, instructions?, compactionModel?, compaction?, getApiKeyAndHeaders, signal })` with the per-model key resolver and the body→spec mapping runs use, and a request-scoped `signal`; refuse 409 first when the run store shows a running run on the session on any replica and hold the session against new runs while the call is in flight; map `compact.session_busy` → 409, `compact.invalid_request` / `config.unknown_role` → 400, `resume.session_not_found` → 404; relay `events` and answer `outcome` / `usage`. @cli @client-core: idle `/compact` calls the session endpoint at once; the arm-then-fire delay and the `COMPACT_NOT_RUNNING` warning retire for the idle case; a live run keeps the run-scoped verb.
46
+ - **Exports.** +2 types `CompactSessionRequest` / `CompactSessionResult`; `Runner` gains `compactSession`. Closed sets: +2 refusal codes from the new verb only (`compact.invalid_request`, `compact.session_busy`).
47
+ - **Pins.** `test/backlog2451-compact-session.test.ts` (real Runner: landing + frames + next run reads the summary; held session refused; a leg arriving mid-pass waits; the doors; withdrawn request; deployment block; the facade's seats; an unavailable seat; summarizer failure; activation snapshot restated; a credential callback that never settles; the summary model's route fallback; `disabled` / `noop`).
48
+
49
+ ### The rulings-landing gate reads every ledger the way it is written — one row splitter, a `path:line` + snippet is a claim judged on the file's content, a window's note is history it never reads, a disposition addresses ONE row, one comms root per run — `scripts/rulings-disposition.mjs` drafts the post's 裁定处置 lines, and the backlog gate counts only work as a car coordinate (#2113, #2260; release tooling only; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none) (@test @cli @server @client-core)
50
+
51
+ - **Before.** Six readings of `scripts/rulings-gate.mjs` disagreed with the books it judges. (1) `BLOCKED(on #N)` took the ticket's state from the FIFTH cell of its backlog row: 188 of 2098 ticket rows are wider than six cells (a bare `|` in a head cell), so for them the "state" was whichever cell stood fifth, and `SHIPPED-…` was never "landed". (2) An `已落` row passed when any ONE of its tokens resolved, so a row that cited a commit and a symbol the code no longer holds read as landed (on the comms ledger of 10-24: 12 `path:line` + snippet claims in 10 rows name text that is not in the file at all — `DATE_ANCHOR_CUSTOM_TYPE` moved to another file, `ENGINE_ATTESTATIONS` likewise, `judgeRead(read, boundary)` changed shape — and 128 more name a line the snippet is no longer on). (3) A ruling that owns several rows in one repo shares one key, so one line in the post disposed of all its due rows. (4) With `--ledger` named, `BLOCKED(on S-563)` was still looked up in whichever comms checkout sat beside the tree; a `#N` / `core@x` blocker in another repo's row could never resolve. (5) A window cell with a note — `7.57.0(10-24 R 对账改窗:7.56.0 未并 / 未派;原 7.56.0)` — was an unreadable window: the 33 rows re-windowed that way were neither format-clean nor due, so leg (a) never asked the post for a line about them. (6) The backlog gate's "car coordinate" was any `car-…` string anywhere in the status cell, so a car that finished, a plan that never ran and an OPEN row naming a planned car all kept a twice-re-windowed row out of the "moving without work" list (22 of the 53 such rows on the stage-7570 ledger).
52
+ - **Now.** (1) A ticket's state is read through the backlog gate's own row reader (`ticketStatusOf`: the second cell from the right) and the rulings gate splits its tables through the backlog gate's `cellsOf` — no second parser; a status that opens with no vocabulary word, and a DEBTS row off its seven columns, are unknown, never guessed. `SHIPPED` and `TEST-VERIFIED` count as landed. (2) A `path:line` (or `path:from-to`, or a bare `:line` after a file was named) immediately followed by a backticked snippet is a claim; when the file is in the tree and the snippet is not in it, leg (b) is red even if another token of the row resolves (a post's 兑现 line is judged the same way). A snippet in the file but not at the stated line is held and counted on one line; `--verbose` lists ruling, old coordinate and lines now. The snippet is judged literally. (3) A disposition names its row: `裁定 C-R97/core.2 改期: …` is the second `C-R97 | core` row; a bare key line is enough only while one row of the key is due, and the finding spells the selector to write. (4) `--comms <dir>` names the one comms root of a run; a named ledger brings the root that holds it, never a neighbour's debts; `--core-root <dir>` (else a sibling `sema-core`) answers `#N` / `core@x` for another repo's rows. (5) A window is `<window> [BLOCKED(on X)] [(note)]`; the note is never read, and a row is due when its first window is at or before the shipping version, whatever the note says. (6) A car coordinate is a commit sha, or a `car-…` name on an IN-PROGRESS row, in the status HEAD (the `原 …` tail is history). New `scripts/rulings-disposition.mjs` writes the post's 裁定处置 section — one 改期 line per due row, addressed by the gate's own selector, each holding `TODO(` until a person writes the reason; it never claims 兑现 or 被取代. (7) `gate:rulings` leaves EXCLUDED for the rc tier (`gate-all --rc`) and the ship lint's rulings leg judges the post: until now the one gate that reads the landing ledger ran in no mandatory run, which is how the 12 rows of 7.54.0 and the 33 re-windowed rows of 7.56.0 went unchecked (open to veto; this is the separately revertable last commit of the car).
53
+ - **Who is hurt.** Nobody at runtime: `src/` is untouched and nothing in the package changes. The release candidate is red until the comms ledger covers the 164 rulings that have no row and the post carries a line per due row (`gate:rulings` is in `gate-all --rc` now; in a container without a comms checkout it REFUSES, exit 2, rather than passes). The release lint reads the new gate: a post that answered a multi-row key with one bare line now needs one line per due row; a ledger row whose snippet left its file is red (12 claims today, listed by the gate); a twice-re-windowed OPEN row that names only a planned car is red from the 7.55.0 line the backlog gate already carries (22 rows on the stage-7570 ledger — dispatch the car and flip the row to IN-PROGRESS, `BLOCKED(on <票>)` it, or close it). Run the gate from another repo with `--comms` / `--core-root` instead of relying on a sibling checkout.
54
+ - **Pins.** `test/backlog2113-rulings-gate-defects.test.ts` (G-1 … G-6, each red on the pre-fix gate and green on this one: positional ticket state, SHIPPED, the bare-pipe row, the DEBTS shape, the second-parser tripwire; claims and their adjacency, the content verdicts over real files, the moved list, the CLI; selectors, bare-key refusal, independence of lines; `--comms` / named ledger / missing root, `--core-root`; the window grammar, history not read, the replay shape), `test/rulings-disposition.test.ts` (the round trip: what the generator writes is exactly what leg (a) asks for), `test/backlog-ledger-gate.test.ts` (`#2260 q1`–`q3`, and the older re-window pin now says IN-PROGRESS where it said OPEN). Mutations: `mutations.md` in the car receipt.
55
+
56
+ ### A stored bare-name rule names a tool the catalog spells exactly — a case twin of a catalog tool is refused loudly, like a retired name, instead of being stored to reach nothing (#2654, on #1949; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none) (@server @cli)
57
+
58
+ - **What changed.**
59
+ - A whole-tool rule's name (`Tool`, `Tool()`, `Tool(*)`) is read by the one reading the entry lists and the session-rule store ask
60
+ (`toolNameFaultOf`: the name judgment's stale words, then another spelling of a carried tool), over the one universe the
61
+ persisted store has — the engine's tool catalog, case exact. `BASH`, `webfetch`, `READ` — another spelling of `Bash`,
62
+ `WebFetch`, `Read` — are refused, as a retired spelling (`bash`, `read_file`) already was: a rule the engine can tell reaches
63
+ no tool is no longer stored. The refusal code is `unsupported.tool` for every name that reading faults (a retired, unprefixed
64
+ or unmintable name read `invalid.grammar` in the unreleased #1949 form) and its sentence names the spelling to write
65
+ (`"BASH" is another spelling of "Bash"…`; a misspelt protocol peer: `write "mcp__git_hub__get_issue"`). A set of tools
66
+ (`mcp__srv`, a glob) keeps `invalid.grammar`.
67
+ - Every door that writes a bare name reads it through that one validator: `addPersistedRule` refuses a twin (`failed`,
68
+ `committed: "no"`, nothing written); a whole-tool card record cannot carry one; `prepareCardApproval` mints no card for a tool
69
+ named by a twin and the offer factory says `lane_cannot_speak` for it; `prepareCcImport` skips a twin with the validator's
70
+ code and sentence (the right spelling stays a name-set item the settings layer carries).
71
+ - One home for the reading: `nearMissSpellingOf` and the case comparison move from `allow-entry.ts` to `tool-name-aliases.ts`
72
+ beside the judgment, where `toolNameFaultOf` composes them; the entry reader (`readRuleEntry`) calls it and reads every entry
73
+ exactly as before.
74
+ - **Limits, stated.** A name no catalog tool carries and that is no spelling of one stays a rule (`SendUserFile`, and the typo
75
+ `Bsh`): it may be a tool a deployment mounts under that name, which is the reading the session-rule store and every name list
76
+ already have. The persisted store has no seat to declare a deployment's own tool names, so a deployment's tool whose name is a
77
+ case twin of a catalog name or a retired spelling (`bash`, `read`) has no whole-tool rule and its card says it has none, and a
78
+ typo of a deployment's own tool (`sendUserFile` for `SendUserFile`) is stored and reaches nothing — the declaration seat is a
79
+ candidate ticket. A case twin of a retired spelling (`multiedit`, `TASK`) is no case twin of a catalog name and stays a rule.
80
+ A row stored under a name that now reads as a twin (only a build that accepted the twin wrote one) is read as no rule: the
81
+ person's lane fails a deny or ask of it closed and lets an allow grant nothing, while the sync join drops such a row from the
82
+ round and reports it (`invalid_rule_text`).
83
+ - **Behavior narrowing (open to veto).** What changed: a bare case twin of a catalog tool no longer stores. Who is hurt: a host
84
+ that wrote a rule text such as `BASH` expecting it to land (it reached nothing), or that mounts its own tool under a catalog
85
+ name's case twin. Way back: bare-name rules exist only in the unreleased #1949 form, so no stored row of the refused form is
86
+ in a released build **provided this change ships in the same version as #1949**; shipped apart, a row a build accepting the
87
+ twin wrote would meet the sync drop above, and the answer is a grammar-stamp bump, not this section. A veto restores the
88
+ retired-only judgment in one function (`parseWholeToolRule`).
89
+ - @server: a rule text typed by an operator can now come back `unsupported.tool` for a twin (render the sentence as given).
90
+ @cli: the same sentence on an import report.
91
+
92
+ ### The engine's own account leaves through one throat per channel and is minted there — a notice (its sentence and every string leaf of its detail), a trace frame (every string leaf) and a memory announcement item pass the `same-principal` tier where they are delivered, not where each was worded; a host Brain's failure words, a response's reason phrase and a worktree mint's thrown text are minted where the engine receives them (#2529, design/1449 slice S2a; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none) (@server @cli @client-core @test)
93
+
94
+ - **What changed.** The engine's account channels each have one delivery function, and that function now mints what crosses it,
95
+ so a mint site that splices in a host's, a store's or a remote's words (a thrown text, an env value, a provider's word) no longer
96
+ has to remember to:
97
+ - `deliverEngineNotice` — the one throat every `EngineNotice` crosses — mints `message` and every string leaf of `detail` through
98
+ the `same-principal` text pass. The detail's member names are the engine's own closed words and are kept as written (a detail
99
+ `key` or `tokenCount` is not a credential name); a value inside the detail that is not plain data (an error, a class instance)
100
+ is read the way a value from elsewhere is read (`egressValue`: every string member passed, a credential-named member whole,
101
+ under that value's own member bound).
102
+ The unwired seat's console line is the minted sentence. A host Brain's finish word in `route.finish_reason_unknown` crosses
103
+ the same throat.
104
+ - `emitTrace` — the one throat of every trace frame — mints every string leaf of the frame; its kind, numbers and member names
105
+ are kept, and no member bound applies to what the engine built (plain arrays and records), in a frame and in a notice's
106
+ detail alike: its members are carried however many there are (a function member, an unreadable accessor and a member nested
107
+ past the depth bound are not, as before). The member bound applies to a value from elsewhere only (an error, a class
108
+ instance, a proxy — whose traps can answer a fresh value at every read), one bound per such value, the bound `egressValue`
109
+ reads it under; an object such a value reached first answers that one reading where the engine's data reaches it again.
110
+ - The memory announcement queue (`enqueueMemoryAnnouncement`; the next session's first injection reads it, so it is
111
+ model-facing) mints each item, and a standing condition's family prefix by the same pass (so a condition whose sentence carries
112
+ a credential still holds one line and still clears); the queue's one read path mints the items it reads too, so a line an older
113
+ engine or a neighbour left in `announcements.json` reaches the next injection minted; the consolidation driver's failure items
114
+ mint the failure's detail before their 300-character bound.
115
+ - A station that bounds a store's words before the notice throat mints them before its cut: the cross-boundary record notice
116
+ (`config.cross_boundary_record_unwritten`) mints the store's failure text, then bounds it to 300 characters. A detail member
117
+ that cannot be read (a throwing getter) is left out on its own; the members around it are read once and kept; an object the
118
+ detail reaches twice (as data and as an error's cause) is read once.
119
+ - The three lanes that account for a failed compaction pass — the turn boundary, the end-of-task pass and the prompt-too-long
120
+ recovery — read one reason: the thrown words minted, then bounded to 512 characters. The end-of-task and recovery lanes
121
+ carried the raw words into `compaction_outcome{failed}.reason` (and the end-of-task lane into its trace frame) before. The
122
+ `task.onerror_sink_failed` frame's reason is minted before its 256-character cut.
123
+ - A host Brain is its own transport: the failure words of a final message it answers are read once and minted where the loop
124
+ receives the message (`remoteWords(…, [])` — a fixed point over the engine brains' words, which their builders minted, so those
125
+ messages pass unchanged; a message whose words are an accessor or a proxy's answer — whatever the first read answered — is
126
+ carried as a copy holding the one minted reading, built without reading the member again, so a second read can neither answer
127
+ differently nor throw), and a Brain that throws has its thrown text minted where the harness builds the failure from it. So
128
+ `TaskResult.errorMessage` / `terminal.message`, the session archive's `API Error` record and the events read minted words for
129
+ every brain.
130
+ - WebFetch reads a response's reason phrase (`statusText`) through one minting reader: the failure headline (it sits outside
131
+ the untrusted fence), every `details.codeText`, and the SearXNG backend's failure text.
132
+ - The Agent tool carries a worktree mint's and a cleanup check's thrown text minted (`details.cause`, `details.worktreeCause`,
133
+ the report's settle line).
134
+ - **Behavior narrowing (open to veto).** What changed: a notice's sentence or detail, a trace frame, a memory announcement, a
135
+ compaction failure reason, a host Brain's failure text, a WebFetch failure headline or `codeText`, and a worktree failure cause
136
+ no longer carry a credential-shaped string (the credential vocabulary's forms — vendor tokens, bearer values, `key=` / `token:`
137
+ label values, URL userinfo, signed URL parameters); a notice's detail and a trace frame reach the host as a new value (a data
138
+ snapshot of what the engine built), never the object the mint site built; a host Brain's failed final whose words the mint
139
+ changes is carried as a copy holding the minted words. Who is hurt: a host or a test that matches a notice's or a frame's
140
+ words literally where they held such a string, that keeps a reference to a detail object to compare it, or that reads a
141
+ credential-shaped value a notice's detail carried (a raw env-knob value that happens to have a credential's shape). How to
142
+ migrate: read the structured members (codes, counts, names, statuses); a credential-shaped value is not carried.
143
+ - **Consumers.** @server: notices, trace frames and the compaction failure reasons on runs arrive minted (core's marker
144
+ `[redacted]`; the server's own marker and its read-path redaction are unchanged). @cli: an engine notice's console fallback line
145
+ is minted; the engine's other, direct `console` lines are not yet (S2b), so `engine-<pid>.log` can still hold what a host's or a
146
+ store's throw wrote (KNOWN-LIMITS KL-1449-a). @client-core: notice `message` / `detail` arrive minted. @test: notice, trace and
147
+ announcement texts that carried credential-shaped words now read the marker.
148
+ - **Not in this slice (S2b).** One engine console function and a closed-set gate "no `console.*` in src outside it" (27 direct
149
+ console calls in 16 files); the `onError` lane, which still hands a host the thrown value itself; side calls that read a host
150
+ Brain's `errorMessage` outside the loop (the classifier ladder, the advisor, memory consolidation); announcement items that
151
+ bound foreign words before the queue (the queue mints whole words only), and stations elsewhere that cut a thrown text before
152
+ any mint; the provider's echoed `responseModel`.
153
+ - **Pins.** `test/backlog2529-account-throats.test.ts` (N1 / N1b the notice sentence, wired and unwired; N2 the detail's
154
+ string leaves and kept names; N3 a foreign value in the detail; N5 an unreadable detail member; N6 the cross-boundary record
155
+ notice's cut; N7 one walk; N4 control; T1 a trace frame; T2 the sink-failure reason minted before its cut, through a real
156
+ Runner; L1–L3 the three compaction lanes with a host summarizer, L2 a control (that lane minted before); H1 a host Brain's failed
157
+ final through a Runner and the file session archive; H2 a Brain that throws; H4 / H5 failure words behind an accessor; H3
158
+ control; W1 / W2 the WebFetch reason phrase; A1 the Agent tool's worktree mint; M1 / M3 / M4 / M2 the announcement queue, its
159
+ family prefix and its read path); `test/backlog1449-account-unbounded.test.ts` (U1 a real Runner's `prompt.assembled` frame for
160
+ a 300-tool mount carries the members a 2-tool mount's frame carries — `ts`, `totalChars`, `snapshot`, `lowering`, every tools
161
+ row; U2 a 3 000-row frame reaches the tracer as built; U3 a credential-shaped string past 2 000 members is still minted; U4 a
162
+ 3 000-member notice detail; U5 a value from elsewhere under its own bound, the one after it whole; U6 a proxy that mints a fresh
163
+ proxy at every read is read under the bound, not followed level by level).
164
+
165
+ ### A session write belongs to the leg that issued it, and what a resumed leg's continuation carries settles at that message's own receipt (#843 / #2080; behavior change, open to veto; hard-breaking: none — no store contract change; closed sets: none; exports: +1 root name `teardownLegBudget` (function, #2557)) (@server @client-core @cli)
166
+
167
+ - **Before.** A bounded teardown leg that wrote to the session (the date anchor, the workspace state, the announced-listing
168
+ snapshot, a `resumeAt` write-back) and outlived its 15 s bound kept running while the leg's session lock was released at
169
+ its result. The session's next leg on the same Runner woke the session and wrote beside it: when the late write landed
170
+ first, the next leg's own first append lost the leaf CAS — that run failed `conflict` and its objective never reached the
171
+ transcript; when it landed second, the late write failed with nobody told. Separately, a resumed leg's continuation was
172
+ marked delivered when the loop took the prompt, before the message was in the session: a failed append of it lost its
173
+ parked steers, the caller's wake message and the orphans' obituary with no account, and a review gate's reject carried by
174
+ it read as delivered, so its row was not reopened.
175
+ - **Now.** (1) The writes a leg issues to its session stay the session's until they settle (a Runner-level ledger beside the
176
+ session lock). The session's next leg starts only once they have: it waits at most the teardown bound and otherwise fails
177
+ `conflict` before writing anything. A write a leg issues after its leg ended is not started; a write that settles after its
178
+ leg ended is reported through `onError` (`phase: "degraded"`, "… it landed" / "… it failed: …"). A session with no write in
179
+ flight takes no new suspension point at its hold: the ledger is asked synchronously and only a busy session awaits the
180
+ settle (the leg's failure path keeps the microtask depth it had). `rewindFiles` answers
181
+ `rewind.session_busy` while such a write is in flight. (2) A resumed leg's continuation — parked steers, the wake message,
182
+ the approving person's words, the orphans' obituary and a review gate's decision — is settled when the continuation message
183
+ is in the session (the prompt's own append receipt, the step the live steer lane already settles at): a failed append
184
+ leaves it owed, so the frames are announced as `task.user_steer_undrained` and an undelivered reject returns its row to
185
+ pending. The resumed frames' `human_input` "applied" accounts go out at that step (still before the message's
186
+ `message_committed`).
187
+ - **Behavior change (open to veto).** *What changed*: a leg on a session whose previous leg on the same Runner left a write
188
+ unsettled waits for it (up to 15 s) and then fails `conflict` up front instead of starting beside it. *Who is hurt*: a
189
+ deployment running same-session tasks back to back on a store slower than 15 s per append — the second task now fails
190
+ before it starts (it used to fail mid-run, or drop the late write in silence). *How to migrate*: nothing to change — retry on
191
+ `conflict` as before; a store that slow is already reported by `TeardownLegTimeoutError`.
192
+ - **Limits (stated).** The ledger covers writes that reach the store; the harness's own queued writes held under an abort die
193
+ with a durable-suspended harness as before. The bounded wait is the drain — waiting behind a leg that is still running is
194
+ unbounded as before, and so is a leg wedged inside an awaited in-run append. The fence is one Runner: two Runners sharing a
195
+ store, or two processes, stay fenced by the leaf CAS only. A resume refused at the drain was already claimed; it takes the
196
+ existing reopen path (`env_failed`) — and, like any resume that fails between its claim and its leg, it drops a wake message
197
+ with no account.
198
+ - **Exports (#2557).** +1 root name `teardownLegBudget` (function, `(): number`): the bound in force for one teardown leg, in
199
+ milliseconds (15 000) — the same number the session's next leg waits at most for a late write. @server (S-2153): a pod
200
+ reclaimer's release wait reads it from the root instead of keeping a copy of the constant (`ENGINE_TEARDOWN_RELEASE_MS`).
201
+ - **Consumers.** @server: no store change (`PgSessionStorage` / `TiDBSessionStorage` / the window and fork-routing stores
202
+ unchanged); the new `conflict` arm meets the existing evict-and-retry. @client-core / @cli: `task.user_steer_undrained` rows
203
+ can now appear for a resumed leg's parked steers / wake message (the existing family, counted under `steer`).
204
+ - **Pins.** `test/backlog843-session-write-hold.test.ts` (W1 the abandoned write lands first and the next leg runs on top of it;
205
+ W2 it does not settle in time: the next leg is refused up front, a later one runs; W3 the ledger's clauses; W4 the rewind
206
+ verb's busy fact; W5 an idle session's hold takes no drain await — the hold resolves on the lock's own depth and the ledger is
207
+ never asked to settle; C1 / C2 a parked steer / a wake message on a failed continuation append are announced; C3 a delivered
208
+ continuation owes nothing and "applied" precedes the commit; C4 an undelivered plan reject reopens; C5 a refused leg's
209
+ stream settles at once). Re-pinned to the surviving reading: `backlog554-interrupted-continuation`,
210
+ `backlog554-resume-batch-settlement` (the loop's hand-off seam is retired; delivery is the prompt's `message_end`).
211
+
212
+ ### A resume's claim is reversible by the store contract — `CheckpointStore.reopen` is a required verb (the `redecision` declaration retires), every refusal after the claim returns the row to pending, the result says what happened to it, and a leg is compensated once (#1914, and #1900's resume-lane and alias cells; hard-breaking, open to veto; closed sets: none; exports: `checkpointStoreExecutionUndeclared` renamed `checkpointStoreIncomplete` (it names every required member a store lacks — the `execution` declaration, the `reopen` verb — and answers `undefined` when none is missing); `CheckpointStore.reopen` becomes required; `CheckpointStore.redecision` removed) (@server @dgx)
213
+
214
+ - **Before.** The resume ladder wins the checkpoint's CAS before the resumed leg prepares; a refusal at that prepare (the doors that read the leg the claim's hand-off builds — interaction posture, the approval seat, the model gates, prepare's own stretch — and every judgment that needs the assembled roster: a near-miss own list, a re-supplied tool whose name no caller tool may carry) could return the row to `pending` only through the store's optional `reopen`. A store without it kept the row `resolved` — the approval gone — while the leg's failed result was byte-identical to the one a reopening store produced (the thrown exit added no account of the row), and three documents said the row stays pending "on a store with `reopen`" or that the engine probes for it (INTEGRATION-CORE, KNOWN-LIMITS, SERVICE-INTEGRATION-GUIDE). The returned exit answered a definitive reopen refusal with "its state is unprovable".
215
+ - **Now.** One contract: `reopen` is a required verb, and the verb is the contract — the store seat (`resolveCheckpointStore`, the one function every seat read goes through) refuses a store that does not carry it, beside the `execution` declaration; no declaration stands for it (`redecision` retired, and with it the sandbox-admission mode's own read of it: a resolved store is re-decidable). A claimed leg that ends before its decided action is disposed — a failed restore, a refusal at its prepare, an abort, a leg that returns failed — returns the row through it, under the failure's reason (`config_refused` for a configuration refusal). What happened to the row is ONE reading of the store's answer (`compensateUnstarted`) and one sentence per outcome on every exit (the thrown backstop, the returned settle, the post-claim abort): reopened (the leg keeps its own code; the message adds that the checkpoint was REOPENED and what to do), declined (`reopen` answered `false`: the row is no longer the resolved row this leg claimed — missing, expired, its decided action already disposed, or pending again — read it before deciding), unknown (the reopen threw: state UNKNOWN, the store's error named through the same-principal redaction on every exit; a throw is never read as a refusal, whatever code it carries). One compensation per leg: a leg whose result was already minted (a tail step threw after the run body settled it) is not reopened, unpinned or torn down a second time by the backstop. Claim and prepare keep their order: the leg's prepare rewinds the transcript, restores the paused environment and dials its peers, which only the claim's winner may do, so the screen before the claim stays the early exit and the reversal is what makes the late one safe. Reach, stated: a deployment's post-consume hook (`RunInternals.afterCheckpointResolve`) that throws, or a post-claim abort after it ran, leaves the row to the hook owner's rollback as before.
216
+ - **Hard-breaking (open to veto).** (1) *What changed:* a store without the `reopen` verb is refused at the first read of either store seat (`config.invalid_checkpoint_store`, the sentence naming each missing member), and the engine has no lane for one (a refusal after the claim always asks the store to reopen). *Who is hurt:* a hand-written `CheckpointStore` that omits `reopen` — TypeScript reports it at compile time, a JavaScript store (or a wrapper that does not forward the verb) at the first prepare; server's SQL and local stores already implement it. *How to migrate:* implement `reopen` (the store-contract kit's reopen cases are the reference) and forward it in every wrapper. (2) *What changed:* `CheckpointStore.redecision` is removed from the contract and read nowhere. *Who is hurt:* server's SQL and local stores and any deployment store (@dgx) that declare `redecision = { reopen: true }` — the field becomes a dead member (no compile error for a class; an object literal typed as `CheckpointStore` gets an excess-property error), and a test pinning it pins nothing the engine reads. *How to migrate:* delete the field; nothing replaces it. (3) *What changed:* the root export `checkpointStoreExecutionUndeclared` is renamed `checkpointStoreIncomplete` and returns `undefined` for a store that carries every required member. *Who is hurt:* a host wrapper that minted the engine's refusal itself. *How to migrate:* call `checkpointStoreIncomplete(seat, store)` and throw what it returns.
217
+ - **Behavior change (open to veto).** (1) A failed result whose claimed row was reopened now says so in its message (the code is unchanged), and a reopen the store declined says "DECLINED … read the row" on every exit instead of "terminally consumed" (thrown and abort exits) or "unprovable" (the returned exit). The codes do not separate every outcome on the returned exit (the leg's own code is kept either way) — a structured outcome member is a follow-up. (2) A widening: the sandbox-admission mode now arms over a store that carries `reopen` but never declared re-decision (before, a missing `redecision` declaration kept it off); every store's `reopen` is now contract.
218
+ - **Consumers.** @server: delete the two `redecision` fields (`checkpoint-store-sql.ts`, `local-checkpoint-store.ts`) and the test that pins them; a test that wraps a store without forwarding `reopen` is refused at the seat; `resume.reopen_failed_unstarted` / `checkpoint.reopen_failed` keep their codes, their sentences change. @dgx: a deployment store implements `reopen` if it lacks it and drops `redecision`.
219
+ - **Pins.** `test/backlog1914-claim-reversible.test.ts` (S the seat: both seats, a member that is not a function, a proxy that hides the verb, both members missing named in one refusal, the in-tree stores accepted with no declaration, the sandbox-admission mode arming over a store that declares nothing; R seven refusals after the claim — interaction posture, an unknown model, a reserved card on a caller tool, a near-miss own list, a re-supplied tool named `-deploy`, one whose second alias is `-x`, one whose alias is not a string — each: the leg's own code, the row pending under `config_refused` at `rev` + 2, the reopened sentence, the corrected resume redeeming the same token; O the three exits under a store whose `reopen` answers `false`, one that throws after committing, and one that throws an error carrying the refusal's own code (still unknown); a credential in the store's error redacted on all three exits; one compensation per leg — a minted result is not reopened again). `test/docs-contract-pins.test.ts` reads the required call, not the retired probe. The files that pinned the removed lane (a store with `reopen` deleted) re-pin onto a store whose `reopen` declines, each cell keeping its purpose (before-the-claim cells: the row pending and byte-identical; after-the-claim cells: `checkpoint.reopen_failed` with the leg's refusal as the lead).
220
+
221
+ ### One judge answers "does this shell command only read" for every read-only consumer — the parallel-batch admission, the read-only shell face and the transcript gate stop judging by the program's name, and a face's program list only narrows the judge (#1438 S1; behavior change and security narrowing, open to veto; hard-breaking: one (`readOnlyShellVerdict` takes an options object); closed sets: none; exports: +1 type `ReadOnlyShellOptions`) (@server @test @cli)
222
+
223
+ - **Before.** Four functions answered the question. The read-only reader (`readOnlyShellVerdict`) served the allow layer and a compound's segments; the reversibility classifier served the full shell's probe; a name-only check served the shell tool's `isConcurrencySafe` and the read-only face's execute guard; the transcript gate had a name check of its own. For the last two a program's name on the list was enough: with `bashReadonlyAllow` widened to `sort`, `sort -o out f` ran on the read-only face, and with `readAllow` widened to `sed`, `sed -i 1d <transcript>` passed the transcript gate.
224
+ - **Now.** `isConcurrencySafe` = the reader, narrowed to the shared list (`BASH_READONLY_DEFAULT_ALLOW`: programs with no effect beyond reading under any arguments — no `cd`, which moves the committed working directory, no git, whose configuration can run helpers), reads the call as read-only, and the call is not `run_in_background`. Upstream reads `isConcurrencySafe = isReadOnly` with no list; this release keeps the narrower set (a divergence, open to veto). The reader no longer peels `noglob`: upstream strips it as a zsh precommand, and this engine's shell is bash, where `noglob` is a program or function looked up like any other (a divergence, stricter, open to veto). The read-only face keeps its single-simple-command admission and runs a call only when the reader, narrowed to the face's list (`readers`), reads it read-only. The transcript gate lets a command that references the transcripts through only when the reader, narrowed to `readAllow`, reads it read-only. The name-only check (`coarseReadonlyCheck`, never on the package root) is gone. A program outside a face's list is refused with that face's one sentence (`command "x" is not in the read-only allowlist`), minted in one place for the reader and the classifier. The classifier itself is unchanged in this release.
225
+ - **Hard-breaking (open to veto).** *What changed*: `readOnlyShellVerdict(command, shape?, cwd?, bound?)` became `readOnlyShellVerdict(command, { shape?, cwd?, bound?, readers? })`; `readers` (a `Set` of program names — the check is that `has` is callable, so a `Map` passes it too; a value without a callable `has` throws) narrows the answer to lines whose every named program — each segment's program past a `command` / `builtin` prefix, and an `xargs` segment's target — is listed. *Who is hurt*: a caller passing the old positional arguments — none found outside the engine (server's consumption ledger records the reader as not consumed; test's repro scripts pass the command alone); in-tree, two engine call sites and six test calls moved. *How to migrate*: move the positional arguments into the object (`readOnlyShellVerdict(c, s, cwd, b)` → `readOnlyShellVerdict(c, { shape: s, cwd, bound: b })`).
226
+ - **Behavior changes (open to veto).** *Parallel batches*: read-only pipelines and compounds of listed programs (`ls | wc -l`, `cat a | grep x`, `ls && pwd`) and a listed program behind `command` / `builtin` now join a parallel batch with other read-only calls (they ran in order); a listed program's form the reader refuses (the 13 `grep` spellings below) now stays ordered; git, `sort`, `find`, `cd`, writes, environment assignments outside the reader's names and path-prefixed programs stay ordered as before. *Allow layer*: `noglob <program>` is no longer read-only (it ran without a question). A repository's own configuration can make a git read form run a program (`diff.<driver>.textconv`, an external diff), which the allow layer runs without a question, as before and as upstream does (KL-1438-a); the upstream reader also probes the directory for bare-repository indicators and an unverifiable `.git` redirection before calling a git command read-only, and this reader does not (a named gap, now ticketed). *Read-only face*: a listed program's writing call is refused (`sort -o out f`, `uniq f out`, `gzip f`, `tar cf out.tar f` with the program listed); a listed program behind a `command` / `builtin` prefix runs (`command cat f` — it was refused by the name `command`), and so does it in the transcript gate; over the #994 dual-run corpus, 15 strings the default list used to run are now refused — 13 `grep` spellings the upstream flag walk refuses too (12 short-option clusters carrying a value: `grep -if pat f`, `grep -e/api f`, `grep -f~/x f`; and the abbreviated `--reg=pat`), and two unterminated quotes (`echo "x`). *Transcript gate*: a read-only compound over the transcripts (`cat <t> | grep x`) is allowed (it asked); a listed program's writing call asks (`sed -i` with `sed` in `readAllow`); the ask sentence carries the reader's reason.
227
+ - **Consumers.** @server: `createTranscriptIntegrityPolicy` with the default `readAllow` (`deployment-governance.ts`) — no change needed; a read-only compound over the deployment's transcripts no longer asks. @test: `readOnlyShellVerdict(cmd)` with the command alone is unchanged; the classifier half of the 38-command table reads as before. @cli: none (the verify scripts drive the classifier).
228
+ - **Comments made true.** The tightening reader is the one lexer's projection, not a lexer of its own (rule model, peel projection, root export note) — the lexer half of #1647 was closed by the one-lexer work; its walk half stays with #1536.
229
+ - **Pins.** `test/backlog1438-one-readonly-judge.test.ts`; re-pinned: `tool-parallelism` (`ls | wc -l` is safe), `backlog619` (`noglob ls` is not read-only), `backlog482-cc-e6s-allowlist` (the shared list narrows the reader), `backlog994-s1-dual-run` (the `coarse` column retired), `backlog619` / `backlog1116` / `backlog1685` / `backlog994-s2` (the options object).
230
+
231
+ ### A remote endpoint's words reach the engine's own text through one mint — what the request was declared with goes first, then the credential vocabulary, then any cut; persistence is not an exit (#1449 slice S1, #2143 rule; design/1449 r2; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none) (@server @cli @test)
232
+
233
+ - **What changed.** One function, `remoteWords(text, declared)` (`src/core/protocol-http-auth.ts`), is the form a remote's words
234
+ take when the engine carries them in its own words: every declared value of the request, in each spelling the request-echo
235
+ pass derives, then the `same-principal` credential vocabulary; where a declared value is there, the values the vocabulary finds
236
+ in the ORIGINAL words go with it, so removing a declared value never hides a credential from the vocabulary (a declared `Bearer`
237
+ and the token after it both go) and the vocabulary never leaves the rest of a declared value; words with no declared value in
238
+ them read exactly as the vocabulary renders them. Words are minted once, where they enter the engine's text. Its readers: the
239
+ MCP failure sentence (the module's private copy is gone), the model brains — where a failure is built (an HTTP error body, a
240
+ transport's own throw on connect or mid-stream, a request the runtime refused to build, an in-band error frame), the stream's
241
+ terminal shell for a value no builder minted (a transport's or a seat's own throw, a cancel; a built failure is carried as its
242
+ builder minted it, so the engine's own display-redacted roots keep their `«redacted»`), and the words a finalize writes without throwing (a refusal's words, the
243
+ provider's stop details — read once, so the sentence and the `diagnostics` record agree — a Responses terminal status and
244
+ reason, the malformed-frame sample, a malformed tool call's name and argument fragment, a word that arrived after the stream
245
+ ended) — and the memory distiller seat's failure, its transport's throw included. A brain request states what it was declared with (`SSERequest.declared`: the entry's, the
246
+ deployment's and the call's header bags as the route kept them, the key when it names a secret — a placeholder key (`EMPTY`, a
247
+ one-letter local key) is left in the words as it is left to every child — and on the Anthropic lane a configured version and
248
+ each entry of a declared beta list); the engine's own protocol words (`content-type`, the default version, the betas it chose)
249
+ stay readable. The words are minted before they are cut, so a cut leaves no piece of a value; a failed body that did not end
250
+ (torn, past the 64 KiB read bound) still reports, as before, but its last, unfinished word is not carried — a value's start
251
+ no pass can recognise is always in that word. A provider's echoed `requestId` or `apiErrorCode` rides the
252
+ failure only when the mint would leave it as it is; otherwise it is dropped (`brain.echo_dropped`, the existing trace).
253
+ The mint is a fixed point — it never rewrites one of the four marker literals a pass writes, and a marker-SHAPED word a remote
254
+ wrote is no marker — so a text minted twice (an MCP failure's rethrow and its status face) reads the same; before, a short
255
+ declared value could rewrite the marker (`[[redacted]acted]`).
256
+ Because the session archive's `API Error` record, `TaskResult.errorMessage` / `terminal.message`, the StopFailure hook and
257
+ the memory announcements read the brain's minted text, none of them carries the provider's echo of the request any more.
258
+ - **The rule this states (#2143).** Persistence is not an exit: whether bytes are redacted is decided by who wrote them, where
259
+ the engine words or carries them. Content — what a model, a tool or a user wrote: the session archive's message entries,
260
+ journal replay payloads and a failed row's model output, checkpoint call arguments, spilled tool results, the memory
261
+ distiller's plan and transcript archive — is kept verbatim by the engine's own stores (a replayed thinking block is checked
262
+ byte for byte by its provider; evidence rewritten is no evidence; the reference implementation keeps its transcripts
263
+ verbatim), in files created 0600 under directories created 0700. The engine's own words are minted already redacted. A host
264
+ redacts content at the exit it writes it FOR someone (`docs/INTEGRATION-CORE.md` §0 item 4, unchanged). So the reasoning a
265
+ server's run-event log shows redacted is in the engine's session archive as the model wrote it — two records, one rule.
266
+ - **Behavior narrowing (open to veto).** What changed: a model brain's or the memory distiller seat's failure text no longer
267
+ carries the request's declared values or credential-shaped strings, a torn or over-bound error body no longer shows its
268
+ last, unfinished word, an echoed `requestId` / `apiErrorCode` (or a late frame's word, now `?`) that carries either is absent,
269
+ and the Anthropic `stop_details` diagnostics record holds only its `category` / `explanation` strings, minted. Who is hurt: a
270
+ host, test or operator that matches provider error sentences literally, or keys a support ticket on a request id the
271
+ provider echoed a credential into. How to migrate: read the structured seats (`apiErrorStatus`, `apiErrorCode`,
272
+ `providerCondition`, `requestId`, `errorKind`, `errorBodyIncomplete`), not the sentence.
273
+ - **Differs from the reference (open to veto).** The reference turns a failed API call into a synthetic assistant message
274
+ holding the provider's text as it came; here the failure text is minted at the transport (stricter).
275
+ - **Consumers.** @server: run rows' `failed.errorMessage` now arrive minted (core marker `[redacted]`; the server's own marker
276
+ and read-path redaction unchanged); `requestId` may be absent on a failure that echoed a credential. @cli: KL0170 stays true
277
+ for model replies; the archive's `API Error` records no longer hold the request's declared values. @test: the session
278
+ archive's message entries differ from a redacted run-event log by design.
279
+ - **Not in this slice.** One mint for the engine's account lines — console lines, notices, traces, the `onError` lane, memory
280
+ announcements' other sources, a host Brain's failure text, WebFetch's failure headline, a sub-agent isolation failure's
281
+ `details.cause` (design/1449 S2); declared values written with JSON `\u` escapes (S3); permission checks on existing store
282
+ files (S4); A2A failure text (its own ticket, reading the same mint).
283
+ - **Pins.** `test/backlog1449-remote-words.test.ts` (B1 a real endpoint's 401 echo through a Runner: result, events and the
284
+ session archive; B2 the Anthropic lane; B3 the cut; B4 in-band frames, three lanes; B5 / B5b the distiller seat's body and
285
+ its transport's throw; B6 the vocabulary half; B7–B7d the mint, its fixed point, a forged marker, a declared label; B8 a torn
286
+ body; B9–B9c a transport's throw on connect and mid-stream, an unbuildable request, a cancel; B10 the echoed seats; B11 a
287
+ configured version and beta entries; B12–B12c a refusal, a malformed sample, stop details with a null explanation, a late
288
+ word and a Responses terminal reason; B13 a malformed tool call's fragment; C1 protocol words stay readable; C2 the archive
289
+ keeps reasoning verbatim).
290
+
291
+ ### `importHistory` says `unsupported_format` only when the door refuses the envelope; every failure after the door — a source that cannot supply a blob (its read throws, or its bytes fail their address), a fault in staging or publishing — is `adopt_failed` (#2562; behavior change, ruled C-R857, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @test)
292
+
293
+ - **Before.** `FileHistoryError`'s contract text said a source that cannot supply a referenced blob, and a fault in the publish step, answer `adopt_failed` (the adoption family's word) and that `unsupported_format` names a bad envelope. The bundled backends answered three things differently: a source whose read THROWS while serving a blob (both backends), a blob whose bytes fail their content address (both backends), and every throw in the File backend's own staging writes and in either backend's catch-all (a disk refusing a good graph) were `unsupported_format` — so a host that reads `unsupported_format` as "the envelope is bad, do not retry" was told that about a healthy envelope, and the server's two SQL stores, which follow the contract text, answered differently from the reference for the same input (two readings of one fact).
294
+ - **Now.** One reading. `unsupported_format` is a word only the import DOOR says: `validateFileHistoryExport` (a foreign epoch, a wrong version, content the closed sets cannot hold — and now also a payload that cannot even be read, a throwing member or a revoked proxy, which used to THROW out of the function; the door now also reads, once, the two members a backend copies without judging their value — `backupTime` and `parentIdent` — so a throwing one is refused here and not found mid-import) and the File backend's own record-naming rule (an entry id that cannot be a file name here — a reserved device name, a leading dot or dash, over 200 characters, an unsafe character), which now runs at the door, before any blob is fetched or byte staged (it used to run mid-staging, after the fetches, and a refused envelope left fetched blobs on disk). Everything after the door is `adopt_failed`, with the caught fault's `permanence` where it has one: a source whose read throws, a source that has no such blob (unchanged), a source whose bytes do not hash to the address asked for, a fault writing the staged graph, a fault publishing it (unchanged in the File backend), and either backend's catch-all. The contract's two sentences (on `FileHistoryError.code` and on `importHistory`) are rewritten to say exactly this, and a test reads them against one table of arms so they cannot drift from the behavior again.
295
+ - **Behavior change (open to veto).** A host that branched on `unsupported_format` from `importHistory` to mean "refuse the payload, do not retry" now sees it only for a payload that is genuinely refused at the door; an unreachable or damaged source and a storage fault arrive as `adopt_failed` (with `permanence` when the fault code is judged). The contract kit now asks the same of a third-party backend: a source whose read throws, and a source whose bytes fail their address, must answer `adopt_failed` (a backend that answers `unsupported_format` there fails the kit's "import door refuses, never coerces" case; the server reports that its SQL stores already answer `adopt_failed` for the throw arm — the wrong-bytes arm is new to them). (@server: no change needed if both arms already answer `adopt_failed`; align the wrong-bytes arm otherwise. @test: the two-database suite reads the kit's new assertions.)
296
+ - **Who is hurt.** A host or test that pinned `unsupported_format` for a throwing or damaged source, or for a staging write fault, on either bundled backend; `test/backlog1468-t2-file-history-permanence.test.ts` is the one in this tree (re-pinned). A caller of `validateFileHistoryExport` that relied on it throwing for an unreadable payload (none known in this tree; the engine's own callers are the two backends).
297
+ - **Pins.** `test/backlog2562-import-failure-words.test.ts`: one table of arms run against both backends (door: foreign epoch / unknown self-description / unreadable payload / entry id the File backend cannot hold, none fetching a blob; adoption family: source read throws with its judged permanence, source has no blob, bytes fail their address, a staging write fault on the tracked set and on a boundary with no half-built graph left, an in-memory publish fault and an in-memory fault after the door); the door never throws over a throwing member, a revoked proxy, a throwing row, and a throwing `backupTime` / `parentIdent` (KL-2562-a records the residue: an in-process object that throws only on a second read); the contract sentences ↔ the table (each row's phrase must stand on the side of the `unsupported_format` sentence its code says; every code word the sentences name is in `FILE_HISTORY_ERROR_CODES`); the kit refuses a backend that answers the old word for either arm.
298
+
299
+ ### One selector build — a leg's tool lists are read once by the strict reader, and the selector is built from what was read, before the session is acquired (#2511 / #1913; behavior narrowing, open to veto; exports unchanged)
300
+
301
+ - **Before.** A leg's tool selector (the ceiling it was spawned under, then its own `allowTools` / `excludeTools`) was compiled at the head of prepare by the lenient reader — a list that is not a list, or an entry no rule syntax reads, was read as "hides nothing" — and every early reader consulted that object: the MCP dial gate, the valve, the roster, the memory pair, the ceiling handed to children. The strict reader ran only at the end of roster assembly, on the LIVE layers a second time, and a second compile followed it. So one table had two readers whose agreement rested on their order, a live ceiling was read four times in a run that never parked (its `deny` getter, four; its `allow`, three) and once more when a leg parked — the row's `toolCeiling` stamp read the live layers again, so a getter that changed its answer was recorded differently from the selector the leg ran under — and the strict refusal arrived after the session was acquired and after the declared servers were dialed. Measured on a real Runner, previous stage head: `excludeTools: [" mcp__srv"]` (a leading blank) dialed the declared MCP server once and was then refused; the same entry in a live ceiling layer whose `deny` getter answered it first and `[]` after was never refused at all (the compile read it as nothing, the judgment read the `[]`), and the run completed; a getter that answered `[]` first and the entry after was refused only at the end of roster assembly.
302
+ - **Now.** `readToolSelector({ layers, where, legs })` (`sub-agent-deny-lists.ts`) reads each layer ONCE with the layer reader the run's list judgment used — which now returns what it read, a frozen layer of the entries as written, an absent allowed list staying absent — and compiles the selector from those products; the live layer is never read again. It is called right after the leg's own-list record is bound and before the session acquire, beside the other refused-before-the-acquire doors. `judgeRunToolLists` takes the built selector: its layer loop and its second compile retire, and the selector's layers — which are what the leg hands its children and its workflow agents as their ceiling, and what a parked leg's checkpoint row records as its `toolCeiling` — are the frozen products. Rule count: readers of a leg's selector lists 2 → 1; reads of a live layer per run 3–4 (one more at a park) → 1; the sentence for whose lists the own layer is has one definition (`ownListsWhereOf`), read by the build and the judgment alike.
303
+ - **Behavior narrowing (open to veto).** A leg whose own list or ceiling layer the strict reader refuses is refused before anything is held: the code and the sentence are the ones it always had (`config.tool_face_control`, `unknown_name` / `not_a_list` / `not_a_string`, the ceiling named by its distance, the own layer by `TaskSpec.excludeTools` or the delegation tool that wrote it), but the session store sees no `acquire` (or `forget`) for the refused run, and no declared MCP server is dialed. A live ceiling is read once, so a getter that answers differently the second time is judged and used as it first answered — in either direction (an unreadable first answer is now refused where the compile used to read it as nothing). Not changed: which list a name judgment refuses or keeps, and the order of the refusals after the build (the near-miss, protocol-denied and definition-list judgments still run over the assembled roster, as before). A task with two faults now reports the leg's list first, wherever the other refusal used to sit between the own-list record and the end of roster assembly (a refused `extraBody`, capability declaration or file-history request; a missing session under `requireExistingSession`; a roster or mount refusal).
304
+ - **Consumers.** @server: a refusal of the leg's lists now arrives before the session store is written (`acquire` is not called for it; a store that counted acquires or minted a row on `acquire` sees none for this refusal); the code and sentence are unchanged. Nothing else is asked of the host: `RunToolListsInput.layers` becomes `selector` on an internal function, not an exported name.
305
+ - **What still stands (named, not closed).** `judgeHostToolCeiling` (a workflow host's `RunWorkflowOptions.toolCeiling`) still reads its layers and then compiles the live ones (its own ticket: the refusal's attribution changes when it goes through the build); `compileToolSelector`'s lenient arms stay for its other callers (the spawn-time child selector, the single-layer helpers), which compile lists that are read strictly before any effect; the own-list door (`assertToolNameListValue`) is a third reader of the own layer's list-ness.
306
+ - **Pins.** `test/backlog2511-read-tool-selector.test.ts` (A the dial gate, B one read of a live ceiling, C before the acquire, D the builder, E the judgment reads no layer, F the parked row records the ceiling as read); re-pinned onto the selector: `backlog1875-one-entry-reader`, `backlog1886-r9-closure`, `backlog1910-persisted-selector-canonical`, `backlog1930-near-miss-refuses`.
307
+
308
+ ### A background agent has its name from the moment its record exists — the registration writes the spawner's name, or the one derived from the handle (`agent-` + the first twelve hex digits), on the live handle and in the row's first write; `BackgroundAgentRecord.name` is required, so the card of an unnamed parked child can be decided (#2450; hard-breaking, open to veto; behavior change, open to veto; closed sets: none; exports: +1 root name `engineAgentName` (function); `BackgroundAgentRecord.name` and `BackgroundAgentRowSummary.name` go from optional to required) (@server @cli @client-core @test)
309
+
310
+ - **Before.** An Agent call that named no child started a background agent whose live handle and durable row carried no `name` — the park terms never asked for one, by decision, so such a child parked on its approval like any other. A deployment's revive door reads `row.name` and turns a parked row without one away (sema-server answers `409 decide.row_unrevivable`), so the card of an unnamed parked child could be neither approved nor denied: the child sat parked until its checkpoint expired. Two predicates — may it park, may it be revived — answered one question differently, and the engine never made them agree.
311
+ - **Now.** One place writes an agent's name: the background registration (`registerBackgroundAgent`). It takes the spawner's explicit name, else `engineAgentName(handle)` — `agent-` plus the first twelve hex digits of the handle (`a3fa9c2d1b7e04a15` → `agent-3fa9c2d1b7e0`; a pure function of the handle, so the same agent id reads the same name in every process and every store; twelve digits (48 bits: two agents of one caller would have to share twelve digits of their ids to collide, and the name layer resolves a name to the newest holder), not sixteen, so the result is an ordinary admissible name and not the shape of an agent id, which is refused as a name everywhere). The live handle and the durable row take it from that one value, and the row's first write carries it — it is not minted at park time. The park terms and the revive door are unchanged; they now read one fact. A revival hands the row's name back to the Agent call as `name`; when that name is the one derived from the row's handle, nobody chose it, so the call keeps the child unnamed — no spawn identity on its frames, no roster entry, no teammate role appears on its second cycle — and the registration derives the same name again. The other reader that hands a row's name to a place that reads it as an identity, SendMessage's retained-session resume (the child's session kept by this run), takes it through the same reading, so a retained resume does not publish the derived name as a spawn identity either. An explicit name is stored verbatim as before; an empty or non-string `name` handed to `registerBackgroundAgent` is refused (it used to be stored). The row a revival re-registers is healed on the way: a row an earlier version wrote without a name gets the derived one in the revived cycle's first write.
312
+ - **Hard-breaking (open to veto).** *What changed*: `BackgroundAgentRecord.name` and `BackgroundAgentRowSummary.name` are required `string` (they were optional); every row core writes from this version carries one. *Who is hurt*: an implementer of `BackgroundAgentStore` (sema-server's SQL stores, a host's own store) or a host that builds a record or a summary by hand — a TS host sees a compile error where it builds either without `name`; and a store that reads rows an earlier version wrote, which have none (the type is true of rows written from this version on). *How to migrate*: keep a name column (nullable is fine) and read it as `name: row.name ?? engineAgentName(row.handle)` — the derivation core uses, so an old row reads as the name a fresh registration would have given it (core's own in-memory and file stores do not rewrite old rows on read; a revival writes the name into the row, a row that is never revived stays as it is on disk). A door that turns a nameless parked row away keeps working for such rows; for rows read through the line above it never fires.
313
+ - **Behavior change (open to veto) — the derived name is visible.** Wherever a background agent's name is read it is now there for an unnamed agent too: the ListAgents row (it listed the description before), the name layer of SendMessage and TaskStop (`to: "agent-3fa9c2d1b7e0"` reaches the live handle; the description layers still resolve the description), the "did you mean" pool, the durable row. Not changed: the not-found footers of TaskOutput, TaskStop and SendMessage (an agent nobody named is still listed by its task id and description — an address TaskOutput accepts, which resolves exact ids only — and a chosen name is still a bare name), the launch receipt (still the task id), the fleet frames' `name` (the description-backed display label) and `spawnName` (the explicit spawn identity — a derived name is never one), the roster (only explicit names are recorded: after a restart an unnamed agent is reached by its task id, as before). What an unnamed agent is CALLED is not yet one reader's answer: ListAgents shows its derived name (`full?.name ?? t.description`, `list-agents-tool.ts:259`) where the page footer shows its description (`chosenAgentName(h.id, h.name) ?? h.description`, `task-registry-agent.ts:2110`) — #2703 (KL-2450-a).
314
+ - **Consumers.** @server (S-2075): with the name on every row, your revive door's `row.name === undefined` turn-away no longer fires for rows written from this version; read old rows through `row.name ?? engineAgentName(row.handle)` (above) and the unnamed parked card is decidable — the decide route's args (`name: row.name`) are accepted as they are. Your `BackgroundAgentStore` implementations take the type change; the store contract kit's row factory now carries a name. @cli / @client-core: a renderer that reads a background agent's name from ListAgents output or from the durable row now receives `agent-<12 hex>` for an unnamed agent where it used to receive the description (ListAgents) or nothing (the row); the description is still on the row. @test: an unnamed parked child's approve and deny both reach the resume (a decide answered 200 and the run continues), the row shows `agent-<12 hex>` from its first write, and the revived cycle's frames carry no `spawnName`.
315
+ - **Pins.** `test/backlog2450-bg-agent-name-mint.test.ts` — the derivation (form, determinism, admissibility against the spawn grammar and the reserved-name check, a non-handle refused); the registration (the handle and the row's first `put` carry the name, an explicit name verbatim, the same id the same name in another registry, the name resolves through the name layer, a legacy row healed by its revival registration); an unnamed child parked on a real Runner then claimed and driven (approve runs the action, deny does not, the revived cycle stays unnamed on every frame, an explicit name stays a spawn identity, a lookalike name is still one); ListAgents rows; SendMessage by the derived name through tier-3 revival and a revived row an earlier version wrote; a retained-session resume of an unnamed child (no `spawnName` on the resumed cycle's frames or notifications); the not-found footers keep an unnamed agent's task id (the id taken from the footer polls); the two interfaces' required `name` as compile-time anchors. Re-pinned to the new law: `agent-park-jian1-park-mint` (the anonymous child's row carries the derived name) and `agent-park-jian4-durableapproval-forwarding`; fixture rows in eleven store and registry pins carry the `name` the type now requires.
316
+
317
+ ### The self-kill guard asks where an environment DECLARES its commands run, not by the remote interface — a host lane is asked again, and `capabilities.sharesProcessTable` is required (#2602; P1 fix; hard-breaking — ruled final, not open to veto (C-R840 加注 2, clay); closed sets: none; exports: none) (@server @dgx @cli @test)
318
+
319
+ - **Before (7.56.0).** The pattern-kill guard (#2348) returned before reading anything for every env that implements the remote
320
+ seam (`isRemoteExecutionEnv`: the interface's members and a capability block). A server's default lane
321
+ (`RemoteHostExecutionEnv`, `provider: "host"`) is such an env and runs its commands as processes of this machine — the
322
+ engine's own process table — so on the product's default lane the guard asked nothing: a black-box run on core 7.56.0
323
+ measured 0 of the 42 kills it refuses on a `NodeExecutionEnv` refused there, and a `pkill -f <word>` whose word was in the
324
+ launcher's command line ended the session that issued it (tool exit 143, the session gone).
325
+ - **Now.** One declared fact: `RemoteExecutionEnv.capabilities.sharesProcessTable: boolean`, required — `true` when the
326
+ adapter's commands run on a table that holds this process (a host lane: they are its descendants; or a PID namespace that
327
+ contains the engine's, such as the host of the container it runs in), `false` only when they run where this process is not
328
+ (another machine, a sandbox container, a pod, a device). One strict reader (only the boolean counts; an env without the
329
+ remote seam reads as this table, which errs toward asking). The guard reads the command first and the declaration second:
330
+ this table ⇒ asked exactly as on a `NodeExecutionEnv` (the same refusals, the same sentences); another table ⇒ not asked
331
+ (what 7.56.0 did for every remote env); absent, not a boolean, or unreadable ⇒ every pattern kill on that env is refused
332
+ (`finding: "unavailable"`, the sentence names `capabilities.sharesProcessTable`) — `kill -9 -1` included — while `pkill -P $$`,
333
+ a kill of other literal pids and every command that is no pattern kill run as before, and the run's admission census
334
+ announces it once (`config.execution_env_capability_invalid`, `detail: { key: "sharesProcessTable", valueType }` —
335
+ `"undefined"` when absent, `"unreadable"` when its getter throws: the one capability key whose absence is announced,
336
+ because it has no default reading; a capability block that cannot be read at all is reported under `capabilities` as
337
+ before).
338
+ - **Hard-breaking (open to veto).** *What changed:* `RemoteExecutionEnv.capabilities` has a required member
339
+ `sharesProcessTable`; an adapter compiled against this version without it does not compile (`implements
340
+ RemoteExecutionEnv`), and one that reaches the engine without it (an untyped adapter, a build against 7.56.0) gets every
341
+ pattern kill refused and one admission notice per run. *Who is hurt:* every remote adapter — server's seven (`adb`,
342
+ `device`, `e2b`, `host`, `k8s`, `local-docker`, `ssh`) and an embedder's own. *How to migrate:* one line in each capability
343
+ block — `sharesProcessTable: true` for the host lane, `false` for the other six; an adapter whose target can be a table
344
+ that holds the engine (SSH to `localhost`, a container started in the host's PID namespace, a lane that executes on the
345
+ host of the engine's own container) declares `true` where its configuration makes that so.
346
+ - **Correction of 7.56.0.** Its note "the guard asks the question of THIS process's table, so it is not asked for a remote or
347
+ sandboxed environment (`isRemoteExecutionEnv`)" was true of the code and untrue of the product: the server's default lane is
348
+ remote-shaped and local, so the guard was absent there (KL-2348-d; exit = this version). KL-2348-b now states the declared
349
+ reach, and what a `true` does not cover: the question is the one a `NodeExecutionEnv` gets, its limits included (it is put
350
+ from the engine's own working directory), and it is asked as for this process's own descendants, so commands on the
351
+ same table that are not its descendants (SSH to `localhost`: BSD's `pgrep` leaves its own caller's ancestors out where the
352
+ real kill would not) and commands in an enclosing namespace (the question sees the engine's namespace only) are answered
353
+ as if they were.
354
+ - **Reference.** The reference implementation's guard is a shell function run in the command's own shell: it asks only when the
355
+ CLI's own pid is visible there (`/proc/$CLAUDE_PID/comm`) and compares the selection with that one pid (cli309.js:266254).
356
+ This engine compares the selection with its whole ancestor chain and decides where to ask by the adapter's declaration.
357
+ Not chosen: asking through the env (`env.exec`) — the chain holds pid 1 whenever the engine's ancestry reaches init, and an
358
+ engine in a container is itself a small pid, so on a sandbox lane in another PID namespace the answer would collide with
359
+ them and refuse ordinary kills (inferred from the code, not run).
360
+ - **Limits (stated).** Unchanged by this change and open: a command whose kill words hold a NUL byte makes the question's
361
+ `pgrep` fail to start, which reaches the tool as an error rather than as the `unavailable` refusal — the command does not
362
+ run either way (KL-2348-e). The `pkill -P $$` way out is read by the option walk that builds the question since #2604, so
363
+ one reader decides both. The undeclared-table notice is once per RUN, not once per deployment: a host that has not declared
364
+ it announces for the parent run and again for every sub-agent run it starts. An environment with no remote interface cannot
365
+ declare at all and reads as this table (KL-2348-b).
366
+ - **Consumers.** @server: the seven capability blocks gain `sharesProcessTable` (`host` true, the rest false); a server that
367
+ has not declared it gets the refusals above with this core. @dgx: the device lane declares `false`. @cli: the product's
368
+ default lane is guarded once the bundled server declares `true`; the refusal is a tool error carrying the sentence. @test:
369
+ core756r2's host-lane arm — expected 42 / 42 refused, with the `NodeExecutionEnv` reading, on a server that declares `true`,
370
+ and 42 / 42 refused as `unavailable`, naming the declaration, on one that does not (this engine's own pins measure the same
371
+ on a host-lane fixture; the black-box arm is not re-run here).
372
+ - **Pins.** `test/backlog2602-self-kill-guard-process-table.test.ts` (T1 46 commands: the same refusal on a declared host lane
373
+ as on a NodeExecutionEnv; T2 controls; T3 / T3b undeclared, malformed and unreadable declarations; T4 a declared other table
374
+ is not asked; T5 the reader; T6 the census; L-H1–L-H3 a child engine whose Bash runs through a host lane). Re-aimed:
375
+ `backlog2348` R5 (a declared other table is not asked; the remote interface alone decides nothing), `backlog787` (its three
376
+ keys; the fourth's absence is pinned here).
377
+
378
+ ### One judge of MCP name contention — `judgeMcpNames` answers a declared set's collisions (server names that mint one prefix; one server's tool names that mint one wire name), and the mount, the no-run probe, a listing's intake and a host that saves a set all ask it (#2568; no behaviour change; root exports +5)
379
+
380
+ - **Before.** The same fact was decided in three places, each wording it its own way: the mount minted its refusal sentence from a prefix primitive; the
381
+ no-run probe asked the primitive again and then borrowed the mount's throw; a listing's intake kept a private map of the wire names it had
382
+ minted. A host that assembles a declaration set (a console saving a server list) had no door: a collision was learned only when the next
383
+ run was refused.
384
+ - **Now.** `src/core/mcp-name-judge.ts` is the one judge. `judgeMcpNames(servers)` takes a list of `{ name, tools? }` (`McpServerSpec` fits as it is;
385
+ `tools` are the server's REMOTE tool names when the asker knows them) and answers `{ ok: true }` or EVERY contention as a row:
386
+ `server_name` (two server names minting one `mcp__<server>__` prefix — a name declared twice included) or `tool_name` (two remote tool names of one
387
+ server minting one wire name — one name listed twice included), each row carrying the two sources in declaration order and the name they
388
+ share. `describeMcpNameConflict(row)` is the sentence. The mount and the no-run probe refuse a set with a `server_name` row through one function
389
+ (`config.mcp_server_name_collision`, the first row's sentence, before any dial); the listing's intake asks the same claim ledger
390
+ (`createToolNameClaims`) and words each drop with the same sentence function (`mcpToolDropReason`). The prefix arithmetic answers both "is there a
391
+ collision" and "which names, all of them" (`findNamespacePrefixCollisions`; the single-answer form is its first row).
392
+ - **Behaviour.** No change. Every set the mount refused is refused with the same code and the same sentence; the probe answers a colliding set as the
393
+ mount does; every tool the intake dropped is dropped with the same sentence, the first spelling keeping the name. A battery of nine declared sets
394
+ (fold, a name thrice, control characters in names, a 200-character name twice, accented names, three names onto one prefix, two empty names,
395
+ a separator inside a name, a distinct pair) through the mount and the probe, and four listings through the intake, reads byte-identical on the
396
+ baseline and on this build.
397
+ - **Exports.** +5, none removed: `judgeMcpNames`, `describeMcpNameConflict`, `McpNameConflict`, `McpNameSetServer`, `McpNameVerdict`.
398
+ - **Consumers.** @server: ask `judgeMcpNames(servers)` where a declaration set is saved (`McpServerSpec[]` is accepted as it is; pass `tools` with the remote
399
+ names when you hold a listing) and show `describeMcpNameConflict(row)` — the sentence the run would be refused with. The engine refuses only a
400
+ `server_name` row; a `tool_name` row is what the listing discloses as a drop (first spelling kept), so what a host does with one is its own call.
401
+ A set that is not a list of `{ name: string, tools?: string[] }` is refused (`config.invalid_argument`), never read as clean. @client-core: the
402
+ collision answer for a whole declared set that was asked for (U5) is this call; nothing to change unless the editor wants to say it before submit.
403
+ - **What still stands (named, not closed).** The roster's `config.reserved_tool_name` refusal (a caller tool named like a mounted MCP tool or an engine-injected
404
+ resource face) compares names that exist only after a listing against the caller's own `TaskSpec.tools`; it is not a contention inside a declared MCP
405
+ set and stays at its site. An A2A peer set reads the same prefix arithmetic with its own code and sentence. The judge reads no `allowTools` /
406
+ `toolAxes` / `toolFaces` key: those are filters and declarations over names, not a list of the server's tools.
407
+ - **Pins.** `test/backlog2568-mcp-name-judge.test.ts` (P1–P4 the rows, N1–N3 the controls, V1 a malformed set, E1 / E2 the mount and the probe say the judge's
408
+ sentence and code, E2b a passing pair is not refused, E3 the intake drops exactly what the judge's tool rows name, S1 no second copy of the rule in the
409
+ mount / probe / intake source, S2 the root door).
410
+
411
+ ### The self-kill guard's way out — `pkill -P $$` — is read by the option walk that builds the question, not by a second reading of the raw words: a line that names the shell's pid once no longer runs unasked when another parent stands beside it, the selection is negated, or a word of the line cannot be read (#2604; security fix and behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none — one internal function and one internal type in `src/tools/fs/pattern-kill-guard.ts`, not index-exported) (@test)
412
+
413
+ - **Before.** The guard (#2348) does not ask `pgrep` about `pkill -P $$` — the children of the shell that runs the command hold no ancestor of the agent — and it decided that from the raw words with a reading of its own: any `-P` / `--parent` followed by the shell's pid word exempted the whole line, and a negation was known only as the full spelling `--inverse` or a bundled `-v`. The reading also ignored two facts about the words it was reading: a word the guard cannot read (a stream appended by `xargs`, an expansion that is not the shell's pid) could be another `-P 1` or a `-v`, and where the platform's options end at the first operand (BSD's / macOS's `getopt`) a `-P $$` after the pattern is a pattern, not an option. Lines of all four kinds therefore ran unasked and could reach the agent. `pkill -f WORD -P $$ -P 1`: BSD's `pkill` unions repeated `-P` lists (the shell's children plus the children of pid 1), procps' keeps the last one (the children of pid 1), so with the init process as the agent's parent the line selects it. `pkill --inver -P $$ x`: procps reads any unambiguous prefix of a long option (`--inver` is `--inverse`), and a negated selection is every process the other selectors do not name — the agent's ancestors included.
414
+ - **Now.** The one walk that already reads every option of `pkill` / `pgrep` for the question (the table of procps' long options with prefix resolution, bundles, attached and next-word values) also records every `-P` list in the order given and whether the selection is negated, and the way out is a predicate over that record: every parent the platform's reading of the `-P` options leaves standing (procps: the last list; BSD: all of them, as a union) is the shell's own pid word (`$$`, `${$}`: the special parameters, which a command can neither assign nor unset — `$BASHPID` is not one, below), nothing is negated, and every word of the line is known — a stream of arguments, or an expansion other than the shell's own pid (it expands to digits and can be no option), lifts the way out. Where the platform's options end at the first operand (the one platform constant `killall` already read, now shared), the words after the pattern are patterns, passed on as written and read as no option. The independent exemption reader and the independent negation reader are gone (two raw-word readings → none). A line the predicate does not accept is asked as written, or — while the shell's pid word is still in it — refused as decided when the command runs, as any word the shell expands is.
415
+ - **Behavior narrowing (open to veto).** *What changed*: `pkill -f WORD -P $$ -P 1` and `pkill -P $$ -P 1 x` (another `-P` list beside the shell's: any on BSD, one after it on procps — `pkill -P 1 -P $$ x`, whose last list is the shell's, still runs on procps) and `pkill --inver -P $$ x`, `--inve` / `--inv` / `--in`, `-vP $$ x`, `-fv -P $$ x` (a negation in a spelling the old reading missed), `pkill -P $$ "$X"`, `pkill -P $$ -f "$X"`, `pkill -P $$ x $(…)` and `echo … | xargs pkill -P $$ …` (a word the guard cannot read beside `-P $$`: any expansion but the shell's own pid — an unquoted glob, `~` or `{a,b}` word too — and any stream) and, on macOS, `pkill -af . -P $$` and `pkill -f x -P $$ -u root` (the `-P $$` stands after the pattern, so it is a pattern) were run and are refused now. **`$BASHPID` is no longer the shell's pid word**: `pkill -P $BASHPID x`, `-P ${BASHPID}` and `--parent=${BASHPID}` (run unasked in 7.56.0; `-xP$BASHPID`, refused there as computed, stays refused instead of joining the widened bundle spelling below) are refused as decided when the command runs, and so is a `$BASHPID` word beside a `-P $$` (`BASHPID=-v; pkill -P $$ $BASHPID x` negated the selection; run unasked in 7.56.0). It is an ordinary variable in the shells commands run under: `/bin/bash` 3.2 (macOS's, and the shell the Node environment prefers on POSIX) has no such special variable, so `BASHPID=<the parent's pid>; pkill -P $BASHPID -f WORD` selects the children of the parent — the outer shell among them — and bash 5 ends its special meaning after `unset BASHPID` (measured on 3.2.57 and 5.3.15: the assignment sticks on 3.2 and after `unset` on 5); only `$$` and `${$}`, which cannot be assigned or unset, are the shell's pid. *Who is hurt*: a model that wrote a second `-P` beside `-P $$`, a negation in a spelling the old reader missed, a variable or a pipe-fed word in a `pkill -P $$` line, `-P $$` after the pattern on macOS, or `-P $BASHPID` — the refusal says so and names the way out. *How to migrate*: drop the extra `-P` or the negation; write the pattern as a literal (`pkill -P $$ -f mypattern`), put `-P $$` before the pattern, and write `$$` (or `${$}`) where `$BASHPID` stood.
416
+ - **Reading widened to what the killer reads (open to veto).** `pkill --par $$ x` (an unambiguous prefix of `--parent`), `pkill -P $$,$$ x` (a list of shell words) and `pkill -xP $$ x` (the `-P` inside a bundle) were refused as computed; each is the shell's children and now runs unasked. A `-v` that is no switch is no longer read as a negation: the value of another option (`pkill -F -v -P $$ x`, `pkill -P $$ -u -v x`), a pattern after `--` (`pkill -P $$ -- -v`), and on macOS a word after the pattern (`pkill -P $$ x -v`, `pkill -P $$ x -P 1`: patterns, so the shell's children still bound the selection).
417
+ - **Limits (stated).** The procps arm is read off its source (the option loop assigns a repeated `-P` list; `split_list` splits at commas) and runs only on Linux; the macOS arm was measured with the real `pgrep` (a union of repeated `-P` lists; a word after the first operand is a pattern: `pgrep zsh -l` prints pids only); the cells load the module as each platform does, so both arms are pinned on every host, and what procps then answers is the source's, not a measurement here. A shell's pid written as a literal number cannot be known to the guard and is asked like any other pid.
418
+ - **Consumers.** @test: the `selfkill` fence's guard cells (`pkill -P $$` as the way out) keep their reading; two lines are worth adding — `pkill -f <word> -P $$ -P 1` and `pkill --inver -P $$ <word>` are refused on procps and macOS alike (the agent's parent held by pid 1 is the field case).
419
+ - **Pins.** `test/backlog2604-parent-list-reading.test.ts`: E (every spelling of the way out reads as nothing to ask, the widened spellings and the non-negations among them), A (another parent beside the shell's: refused), N (every spelling of a negation: refused), S (a stream or an expansion other than the shell's pid lifts the way out, and the refusal names that word), O (where the options end — the same line under the two platforms' grammars), Q (another pid asked as written), G (a repeated `-P` under the two platforms' grammars), and for `$BASHPID`: B (every spelling of it bounds nothing: refused as computed under both platforms' grammars, assigned or not), U (it beside a `-P $$` is a word the guard cannot read), K (`$$` / `${$}` still bound the selection); re-pinned: the `-P $BASHPID` cells of E and of the #2348 R2 moved to `${$}` and R3 now holds the refused `$BASHPID` lines (R7 reads the same).
420
+
421
+ ### A held message's decision is the record's own field in its box — one compare-and-set, read by every consumer, so a letter a person refused in one terminal is refused in all of them; `principalInboxConsumer: "terminals"` is accepted (#972b, design/972b slice S1, C-R864; hard-breaking; open to veto; closed sets: `MailboxDecision` new; exports: +13) (@server @cli @client-core @test)
422
+
423
+ - **What changed.** `MailboxMessage.decision` (seven words: `held` / `approved` / `delivered` / `denied` / `expired` / `refused`
424
+ / `dropped`) is the decision about a parked record a recipient's setting held. It is never appended; it moves only through the
425
+ new required `MailboxStore.moveDecision(scope, handle, seq, expect, next)` — a compare-and-set, atomic against every other move
426
+ and against `ack`, not fenced by the lease (a person answers from whichever terminal shows the card), refusing a malformed call
427
+ with `mailbox.invalid_decision_move` before any side effect. The decision law: absent → `held`; `held` → `approved` /
428
+ `delivered` / `denied` / `expired` / `refused`; `approved` → `delivered` or a final word; `delivered` → `dropped` only; a final
429
+ word never moves. Every lease snapshot carries the decision, so the drain reads it from the box: a final record is disposed of
430
+ (and never re-supplied) and the ack prefix may pass it; a record held or approved elsewhere is shown as a card here and the card
431
+ — the queue is the one writer of every move out of `held` / `approved` — refuses, releases or hands it over with the receipt
432
+ that move owes; a `delivered` record not yet acked is handed over again (at-least-once). Every hand-over of a held record is
433
+ itself the move to `delivered`, taken by a pass that holds that box's lease and can inject now; an answer that lands later
434
+ learns it came too late. `moveMailboxDecision` is the one caller-side reading of a move that faulted after committing.
435
+ - **The held queue is a presentation of the decision.** `PeerHeldQueue.hold` / `rejudge` / `settle` / `shutdown` return Promises
436
+ (the answer is the store's); `settle` answers `decided_elsewhere` when another consumer's move won first; `PeerHeldEntry.released`
437
+ is replaced by `decision` (`held` / `approved` / `delivered`); `PeerHeldSettlement` gains `retired` (the card left this process,
438
+ no decision was taken) and `PeerHeldSettleReason` gains `decided_elsewhere`; the sink gains `decide` (the store's move),
439
+ `tellsSender`, `canInject` and `onFault`; `reconcile` takes the snapshot's seq → decision map. `decisions()`,
440
+ `forgetDecisions()` and `PEER_HELD_DECISION_MEMORY` are retired: no decision is remembered beside the box any more. A
441
+ queue-local event — eviction at the cap, process shutdown, a hand-over the lane refused — is written to the record only from the
442
+ state it ends and only when its receipt reaches the sender (the session lane); an approved card is never overridden by it; on
443
+ the principal inbox it only takes the card out of this process and the letter stays held for the other terminals. At the cap the
444
+ oldest held card that tells its sender is evicted; a queue with none keeps the new record held in the box but uncarded
445
+ (`peer.inbound_disposition` says so).
446
+ - **`"terminals"` opens.** `principalInboxConsumerOf` accepts `"terminals"`; the `terminals_unsupported` refusal word is retired
447
+ (KL-972-a closed). A gate minting with no declared consumer is told to declare `"terminals"` or `{ assistant }`.
448
+ - **Behavior on the session lane.** A held message whose sender was told `expired` at a graceful shutdown (or at the cap) is no
449
+ longer re-judged and delivered by the session's next process; a person's deny, a deadline expiry and a policy refusal survive a
450
+ process restart; an approved, not yet delivered message survives a shutdown and is delivered by the next run (it used to expire
451
+ with the queue). A held message re-judged by a new process now sends its sender the `delivered` / `refused` receipt it is owed
452
+ (it heard only `held` before).
453
+ - **A refusal ends a card that was handed over but not yet injected.** A card whose hand-over move was taken while the
454
+ `ReadInbox` page budget deferred its injection answered `held` for ever once the policy then refused it, and the ack prefix
455
+ stopped at it (a later refused record never left the box). A refusing policy now ends such a card, at the re-judgment a
456
+ lease-holding pass runs, by the decision law's one exit out of `delivered` — the record moves to `dropped` and the sender,
457
+ where a reply address exists, gets the `dropped` receipt. A process that does not hold the lease (an approval answer between
458
+ passes, a deadline timer) writes nothing for such a card: it may only have adopted a `delivered` that another consumer already
459
+ injected. A held or approved card still ends as `refused` wherever the verdict is taken. A person's deny / cancel of a
460
+ handed-over card keeps its answer (`delivered`, too late): the card is injected on the next pass, so it is not stranded.
461
+ - **Hard-breaking, three sentences each.**
462
+ - *`MailboxStore.moveDecision` is required.* Every out-of-repo `MailboxStore` (the server's SQL twin) stops compiling against
463
+ 7.57.0 until it implements the method and persists the decision word with the record (`ack` / `reap` / `drop` remove it with
464
+ the record; `claimLease` returns it). Migrate: add a nullable decision column, implement the compare-and-set under the box's own
465
+ atomicity, refuse malformed calls with `assertMailboxDecisionMove` first, run `mailboxStoreContract` (two new cases pin the law
466
+ and the outcomes). @server `src/plugins/mailbox-store-sql.ts`.
467
+ - *The held queue face is asynchronous.* A host approval face that calls `settle` / `hold` / `rejudge` / `shutdown` and reads the
468
+ answer synchronously gets a Promise (no consumer found in the server / cli / client-core trees). Migrate: `await` each call,
469
+ read `entry.decision` instead of `entry.released`, and give a hand-built sink `decide` + `tellsSender`.
470
+ - *Mixed engine versions on one file box.* An engine before 7.57.0 skips `decision` events and can deliver what a newer one holds
471
+ or denied; its compaction erases them. Migrate: upgrade every terminal / replica that drains a box before declaring
472
+ `"terminals"` (deployment order @server @dgx).
473
+ - **Known limits written down.** KL-972-d (a long-held head blocks the ack prefix: committed records after it are delivered again
474
+ by the next consumer, and its deadline can keep restarting), KL-972-e (a crash between the move and its receipt leaves the
475
+ sender untold; a move retried after a fault can be read as won by two writers of the same word), KL-972-f (mixed versions; an
476
+ unknown word reads as `held` and can be overwritten), KL-972-g (`drop` restarts seq), KL-972-h (directly delivered records write
477
+ nothing).
478
+
479
+ ### A rule with no content is the whole-tool rule (hard-breaking, open to veto) — every surface reads a bare tool name the way the reference does: a stored `SendUserFile` allow / deny / ask reaches every `SendUserFile` call, and the card of a tool with no rule grammar offers it (#1949; closed sets: `PERSISTED_RULE_MATCHES` +1; grammar stamp 5 → 6; exports: +1 `adjudicatePersistedToolRules`) (@server @cli @dgx)
480
+
481
+ - **What changed.**
482
+ - The one validator (`parseRuleText`) reads a rule with no content — `Tool`, `Tool()`, `Tool(*)` — as the whole-tool rule
483
+ (`match: "tool"`, `command: ""`, its canonical text the bare name) on every tool and under every behavior, the shell's
484
+ included; it refused every bare name (`invalid.grammar`) before. A name for a set of tools (a protocol peer `mcp__srv`, a
485
+ glob) and a name the name judgment reads as naming no tool (a retired spelling such as `read_file`) stay refused, in the
486
+ judgment's own words. `PERSISTED_RULE_MATCHES` gains `"tool"`.
487
+ - The person's rule lane reads a whole-tool row for every call that answers to its name (the call's spelling, its wire name or
488
+ an alias it declares): the same precedence walk (deny > ask > allow) beside the command, path, name and host walks
489
+ (`adjudicatePersistedToolRules`, exported beside them). A list's whole-tool entry and a stored whole-tool row answer through
490
+ one predicate. A kept tightening row on a tool with no rule grammar whose text no grammar reads now reaches that tool's calls
491
+ in the fail-closed form; it reached nothing before.
492
+ - The approval card. The card of a call no rule word speaks for under any name it answers to (a host's own tool, an MCP tool)
493
+ offers the whole-tool rule — `{ kind: "single", rule: "<Tool>", match: "tool", command: "" }`, the reference's "don't ask
494
+ again for <Tool>". A card the rule lane is armed for is no longer silent: a path tool's, a shell-face sibling's and a tool's
495
+ that answers to one of those under an alias it declares (a caller's shell wearing `Bash` — a one-click yes to its whole tool
496
+ would be a licence for every program) names `ruleOffersAbsence: "lane_cannot_speak"`, and a closed door's word (`mandated`,
497
+ `allow_outranked`, `ask_rule_matched`) appears on those cards too. A card stays silent only where no rule speaks for the call:
498
+ no lane wired; an anonymous task in a deployment that declares no local owner; a content-routing tool the engine mounts
499
+ (question, answer, blocked report — the lane does not judge them). One function (`cardRuleFormOf`) decides the card for the
500
+ offer factory and for the consent door; the factory reads it with the call's names (a caller's tool wearing a shell-face alias such as
501
+ `Bash` is offered nothing), the door holds no face and reads the wire name alone (KL-1949-a).
502
+ - `prepareCardApproval` mints the whole-tool card for a tool with no rule grammar when `command` is `""` (the offer's own
503
+ `command`: the whole tool binds no value); any other value mints nothing. A card record whose candidate is a whole-tool rule
504
+ is read as damage unless it is that card (no command, a tool whose card is the whole-tool one), and a card's edit to a
505
+ whole-tool rule is refused in so many words.
506
+ - `prepareCcImport` reads every settings entry through the one validator (its own spelling screen is retired): a refused entry is
507
+ skipped with the validator's code and sentence; a bare tool name — now the whole-tool rule — stays in the settings layer that
508
+ carries it, skipped as a name-set item as before.
509
+ - `addPersistedRule` / `removePersistedRule` write and clear a bare deny or ask. In `auto` a stored whole-tool allow is stripped
510
+ before matching exactly when the list entry spelling it is (the shell's, and a name the call answers to the shell under).
511
+ - The organization layer still reads shell command rules only: a whole-tool row (`Bash` alone) stands at its strictest reading,
512
+ as every row it does not read; its code is now `unsupported.tool` (`invalid.grammar` before).
513
+ - The rule grammar stamp (`SESSION_RULES_GRAMMAR`, on every persisted bucket, sync state and session rules row) is 6.
514
+ - **Hard-breaking.** What changed: `PersistedRuleMatch` (and `PERSISTED_RULE_MATCHES`) gains `"tool"`; `parseRuleText` answers a
515
+ rule for a bare name; cards that were silent carry an offer or `ruleOffersAbsence`; the grammar stamp is 6, so an engine of
516
+ grammar 5 refuses a bucket, a sync state or a session rules row this engine writes, whole. Who is hurt: a consumer with an
517
+ exhaustive switch over `PersistedRuleMatch`; a host UI that reads "both seats absent" as "no standing-rule option"; a store
518
+ backend that keeps its own match list instead of reading `PERSISTED_RULE_MATCHES`; a rolling deployment where an older engine
519
+ reads what a newer one wrote. Migration: add the `"tool"` arm (render `rule` as written — the whole tool); render
520
+ `ruleOffersAbsence` on every card that carries it; prepare the whole-tool card with `command: ""`; upgrade the readers of a
521
+ rule store before its writers. The mixed-version symptom: an engine of grammar 5 meeting a grammar-6 bucket refuses it whole
522
+ (`PERMISSION_RULES_LATER_GRAMMAR_CODE`) — its run reads that person's stored rules as unreadable and fails closed (it asks or
523
+ refuses more, never less) until it is upgraded. Ruling C-R866 (open to veto).
524
+ - **Behavior change (open to veto).** A bare-name rule — refused by every store entrance before — is now written, read and cleared
525
+ as the reference reads it. Ruling C-R504 (a).
526
+ - @server: adopt `"tool"` in your transcription of the match words (or read `PERSISTED_RULE_MATCHES`); prepare the whole-tool card
527
+ with `command: ""`; S-1674 can consume the whole-tool offer; deploy order for the grammar stamp. @cli: L-1238 — render the
528
+ whole-tool option. @dgx: deploy order for the grammar stamp.
529
+
530
+ no-notes: #2575(the multi-package release-base pin now reads a fixture repository whose history it writes instead of the checkout's own tags and packages; a pin that was red on the v7.56.0 tag tree and on a depth-1 clone is green on both; test only; no script or export change), #1030(test fixture only: the 1751 pin's fixture body now reads the RC manifest through the one git seam; the seam itself shipped in 7.54.0), #2700(ticket named by the 2604-fix1 commit, not fixed here: Monitor skipping the kill guard is a 7.58.0 car)
531
+
532
+ ### A parked shell call binds where its reads landed — the resumed call whose read lands elsewhere is vetoed by the approval object's one judge, as a moved path target is (#1185, design/1185; hard-breaking, open to veto — a checkpoint store format forward change; behavior narrowing, open to veto; closed sets: requirement words +1 `approval_target_reads`; exports: +2 types `ApprovalTargetReads`, `ReadLanding`; `ApprovalObject` +`targetReads?` and `LiveApprovalObject` +`reads?`, both optional) (@server @dgx)
533
+
534
+ - **Before.** A tool with a path slot (Read, Write, a caller tool declaring one) was bound since 7.54.0: the row recorded where its target resolved when the card was asked and the execution station vetoed a move (`resume.target_moved`). A shell command was not: its row recorded nothing of where the shell read station found its reads, and the resumed leg's read belt judged the bytes on its own station, where an ordinary question on the unedited bytes is the card the person already answered. So `cat <link outside the root>` parked on its card, the link re-pointed, then approved, read the new target — the person saw one file named and the model received another; the same for an in-root link re-pointed outside while the call waited on another read's card.
535
+ - **Now.** The approval object's TARGETS have a second reading: per read candidate the shell read station judged for the parked bytes, where it landed (its key and its settled targets, each as a sha256 digest — a pattern lands on a set). The park mints it from the station's own judgment of the call (the one its pre-spawn admission re-establishes), beside the path slot's roles, through the one mint (`approvalTargetsOf`); the execution station reads the resumed leg's station's judgment of the bytes that will run — the belt's, a few lines earlier — through the same mint, and the one judge (`judgeApprovalObject`) compares. The member is written whenever the station judged the parked bytes — `{}` when it found no read candidate, so a read that appears by the resume is one the card did not cover. A command read differently on the resuming leg (a read the card covered is not read, or a read it did not cover is — the same read in another segment of the command is one the card did not cover, a read's key carries its segment), a read that lands elsewhere (a re-pointed link, a pattern that matches one more entry or one fewer, a read the station could not place then and places now), no judgment on the resuming leg, or a damaged member ⇒ the call is vetoed: not run, the approval spent, `resume.target_moved` on its `tool_end`, the sentence saying which (a moved read is named with where it lands now); the model re-issues it and the new card is bound to the current landing. Not judged, as before, when a person edited the call to other bytes (the belt's `rejudge` seat judges an edit); judged when the decided bytes are the parked ones handed back, and when a PreToolUse hook rewrote them (their reads are not the card's). The in-leg pre-spawn check and the row read one keying and one landing (`readLandingsOf`, `shell-read-identity.ts`).
536
+ - **Hard-breaking (open to veto) — store format forward change.** *What changed*: a parked shell call's `pendingAction` carries a new member `targetReads` (a record of 64-hex → 64-hex) and the reader-requirement word `approval_target_reads`. *Who is hurt*: a worker of v7.56.0 or older that meets such a row refuses it `version_newer` (the row stays pending — it is not replayed unbound); a store with a member allow-list for `pendingAction`, or a column per member. *How to migrate*: upgrade every worker that resumes parked approvals before any worker that parks them (a mixed fleet otherwise leaves shell rows pending until an upgraded worker takes them); a JSON-document store needs no migration; add `targetReads` wherever `pendingAction` members are enumerated. Rows parked before this version carry no `targetReads` and resume as before.
537
+ - **Behavior narrowing (open to veto).** A parked shell call whose reads moved while it waited is vetoed and asked again (once per move), where it ran. A PreToolUse hook on the resumed leg that rewrites a parked shell command so that a read the card did not cover is read — another read, or the same read in another segment (a prefix segment added) — is vetoed (its in-root rewrite ran before); a hook that is not idempotent (it adds the prefix again on the resumed leg) is vetoed at every resume (KL-1185-e, #2702). A pattern read over a directory that gains or loses an entry while the call waits is a move.
538
+ - **Not bound (stated; KL-1391-a, KL-1185-a … -f).** The binding stops drift between the card and the resumed run; a command the agent itself arranges to move later is the read-then-run window, which is not closed (between the pre-spawn check and the child's own open). A landing is a LOCATION: a file replaced at the same location, a hard link or a bind mount swapped in at the same path is not a move. Every read candidate is bound, the ones the station vouched for too (the in-leg check's reading): a park over a pattern in the workspace is vetoed once when the workspace gains a matching file before the approval. An engine version that plans the same command differently vetoes in-flight parked shell approvals once, worded as a re-plan. An isolated sandbox's establishment is lexical, so moves there are not seen. A station that holds more than 1024 judged-not-spawned calls on one leg forgets the oldest: a call parked past that bound carries no read member and is not judged on it. A store whose encoding changes the parked arguments (the filed bytes are not the judged ones) gets neither target member, as the path roles did before. On Linux, a read through `/proc/self/…` lands on the resolving process's own entry, so a resume in another process reads it as a move and vetoes (each time it resumes in a new process).
539
+ - **Consumers.** @server: add `targetReads` to the `pendingAction` keyset your trace guard enumerates; the SQL store holds the row as a JSON document (no migration); deployment order as above. @dgx: the same deployment-order sentence. @cli / @client-core: nothing new to render — the veto is today's `resume.target_moved` on the vetoed call's `tool_end`.
540
+ - **Pins.** `test/backlog1391-approval-object.test.ts`, block `#1185` — real Runner, zero path rules: SH-r (a link outside the root re-pointed; the re-issued call parks bound to the new landing), SH-i (an in-root link re-pointed outside while the call waited on another read's card), SH-hb (the parked command handed back), SH-g (a pattern gains an entry), SH-h (a hook rewrites to an in-root read), SH-e (a person's edit runs), SH-d (damage), SH-8 (control, a link and a pattern); SH-0 and units: the judge's read arms (an empty record, a re-plan, both readings on one row), the mint, the requirement word. SH-r / SH-i / SH-hb / SH-g / SH-h / SH-d red on v7.56.0's tree. The park self-check row carries both target members, so a store that drops either fails at wiring.
541
+
542
+ ## 7.56.0 — 2026-10-23
543
+
544
+ ### One writer per fact on a permission decision — a policy's or a hook's decision that states a word the gate writes is refused by name, and the integrity guards and a fold's collapsed classes ride the engine's attestation record alone (#2134 / #1617 / #1329; hard-breaking, open to veto; security narrowing; closed sets: `RETIRED_DECISION_MEMBERS` +2 (`guards`, `foldedAskClasses`); exports: `PermissionResult`'s ask arm loses `guards` and `foldedAskClasses`, `EngineAttestation` gains an optional `guards`, `AskClassFacts` (the facts `classifyAsk` takes) gains a required `call`) (@server @test @cli @client-core)
545
+
546
+ - **Before.** A decision a policy or a PreToolUse hook returned could carry the words the gate itself writes after it answers — `origin`, `askClass`, `classes`, `mandate`, `allowRuleShadowed`, `ruleEvidence`, `segmentCoverage`. The receipt took them (each held to its closed set), the class derivation did not read them, and the gate's stamp replaced them: `askClass: "cross_boundary"` on an ask without `crossBoundaryMandated` was derived as an ordinary ask and released under `bypassPermissions`, and a `segmentCoverage` the lane did not overwrite was kept. The integrity guards (`guards`: a protected removal, an unverifiable delete or shell text, the session transcript, another person's shared artifact) were a member any policy could write, and they count as explainers of the real-approval bit — a policy writing `guards: ["transcript_write"]` beside `requiresRealApproval: true` moved its own question out of the bypass-immune `policy_rra` class into one `bypassPermissions` releases. A fold's collapsed classes stood twice, as the member `foldedAskClasses` and as `attestation.foldedClasses`; the receipt compared the two by the record's brand only, and the class derivation read the member — so a fold record handed back for another call (which reads as absent for this one) still added its classes.
547
+ - **Now.** One table says who writes each member (`DECISION_MEMBER_WRITERS`, beside `DECISION_MEMBER_ARMS`, total over the decision union): the composer states its own question (`action`, `message`, `updatedInput`, `decisionReason`, `requiresRealApproval`, `mandated`, `authoredBy`, `matchedAskRule`, `probeMandated`, `crossBoundaryMandated`, `denialLimitFallback`, `classifierNoRuling`, `ruleUnreadable`, the `probe*` display members, `editRewritten`); the gate's stamp writes `origin` / `askClass` / `classes` / `mandate` / `allowRuleShadowed` / `ruleEvidence` / `segmentCoverage` — a decision that states one is refused by name at the receipt (fail-closed deny, the member named), on the policy seam and the hook seam alike; the engine's record (`attestation`) carries what only an engine station knows. `guards` and `foldedAskClasses` are retired names: the guard policies, the read station's silence and the artifact tool's consent attest their guard on the call's record (`attest(…, call, { guards })`), `combinePolicies` and the gate's two own writers attest the collapsed classes, and the class derivation reads both off `attestationOf(decision, call)` once — the call is a required member of the facts it is handed; an ask whose record belongs to another call is refused by the receipt that decides this one. A spread of a fold's answer keeps its record, as before.
548
+ - **Hard-breaking (open to veto).** *What changed*: a decision carrying `origin`, `askClass`, `classes`, `mandate`, `allowRuleShadowed`, `ruleEvidence` or `segmentCoverage` is refused (the call does not run); `guards` / `foldedAskClasses` are refused by name and left `PermissionResult`; `classifyAsk`'s facts require `call: { toolCallId, toolName }`. *Who is hurt*: a host policy or hook that puts any of these on its answer (TS hosts see `guards` / `foldedAskClasses` as compile errors; a JS host gets the refusal), a host that reads `decision.foldedAskClasses` or writes `guards`, and a host calling the exported `classifyAsk` with facts that name no call. A policy that caches an ASK answer across calls and returns it for another call id has that call refused. *How to migrate*: drop the members; ask again rather than relay a cached ask; to make a person answer, state `requiresRealApproval: true` (class `policy_rra`); to get a guard's question, install the engine's guard policy (`createUnverifiableDeletePolicy`, `createTranscriptIntegrityPolicy`); read a fold's classes off the `AskRequest` / `GateDisposition` `classes` the gate writes; pass the call the facts are about to `classifyAsk`.
549
+ - **Behavior narrowing (open to veto).** A self-declared stamp word was silently replaced and the call went on; it is now refused. Measured on a real Runner, previous stage head vs this one: `askClass: "cross_boundary"` under `bypassPermissions` ran and is refused; an ancestor ask stating `origin: "org_rule"` was released under the engine's word and is refused; a host's `guards: ["artifact_shared_read"]` beside the real-approval bit was released under bypass and is refused; `mandated: true` with a `mandate` word beside it was put to a person as `operator_forced` and is refused (`mandated: true` alone still is put to a person); a policy's `segmentCoverage` was received and is refused. An ask carrying the engine's record of another call — a fold's answer a policy cached or relayed from another call id — is refused where the call it decides is received (the gate, the fold's layers, the inherited stations, the approved-edit, park and synthetic-read re-judgments): its recorded findings are the other call's question, and reading them as absent would drop them (its card read `hook` before; read as absent it would have been released under bypass). An ALLOW carrying another call's record still reads it as absent (it settles nothing here), as before.
550
+ - **What still stands (named, not closed).** A policy can still obtain a genuine guard record for its call by running an exported guard policy itself — with its own configuration — and attach it to another ask of the same call: the record is bound to the call (`toolCallId` + `toolName`), not to the bit it explains, so the guard can still explain that ask's real-approval bit away (the former one-word route needed none of that). The second explainer rule stands too: a bare `mandated: true` reads `operator_forced` only when no other class is present, so adding `probeMandated` or `decisionReason: "mode"` to it yields a class bypass releases. Both close with the one design that makes the composer's statements a class of their own that no other class explains away (filed for 7.57.0; `docs/KNOWN-LIMITS.md` KL-1111i).
551
+ - **Consumers.** @server: `operator-ask.ts` / `runtime-governance.ts` already hand the fold's answer back as it is and stamp `authoredBy` on the leaves — no code change; the S-958 notes saying a copy of the fold's answer is refused "by object identity" have been untrue since 7.49.0 (the record rides the copy) and stay untrue. @test: a black-box cell per stamp word and per retired name (refused, the member named, nothing run) and the KL-1111i form (a host's `guards` beside the real-approval bit under bypass: refused). @cli / @client-core: the `AskRequest` and park-row `origin` / `askClass` / `classes` are unchanged (the stamp still writes them).
552
+ - **Pins.** `test/backlog1329b-one-writer.test.ts`; re-pinned to the one carrier: `backlog1266` (the fold's classes on the record; the retired member refused), `backlog1231` (the stamp words refused whatever their value; ⑤ a self-reported `origin` / `mandate` refused on both lanes), `backlog1214` G2, `backlog1293b-*`, `backlog595` #33, `backlog564`, `design469-r3-boundary-legs` (the #2134 cell), and the guard fixtures across the arbitration pins, which attest their guard for the call (`test/helpers/engine-record.ts`).
553
+
554
+ ### The role base has one source chain — the task, then the requested model role's preset, then an engine spawner's child persona — and the first source that names a role base decides it wholly; a role preset and the delegation tool can name the catalog's role base by word (`promptRole`), at the same position as the text it replaces (#2235; behavior change, open to veto; hard-breaking: two (the task's word now beats a role preset's text; `CODE_ROLE` changes shape); closed sets: none; exports: +1 member `RoleSpec.promptRole?`, +1 member `SubagentToolOptions.promptRole?`, `CODE_ROLE` value reshaped; no new index names) (@server @dgx @test)
555
+
556
+ - **Before.** The role layer had two independent readings. The text — `TaskSpec.systemPrompt ?? <role preset>.systemPrompt ?? <the delegated child's default persona>` — replaced `core/role.base`; the host role — `TaskSpec.promptRole ?? "general"` — selected its catalog variant. Any text anywhere beat the task's word, so a host that wanted the catalog's coding base on a role preset, in `runDeveloperTask`, or for the code bundle's children could only paste the catalog's text (`CODE_AGENT_PROMPT`) into a text seat, bypassing the selector — and a task writing `promptRole: "cowork"` on a deployment whose default role carried coding text got host role `cowork` with the coding text. `runDeveloperTask` and the code bundle ran with host role `general` while sending the coding base.
557
+ - **Now.** One decision (`src/prompt-assembly/role-base.ts`, read by the request-body sugar): the first of the task, the requested role's preset and the engine's child default that names a role base decides it — a word (`promptRole`) is the run's host role and selects the catalog variant (no overlay); a text replaces the base; a source naming both gives the host role by the word and the base by the text (the `TaskSpec` reading, now every seat's). `RoleSpec.promptRole?` and `SubagentToolOptions.promptRole?` are new seats with `TaskSpec.promptRole`'s closed set and door (`config.prompt_selector_invalid`, naming `roles.<role>.promptRole` / `SubagentToolOptions.promptRole`; the tool option is judged when the tool is built). `runDeveloperTask`, the code bundle's children and `CODE_ROLE` write the word on their own seat when that seat names no role base. A `TaskSpec.roles` override that names a role base replaces the deployment's statement whole (an override's word is never joined to the base's text). The workflow lane writes the requested role's preset onto its child's seat by the role resolution's own reading (`presetRoleBaseOf`: the merged maps' chain-head role — it no longer reads the raw `roles.subagent` past a child's own `roles` or explicit `modelRole`; the lane reads and the child's prepare judges, so a preset that read refuses lifts nothing and the child's own prepare refuses it on the run's failure path, never a throw at the launch site). The side query reads the same decision over its own text and the preset; a word preset is rendered by the one role-base renderer (the Cowork base renders only its unconditional sections there — a side query has no workspace and no mounted tools). Every role map is judged whole at the config doors, so a bad word on an entry no task selects refuses, and a resume refuses it before the claim (`checkpoint.config_refused`, `field: "deps"` / `"taskConfig"`). The delegation and workflow spawn reviews state a word role base as `args.promptRole` instead of sampling the engine's own catalog text.
558
+ - **Hard-breaking (open to veto) — the task's word now beats a role preset's text.** What changed: with `TaskSpec.promptRole` written and the requested role's `RoleSpec.systemPrompt` set, the base was the preset's text (host role still the task's word); it is now the variant the task's word selects. Who is hurt: a host that sets a role preset's text AND writes `promptRole` on tasks and relied on the preset's text winning — e.g. a deployment that attaches coding text to `roles.default` (`MODEL_CODE_ROLES=default`) and serves a `promptRole: "cowork"` scenario: those runs now get the Cowork base. How to migrate: to keep the preset's text, leave `promptRole` off those tasks or write the text on the task's own `systemPrompt`. This is the reading the selector's "task-level `promptRole` unchanged" statement now has: the key and its closed set are unchanged; its order against a preset's text changed to remove the mismatch.
559
+ - **Hard-breaking (open to veto) — `CODE_ROLE` is `{ promptRole: "code", thinking: "high" }`** (was `{ systemPrompt: CODE_AGENT_PROMPT, thinking: "high" }`). Who is hurt: code that reads `CODE_ROLE.systemPrompt` for the coding text. How to migrate: read `CODE_AGENT_PROMPT`; to preset a role, use `CODE_ROLE` as is.
560
+ - **Other behavior changes.** `runDeveloperTask` and the code bundle's children: host role `code` (was `general`), the `core/role.base` section's variant `code` and chain `bundled` (was a task `replace`); the bytes are the same unless an extension conditions on the host role (`when.host.role: "code"` overlays and `prompt.compose` handlers reading `e.selectors.host.role` now apply to these runs — the host role is true now). A spec that writes `systemPrompt: undefined` explicitly reads as naming no role base (it used to suppress `runDeveloperTask`'s default through the spread). These runs record one prompt change after upgrade (the task layer and the selector record change); the provider cache is keyed by the sent bytes and is not affected by that alone. A workflow child with an explicit non-subagent `modelRole` no longer takes the `roles.subagent` preset.
561
+ - **Consumers.** @server: `MODEL_CODE_ROLES` can write `roles[r].promptRole = "code"` instead of pasting `CODE_AGENT_PROMPT` (the leader legs, the side query and engine-built subagent children all read the same seat); the cowork-scenario change in the hard-breaking item above is yours to check; the new refusal seats (`roles.<role>.promptRole`, deployment-configured — not reachable from a request) carry `config.prompt_selector_invalid`. @dgx: a library host may name a role base by word on a role preset; nothing else changes for you here. @test: the black-box cells are in the design (role preset word ⇒ manifest `selectors.host.role` and the role section's `variant`; cowork task word over a coding text preset; a `roles.subagent` word reaching a delegated child).
562
+ - **Pins.** `test/role-base-source-chain.test.ts` — real lanes (a Runner's prepare, the delegation tool over a Runner, the workflow launch site, the side query, the resume screen): the task word over a preset text, the preset word, both on one seat, the unit merge, the door on four bad values / an unread entry / the resume screen / the tool option, the delegated child under a `roles.subagent` word and under the tool option's word, the definition over the option, `runDeveloperTask` (default, task word, a host-role-conditioned overlay), the workflow lift (and a child whose requested role is not the subagent role), the side query's three forms.
563
+
564
+ ### A resume's CAS carries the deployment's claim holder, and the store judges it in the same atomic step — a run whose claim was taken over applies no decision (#2119; security fix and behavior narrowing for callers that pass a holder, open to veto; hard-breaking: none; closed sets: +1 `CheckpointError.code` word `resume.holder_superseded` (the `resume.*` family); exports: +2 root names `checkpointHolderMatches` (function) and `HolderJudge` (type); new optional members `ResolveExpectation.holder`, `CheckpointStore.fence`, `RunInternals.holder`, `RunWorkflowOptions.holder`, the in-tree stores' `holderIsCurrent` option and the store-contract kit's `holders` option) (@server)
565
+
566
+ - **Before.** A deployment that accepts a person's decision under its session claim hands it to the next run it starts on that session (`RunInternals.workflowParkedResume`, or a top-level `resumeStream`); the run's resume wins the checkpoint's CAS with it. The CAS judged the row (token, scope, pending) and its generation (`expect.rev`) — nothing about who was resuming. A run that lost its claim after the decision was written (a stall past the deployment's staleness bound) and a run that took the claim over and accepted a newer decision both raced the same CAS, and the first one won: measured on a real Runner, a superseded run's allow executed the action, and a superseded run's deny landed first so the person's newer allow never ran; a Workflow drive under a superseded claim drove the parked child. No transaction of the deployment's own could close it — the apply is the engine's CAS.
567
+ - **Now.** One precondition object, judged whole in the store's atomic step. `ResolveExpectation` gains `holder` — the deployment's credential for the claim the resolving run acts under (opaque; the engine never judges, stores or prints it). A resume carries the one it was handed (`RunInternals.holder`), and every Workflow drive a run makes carries that run's (`RunWorkflowOptions.holder`, threaded by the Workflow tool; spawned children do not inherit it). A store that declares `fence = { holder: true }` lets the CAS win only while that grant is current — the in-tree `InMemoryCheckpointStore` / `FileCheckpointStore` take a synchronous `holderIsCurrent` judge (called inside the CAS's step, before the commit; with it they declare the fence), through one exported predicate `checkpointHolderMatches` (a holder with no judge loses — never ignored; the judge's answer is current only when it is exactly `true`, and an answer that is not a boolean — the promise an async judge returns included — throws `config.invalid_checkpoint_store` with the row untouched, never read by its truthiness nor as a lost grant). A provided `RunInternals.holder` that is empty or not a string is refused at the door (`config.invalid_argument`, at the deployment's seat). A lost CAS whose row is still the one the resume validated (same scope, pending, same generation) is `resume.holder_superseded`: nothing applied, the row unchanged by that resume, do not retry it — the reading the reference implementation's remote worker gives a superseded worker epoch (refused at the write, then it stops). A Workflow drive refused this way re-parks its ordinal and the run suspends, as any refused drive does. A holder handed to a run whose store does not declare the fence is refused at the store seat (`config.invalid_checkpoint_store`; on a resume, before the claim, by the resume's one refusal channel) — a holder the store would ignore would fence nothing.
568
+ - **Reach, stated.** The precondition belongs to the decision-bearing verb: `expire` carries no expectation (as today, not even `rev`), so a destroy or a preflight-terminal settle is not fenced; the background lane's parked drive answers to its own reservation ticket, not a session claim (that lane's documented gap between its seat check and the CAS is the same law's next consumer). A store without `claimTerminal` classifies the loss from its diagnostic read (a later instant): under a race the row's later state answers instead (`checkpoint.reopened_concurrently` / `checkpoint.already_resolved`), fail-closed either way. The in-tree judge closes the race only when the claim's transfers happen in the same process, synchronously; a claim another process can transfer needs a store whose CAS and claim table share one transaction. A run that won while holding and loses its claim mid-execution finishes that one action (the decision was valid when it was applied). The refused-drive run-log sentence now says "refused before this run consumed the checkpoint … the park stands on this run's record" (it was untrue for a lost CAS), and its quoted reason replaces the claim holder by `<holder>` as it already replaced the checkpoint token by `<token>` (a judge's or a store's error may quote either). A workflow run reads its drives' trusted inputs — the decisions and the holder — once at its start, so a caller that edits or reuses its options object afterwards changes neither.
569
+ - **Behavior narrowing (open to veto).** Only for a caller that passes a holder: a resume under a superseded holder, which used to win, is refused; a holder over a store that cannot judge it, which used to be ignored, is refused at the store seat. Without a holder every path is byte-identical. Hard-breaking: none. A consumer switching exhaustively over `CheckpointError.code` sees one more `resume.*` word; the `claimTerminal` loss answer's `status: "pending"` now has two arms (a moved `rev` = the OCC arm, the caller's own `rev` = the holder arm).
570
+ - **Consumers.** @server (S-1869, 7.135.0 pickup): pass `RunInternals.holder` on every run started or resumed while holding a session claim, minted per grant and passed only after the grant commits; the SQL store declares `fence = { holder: true }` and adds the holder predicate to the same `UPDATE` (the claim row locked in share mode, in an order consistent with the reaper's) — `claimTerminal` recommended for the exact loss word; the local lane hands its in-process claim reader to `FileCheckpointStore` as `holderIsCurrent` (and its wrapper forwards `fence`); map `resume.holder_superseded` on a top-level resume to "superseded: stop, do not retry"; retire the known-boundary sentence on runs that lose the claim after the write. The store-contract kit's holder case runs for a store that declares the fence, given the binding's `holders` set.
571
+ - **Pins.** `test/backlog2119-resume-holder-fence.test.ts`: the store law on both in-tree stores (claim route and two-step route; current / released / no judge / throwing judge / no holder), the kit's holder case bound to both judged stores, the resume lane on a real Runner (released-then-current, current-then-released, a holder over a store without the fence, a fresh run's prepare door, the no-holder control), the loss reading cell by cell on a scripted two-step store (the expectation's shape, same row at the validated rev, a moved rev, another scope, a consumed or missing row), and the Workflow drives (the Workflow tool's three legs, `runWorkflow` on both spawn lanes).
572
+
573
+ ### A pattern kill that selects the agent's own process or a process above it is refused before it runs — `pkill`, `killall` and a `kill` fed by `pgrep` / `pidof` put the same question to the real `pgrep` first, in every permission mode (#2348; security hardening and behavior narrowing, open to veto; hard-breaking: none; closed sets: +1 member `bash_self_kill_refused` in the Bash tool's card vocabulary (`engineCardTypes()`, and `ENGINE_CARDS` for the module that mints it); exports: +2 internal names in `src/tools/fs/pattern-kill-guard.ts` (`patternKillRefusal`, `patternKillReadings`) and the type `PatternKillReading`, not index-exported) (@test @cli @server)
574
+
575
+ - **Before.** With the permission mode bypassed (or after a person allowed the call), a model's `pkill -f <word>` whose word is also in the command line of a process above the agent — the shell a harness started the agent under (`bash -c "…; sema -p '<prompt with the word>' | tee log"`), a CI wrapper that carries the prompt in its argv — selected that process and terminated it (exit 143 for the user's script). The gate had judged the command ordinary and nothing else asked: no refusal, no sentence. The reference implementation's Bash tool refuses this at its shell (a `pkill` shell function runs `pgrep` with the same selectors first and refuses when its output holds the CLI's own pid: "this pattern matches the Claude CLI process … Narrow the pattern, or target your own children with `pkill -P $$ ...`"). Measured on procps (Linux); on BSD's `pkill` (macOS) the same command leaves the caller and its ancestors out unless `-a` is given, and `killall NAME` selects them there too.
576
+ - **Now.** One guard at the Bash tool's execution door (`createBashTool`'s `execute`, beside the read station's admission and ahead of the foreground / `run_in_background` split, so every mode reaches it — it is self-preservation, not an approval, and nothing answers it). It reads the command with the plan the read station plans (the one program-position reader: launchers, absolute paths, `nohup` / `env` / `timeout`, nested `bash -c` text and substitution bodies included) and finds every `pkill`, every `killall`, and every `pgrep` / `pidof` in a command that also runs a `kill` (`kill $(pgrep -f X)`, `pgrep -f X | xargs kill`, a loop over `$(pgrep …)`). For each it puts ONE question to the platform's real `pgrep`, with the selectors the killer would select by (signal words and the switches that only change the output — a count, the signal echo, the delimiter — are left out, bundled or not, because a count would be read as a pid; a signal word is `-<digits>` or `-` plus a name of the signal table (`-KILL`, `-SIGKILL`, `-USR1`, `-RTMIN+3`, in either letter case), never a word by its letter case alone; `-f`, `-x`, `-u`, `-P`, `-v`, the pattern pass through, and so does a value attached to its switch (`-U1000`, `-P1`, `-G0`, `-F/tmp/x.pid`); a `killall` / `pidof` name is asked as an exact, escaped process name, `-u` / `-I` / `-r` narrowing it as `pgrep` narrows, and `killall -u USER` alone is every process of the user), through the scrubbed environment, never a shell. A `pgrep` / `pidof` is a killer only when a `kill` in the same command takes its operands from somewhere (`kill $(pgrep …)`, `… | xargs kill`, a loop or a variable, a `kill "$@"` in a nested shell); a `kill` of literal pids beside a `pgrep` is not fed by it. It reads this process's pid and every parent (`/proc` on Linux, `ps` elsewhere). A selection that holds this process or any of its parents is refused before anything is spawned — a tool error (`isError: true`) whose sentence names the command, the pid and whether it is the agent's own process or one above it, and the way out in the reference's words (narrow the pattern; stop the process by its PID; target your own children with `pkill -P $$ ...`). `details` = `{ type: "bash_self_kill_refused", code: "bash_self_kill_refused", finding: "hit", pid, relation: "engine" | "ancestor" }`.
577
+ - **Two more refusals, one law (the question must be askable).** A selector the shell decides only when the command runs (`pkill -f "$X"`, `pkill -f $(cat pat)`, a `pkill` whose arguments arrive on a stream, `kill $(pgrep -f "$X")`) cannot be put to `pgrep` first: refused with `finding: "computed"` (a value the plan settles is asked as written: `X=word; pkill -f "$X"`). A question the platform cannot answer (no `pgrep`, no readable process table, a timeout) is refused with `finding: "unavailable"` and the reason, never silently run. Two forms whose targets this question cannot list are refused the same way (`finding: "computed"`, the reason says so): `killall -g` (it signals the whole process group of each match) and a negated selection with `-P $$` (`-v` / `--inverse` reaches everything the other selectors do not name). The one exemption is the way out itself: a `-P` / `--parent` selector whose value is the running shell's own pid (`pkill -P $$ …`, `-P $BASHPID`), not negated, selects that shell's children and nothing above, so it is not asked.
578
+ - **Options are read as one grammar, a signal word is what a table names (follow-up; security fix and behavior narrowing, open to veto; hard-breaking: none).** Before this follow-up `killall`'s words went through the `pgrep`-shaped pre-pass, which does not know `killall`'s options: a value attached to its switch (`killall -uUSER`), a long option with `=` (`killall --user=USER`) and a bundle (`killall -Iu USER`) selected nothing or read USER as a process name, so the line ran unasked; and every word of `-` plus upper-case letters and digits was dropped as a signal, which also dropped `pkill -U1000`, `-P1` and `-G0` (no question at all) and `-U1000` from `pkill -x -U1000 node`. Now ONE table lists every option of `killall` (the procps and the BSD spellings) with what it does to the selection, and ONE reader walks it: a bundle is split (`-Iu USER` → `-I -u USER`), a value attached to its switch (`-uUSER`, `-sTERM`, `-o5m`) or to a long option (`--user=USER`) is that option's value exactly as the next word would be, and every word after `--` is a name (put to `pgrep` after its own `--` when it begins with `-`, so `killall -- -zsh` asks for the process named `-zsh`). An option the table does not list — an unknown switch, an abbreviated long option (`--us=USER`), a value given to a switch that takes none (`--quiet=1`) — selects an unknown set and is refused with `finding: "computed"` and the reason, as `-g` is (spell the option in full, or stop the process by its PID). The table carries, per option, the `pgrep` switch that says the same: `-u USER` and `-t TTY` select as `pgrep -u` / `pgrep -t` do (alone: every process of the user / tty; beside names: a narrowing); `-Z CONTEXT` has no such switch, so beside a name, user or tty it only narrows, and alone it is refused as an unaskable selection. A `kill` of the pid that means every process (`kill -9 -1`, `kill -- -1`, `kill -s TERM -1`, after any spelling of the signal) holds this agent by construction and is refused as a hit without a question to `pgrep` (minus one in any numeral spelling: `-1`, `-01`); a signal word alone (`kill -1`) and literal pids are as before. Where `killall`'s options end is the platform's, and the reader follows it with one platform constant (beside the one for `pgrep`'s ancestors): procps permutes the words, so an option may follow a name; BSD's (macOS) getopt stops at the first name, so `killall node -unobody` there names the processes `node` and `-unobody`. BSD's `-c PROCNAME` is a name every selected process must also carry: alone it is the name asked, beside names it is one more name asked, and the last one counts (the two sets are not intersected by comparing spellings: the platform compares the `-c` name with a process's name over its first sixteen bytes only, so `killall -c <B> <A>` with A and B sharing their first sixteen bytes selects the process named A — measured on macOS — and a name that no given name equals is no proof that nothing is selected; either name selecting this agent or a process above it is refused). A signal word is `-<digits>` or `-` plus a name of ONE signal table (`-KILL`, `-SIGKILL`, `-USR1`, `-RTMIN+3`, in either letter case — BSD's `pkill` and `killall` read `-term` as SIGTERM and `-io` as SIGIO, measured), shared by the `pkill` and the `killall` readers: `killall` drops it wherever it stands, `pkill` takes ONE, where its platform takes it — procps' the first word that spells a signal, anywhere (`pkill -f -9 WORD` asks `pgrep -f WORD`; a second signal-shaped word stays a switch bundle), BSD's only its first argument (a later `-io` is ignore-case plus oldest — measured: it selected and killed a process the case-sensitive question missed — and a later `-9` is a usage error of the killer itself, asked as written); `pgrep` has no signal, so a `pgrep` that feeds a kill keeps every word as written (`-io` is ignore-case plus oldest there, and a `-9` reaches `pgrep` unread); every other word that begins with `-` is put to `pgrep` as written and `pgrep` reads the attached value itself (a signal name outside the table reaches `pgrep` unread: the question is `unavailable` and the line is refused with the reason, never run). One exception to "the signal is the killer's alone": on procps `-H` / `--require-handler` keeps only the processes that handle the signal, so the signal the killer takes (the last `--signal` option, in any unambiguous spelling of it such as `--sig`, else the one signal word — which procps' `pkill` takes in a pre-scan before it reads any switch, so the first signal-shaped word is the signal even where it would otherwise be a switch's value or follows `--`, and the guard reads it in that order) is put to the question as `--signal <name>` (`pkill -H -USR1 -f WORD` asks `pgrep --signal USR1 -H -f WORD`, not `pgrep -H -f WORD`, which asks about SIGTERM and would miss an ancestor that handles only USR1); without `-H`, and on BSD (no such switch), the signal stays out of the question, and a `pgrep` too old to know `--signal` fails the question, which is refused as `unavailable` like any question that cannot be asked. The upper-case-word rule is retired; `pkill` / `pgrep` / `pidof` words are otherwise put to `pgrep` as before, except that the word after a switch that takes a value (`-F`, `-u`, `-P`, `-t`, …) is that value as written, whatever it looks like (`pkill -TERM -F -cont` names the pidfile `-cont`; it used to be read as the switches `-c -o -n -t` and asked as the pidfile `-ont`); that one value rule serves the long options too, over procps 4.0.4's option table (`--runstates`, `--older`, `--pidfile`, `--parent`, `--session`, `--terminal`, `--uid`, `--euid`, `--group`, `--pgroup`, `--ns`, `--nslist`, `--cgroup`, `--queue`, `--signal`, `--delimiter`, and the short `-r`; an exact name or any unambiguous prefix of one, its value attached by `=` or the next word; on procps `pkill`'s `-q` / `--queue` payload is the killer's alone and leaves the question with its value, as `pgrep` has no short `-q`), so `pkill -H --runstates --sig=USR1 -f WORD` asks `pgrep -H --runstates --sig=USR1 -f WORD` (the signal stays SIGTERM and `--sig=USR1` is the run states; it used to be read as the signal, and the question `pgrep --signal USR1 -H --runstates -f WORD` took `-f` as the run states and missed an ancestor that is asleep and handles SIGTERM), and the delimiter's value goes with its switch in a bundle (`pgrep -fd , x`) as it does apart. Behavior narrowing, stated (open to veto): a `killall` line with an option outside the table, which ran before, is refused now; and a line that selects by an attached value (`killall -uUSER`, `pkill -U1000`, `pkill -P1`) is asked now where it ran unasked.
579
+ - **Behavior narrowing (open to veto).** A command that ran before is refused now: a pattern kill whose selection holds the agent or a process above it, a pattern kill with a computed selector, and a pattern kill on a host where `pgrep` cannot be run. Nothing else changes: a pattern that selects only other processes, a literal-PID kill, `pkill -P $$`, and every command that names none of these programs run exactly as before. Hard-breaking: none. Reach, stated: the guard asks the question of THIS process's table, so it is not asked for a remote or sandboxed environment (`isRemoteExecutionEnv`) nor on Windows; a kill by literal pid (`kill -9 1234`, `kill $PPID`), shell text built only when the command runs, a script file's own contents, `ps | grep | awk | xargs kill` pipelines and the `Monitor` tool's commands are not read (KL-2348-a / b); on procps a `pkill -f <word>` still selects the tool's own `bash -c` (the word is in its command line), which is unchanged (KL-2348-c).
580
+ - **Consumers.** @test: the `selfkill-20261021` fence's red arm (`s1-self-wrap-bypass`) turns to a refusal on procps (`pkill -f <word>` with the word in an ancestor's argv: refused, the outer shell lives; the no-match / `pkill -P $$` / children controls run as before; on macOS the form that selects ancestors is `pkill -a -f <word>`) — rerun on this version. @cli: nothing to do (the refusal is a tool error carrying its own sentence; it renders as any Bash error). @server: the Bash tool's `details.type` vocabulary gains `bash_self_kill_refused` (a consumer that pins the set `bash`, `readonly_out_of_root`, `read_unestablished`, `bash_invalid_timeout` sees one more).
581
+ - **Pins.** `test/backlog2348-pattern-kill-self-preservation.test.ts` on two real layers. Lane cells L1–L8: a child engine (real Runner, real `NodeExecutionEnv`, real `/bin/bash`; `test/helpers/selfkill-engine-child.ts`) started under a shell whose command line carries a random marker word (`test/helpers/selfkill-lane.ts`) — six spellings in bypass (the outer shell lives, the agent finishes, the pid named is the outer shell's), the marker in the agent's own argv, `killall <outer shell's name>`, default mode after a person allowed the call, headless bypass, `run_in_background`, the controls (a pattern nothing carries, a live process the test started is killed, `pkill -P $$` kills only the shell's child) and the reference's words. Reading cells R0–R7: the real `pgrep` and this process's own ancestry on a table of spellings that hit and a table that run, the computed and unavailable arms, the remote-environment skip, a cancelled call, and the exact `pgrep` argv each killer form is asked. Option cells (the follow-up): L9–L12 on the real lane through a stand-in killer script (`killall -u<user>`, `pkill -U<uid>`, `kill -9 -1`: refused as hits, the stand-in never runs) and `killall -- -<outer shell's name>`; R14 (`-t` as `pgrep`'s `-t`, `-Z` unaskable alone) and R15 (`kill` of the pid `-1`); R9 (every spelling of `killall`'s options asks what the spaced one asks; options outside the table are unaskable), R10 (the spellings that used to read as no selector, with the real `pgrep`), R11 (controls: listed options with names nothing carries run), R12 (the signal table, shared by both readers) and R13 (`pkill -U<uid>` / `-G<gid>` are hits); each in a base-versus-head reading and with a mutation per rule. Second follow-up: L13 (a stand-in `killall -c <other name> <the outer shell's name>` is refused as a hit), R14 (the `-c` block: both asked), R17 (a signal word is asked about the way the host's `pkill` takes it, with the real `pgrep`) and R18 (the procps and the BSD grammar of the one signal word, both read on any host by loading the module as each platform loads it). Third follow-up: R19 (the probe each `-H` / `--signal` spelling is asked, in both grammars), R20 (a stand-in `pgrep` modelling procps' handler selection: an ancestor that handles only USR1 is refused for `pkill -H -USR1`, one that handles only SIGTERM is not) and R21 (a `pgrep` without `--signal` fails the question that needs it: `unavailable`); the value rule over the option table: R19's valued-option rows, R22 (procps' long options whole: every name, every prefix, spaced and with `=`, in both grammars) and R20's run-state rows.
582
+
583
+ ### The MCP failure text takes the request out of an endpoint's echo — every header value the request carried, whatever the header's name, and every `Cookie` / `Set-Cookie` member are removed at the one mint, ahead of the credential vocabulary, on every face that prints the remote's words (#2410; security fix and behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none — two internal functions in `protocol-http-auth`, not index-exported) (@server)
584
+
585
+ - **Before.** The connect, re-dial and refresh failure sentence (`statuses[].error`, the operator warning and its `cause`, the wiring manifest's `mcp[].error`, `announcedListings.mcpFailed[].error`, the refresh result and the re-dial `reason`) ran only the credential vocabulary over the endpoint's words, and the vocabulary reads header NAMES: an `Authorization` or `X-Api-Key` line came out `[redacted]`. An endpoint that answers a 4xx with the request headers it received in its body therefore handed every other value the request carried — `X-Org-Handle: acme`, a `Cookie: sid=…` line, the principal the engine sent under `principalHeader` — verbatim to every one of those faces (seven read faces on the server, measured). The faces that run after the mount read the remote's words the same way: a tool call's failure text and isError text, and a resource list / read / directory failure. Not a regression: the same on 7.55.0.
586
+ - **Now.** At the mint (`mcpRemoteText`, one helper behind every place that redacts remote text, and it takes the request's values as a required argument, so no face can read the remote's words without naming its request), before the credential vocabulary and before `inlineUntrusted`, both kinds of removal found on the ORIGINAL text and their ranges merged, one marker per merged range: (1) every VALUE the request carried is removed from the remote's text — each string value of the declaration's `transport.headers` (whatever its name), the principal sent under `principalHeader`, each in every spelling the one derivation below yields (as declared and as the transport sends it — the whitespace at a value's edges is not sent, so an endpoint echoes the trimmed value —, the part after its first word as in `Bearer x` → `x`, the spelling an "invalid token x" echoes, and their JSON, percent-encoded and `+` spellings) — in any letter case, longest spelling first; (2) every `Cookie` / `Set-Cookie` member is replaced whole, whether or not the request declared it — a header line to its end, a record member's string, array or object to its own close (across lines, brackets inside its strings skipped; sibling members stay). Found on the original text and merged, because a declared value that sits inside the word `Cookie` (`ok`) must not disarm the Cookie rule (it leaves the name and the member's value still goes), a declared value that overlaps the name from outside (`"cookie"` with its quotes) must not eat the name and leave the member's value behind (both ranges go), and no marker is rescanned. A failure text can go through the pass more than once on its way out (a tool call's failure is read where it is caught and again where it is rethrown; a resource-list failure where the rejection is captured and again where its line is printed), and a second pass reads the first pass's marker as text: a declared value that sits inside the marker (`act` in `[redacted]`) turns it into `[red[redacted]ed]` — legibility only, nothing that was removed comes back. The marker is the vocabulary's `[redacted]`; the vocabulary still runs after, as the supplement for what the engine was not sent. The status line's reason phrase and a URL's userinfo are unchanged (the first never echoed, the second is refused before any request with a fixed sentence), and the 240-character bound on the inline faces is unchanged.
587
+ - **Spellings (#2445).** An endpoint rarely prints a value the way it was sent. ONE derivation (`requestEchoSpellings` in `protocol-http-auth`, internal, not index-exported) yields every spelling that is removed, from each declared value alike — no list of header names, no guess at what a token looks like. Three bases: the value as declared; as the transport sends it; and, of that, the part after its first word when it reads `<word> <rest>` (`Bearer x` → `x`; the cut is made of the value's shape, so `Realm x`, `Token x` and a plain two-word tenant name give their second part too — the cut used to be made only under credential-looking headers). Each base in eight spellings: as it is, JSON-string escaped, that with `/` written `\/` (PHP and some other servers escape the slash: `Bearer q7Zk/9xW` → `Bearer q7Zk\/9xW`), and with `+` for a space; and the percent-encodings as a closed grid, two reserved sets × two spellings of the space — the set `encodeURIComponent` writes (it leaves `~ ! ' ( )` bare) or the set the platform's form serializer (`URLSearchParams`) writes (it encodes them: `tenant~key!2491` → `tenant%7Ekey%212491`), each with `%20` or with `+` for the space. The form-serializer spellings come from that one encoder, not from a character table of ours. The match ignores letter case, so `%2f` and `%2F`, `BEARER X` and a lower-cased echo are one spelling. The measured leaks this closes: `Bearer%20<value>` and `Bearer+<value>` in the endpoint's words, a value in upper or lower case, a percent-encoded Cookie value (`sid%3dabc`), a bare token with its scheme word dropped, the form serializer's spellings of a value holding `~ ! ' ( )` (`tenant%7Ekey%212491`, `Acme%20Widgets%2F7%21x`), a base64 credential printed with escaped slashes (`Bearer q7Zk\/9xW+aB3\/Lm0=`, as a PHP-style JSON body writes it), and the same spellings of the principal. Not removed, and stated in KL-2410-c: base64 and other re-encodings, a percent-encoding whose reserved set is neither `encodeURIComponent`'s nor the form serializer's (a form that encodes only some of `!'()~`, another language's encoder), a JSON escape other than the standard ones and `\/`, double encoding, a value the endpoint splits across lines, the single pairs of a `;`-joined Cookie value echoed one at a time, and a case rewrite that changes the string's length or its encoded bytes (`ß` upper-cased to `SS`; a non-ASCII value upper-cased and then percent-encoded). A declaration too large for one matcher (tens of thousands of characters in one value or in the spellings together, which no header carries) makes the whole remote text the marker `[redacted]` instead of throwing — the matcher's own error text would have carried the value. A text that holds none of the spellings and no Cookie member is still returned as it is.
588
+ - **The collision rule.** A declared value that is also an ordinary word or number (`en`, `1`, `error`, `application/json`) is removed, in any letter case, wherever it occurs in the text the pass reads, and that text is the failure's whole message — the SDK's prefix, Node's error text and any URL the message quotes, not only the remote's own words — inside a word as well as as a whole word: a header value `1` takes the `1` out of `401`, `127.0.0.1` and `HTTP/1.1` in a connect failure (`40[redacted]`, `[redacted]27.0.0.[redacted]`), a value of only spaces takes every space, and on the tool-call faces a tool's own isError text goes the same way (a header value `user` takes the word `user` out of `/home/user/data` there), a header value `error` takes the `Error` of `Error POSTing to endpoint` as well, and a two-word value such as `hello world` takes `world` wherever it occurs (the part after its first word is one of its spellings): the sentence loses legibility, never a secret. Two declared values that overlap are removed one at a time, leftmost first: the tail of the second can stay (KL-2410-c). The failure's closed fields (`errorCode`, `httpStatus`, `delivered`) are not text and do not change.
589
+ - **Reach, stated.** Every MCP face that prints a failure text reads the same way (a server's `instructions` and a tool's description are not failure text and are read as before): the connect, re-dial and refresh sentence and every face it reaches, the note of a tool listing that stops on a later page, a tool call's failure sentence (the transport branch and the raw rethrow of a JSON-RPC error) and its isError text, and a resource list, read and directory failure (the extension's InvalidParams detail and the prefix-fallback listing included). Each connection carries its request's values — the tool closures and the resource-server record are handed them at the dial. What is not covered is stated in KL-2410-b (the A2A path has no echo pass: a peer's JSON-RPC error text reaches the model's tool error with an out-of-vocabulary header value or the principal as the peer echoed it, and only the vocabulary lines are redacted there) and KL-2410-c (re-encodings other than the spellings above, overlapping values, the multi-cookie form).
590
+ - **Behavior narrowing (open to veto).** A failure sentence carries less of the remote's words than before: header values the request sent and Cookie / Set-Cookie members no longer appear in it, and neither does any other occurrence of the same string. Nothing is blocked and no closed set changes. One visible side effect for a session carried across the upgrade whose server still fails against an echoing endpoint: the once-per-session record identifies a failure by server name and sentence (and the operator warning by code and message), and the sentence changed, so the model and the operator sink are told of that failure once more, and the stored row that carried the old text is not carried forward.
591
+ - **How to migrate.** *server*: on pickup the S-1948 read faces turn to zero hits for out-of-vocabulary header values and Cookie lines — flip the 🚧 reads to "zero hits"; your second redaction that rewrites the engine's `[redacted]` to `«redacted»` is yours to retire (S-2027), the engine already redacts at the one mint.
592
+ - **Pins.** `test/backlog2410-mcp-request-echo.test.ts` (a real local HTTP endpoint that echoes the request into a 400 body: E1–E9 and E13–E17 on the connect — E15 a declared value inside the word `Cookie`, E16 whitespace-padded values, E17 multi-line structured Cookie members with their siblings kept — R1 the refresh arm, R2 the re-dial arm, L1 / L2 a listing that stops on a later page at the connect and at a refresh, F1 a real run's manifest, events and operator chain, C1–C7 the faces after the mount — a tools/call answered 4xx, an isError result, a JSON-RPC error in a 200, a resources/list with a Cookie, a read, a directory read through the fallback listing and through the extension — C8 the law read off the source (every production page walk hands the walker the request's values), C9 / C10 a resource-list rejection that settles just before the run's abort, on the first and on a continuation page, T1 the Cookie rule alone, U1–U3 the two functions, G1–G4 the spellings on a connect, the principal, a real run's tool-call text and a refresh, U4–U9 the derivation itself and its controls, the Cookie / value overlap shapes and the oversize declaration, H1–H4 the form serializer's spellings of `~ ! ' ( )` values removed beside the other encodings — direct, on a connect, in a real run's tool text — and the reserved-set × space grid closed with the mixed form kept, I1–I3 the escaped-slash JSON spelling (direct, on a connect, in a real run's tool text); E10–E12, G5, H5 and I4 are the controls that hold on the pristine tree — an undeclared header is left alone, the reason phrase and URL userinfo do not echo).
593
+
594
+ ### A row's owner is recorded by the write that creates it and `register` never changes it — the session-store kit's "register on a row that exists does nothing" cell asserts that for a placed row too, instead of finding `null` there, and the `headOf` / `register` contract notes say so (#2403; behavior narrowing of the kit, open to veto; hard-breaking: none; closed sets: none; exports: none) (@server)
595
+
596
+ - **Before.** The kit cell `[ownership] register on a row that exists does nothing, whatever it carries — an owned row, an anonymous row, a row acquire created, a placed row` ended with a placed arm that registered a name onto a placed row and asserted its owner `null`; the `headOf` note listed "a placed row" among the rows that read `null`. That was a second law beside the first (a row's owner is recorded by the write that creates it): a store whose placing write records a principal in the creating write — the server's two SQL session stores record the delegation tree's owner (or a sentinel no caller matches) on every placed row — failed the cell at the placed step although no `register` had moved anything.
597
+ - **Now.** The placed arm reads the owner the placing write recorded (through the one owner reader every ownership cell uses), registers `null` and then a name onto the row, and asserts the owner is the recorded one after each; no `null` literal stands for a placed row any more. The cell keeps its text and stays in the required set. The `headOf` note now says a placed row carries the owner its placing write recorded, and the `register` note names the placed row among the rows it leaves alone. The engine's own placement write is unchanged: `acquire`'s options and `SessionPlacement` carry no principal, so a placed row on a bundled store (`TtlSessionStore` over `FileSessionRepo`, `FileStorageBackend`'s store) records no owner and reads `null` — now pinned, so the note's sentence cannot rot silently.
598
+ - **Behavior narrowing of the kit (open to veto).** The kit demands less of a placed row: it no longer requires the placed row's owner to be `null`. A store that passed the old cell still passes (an owner the placing write recorded as `null` is invariant under a register); a store whose placing write records a principal now passes it, and a store whose `register` changes a placed row's owner — from `null` to a name, from a name to another, or from a name to `null` — fails it.
599
+ - **How to migrate.** *server*: on pickup the four `it.fails` cells the session-store kit run registered (tidb and pg, the SQL store and its cache wrapper — S-2010) turn green: delete those four rows and the positive pin that stood in for the cell; the stores themselves need no change.
600
+ - **Pins.** `test/backlog1468-s1-session-store-kit.test.ts` (K2 positive control: a store whose placing write records a principal passes the whole kit and keeps it through every register — red on the previous cell, naming exactly this cell; M3c / M3d / M3e: a `register` that overwrites a placed row's owner — `null` to a name, a recorded principal to `null`, a recorded principal to another name — reddens the cell; K6: a placed row on each bundled store that keeps the tuple records no owner and a register does not give it one).
601
+
602
+ ### Four shell read-boundary corners close under one law — execution text the lexer cannot see is an unreadable definition, judged ambiguous (ask / refuse), never silently allowed (#1064 / #1415 / #2298 / #1628; four narrowings and one widening — the removal planner reads rm's own refusals, a divergence from the reference — all open to veto; hard-breaking: none; closed sets: +1 member `wildcard_program_word` on `PROGRAM_POSITION_REFUSAL_KINDS`; exports: +7 internal readers, not index-exported (`exportedFunctionNames`, `envFunctionNames`, `inheritedExportedFunctions`, `inheritedFunctionsOf` and the type `InheritedBody` in shell-read-bindings; `rowRefusesArgv` in program-read-model; `refusedByRemoval` in removal-targets, read by the read station's acceptEdits license too); members: a `ProgramPositionRefusal` gains the `wildcard_program_word` kind, a `ProgramRow` gains `optionsComplete?`, a `RemovalFinding` carries no new member; internal, not index-exported: an `ArgWord` gains `rewritten?` and `vanishes?`, and the plan start gains `exportedFunctions?`) (@test @cli @server)
603
+
604
+ - **#1064 — a `#` inside an arithmetic command `(( … ))` is arithmetic, not a comment.** *What changed:* the one lexer no longer starts a comment at an unquoted word-start `#` while inside an arithmetic command `(( … ))` (recognised by the adjacent `((`, which bash reads as arithmetic wherever a command or a `for` header begins; nested subshells need a blank, `( (`); a following command is no longer swallowed, so `(( 1 #)); cat <deny>` reads and refuses the deny row where it was vouched at zero cards, and `for (( i=0 # )); do cat <deny>; done` the same. A `#` inside a subshell `( )` is still a comment, as bash reads it. *Who is hurt:* a command relying on the lexer mis-reading a trailing segment as a comment — none benign (measured against bash 5.3 and 3.2: the trailing command runs). *How to migrate:* nothing.
605
+ - **#1415 — a program word carrying an unquoted glob is an unreadable program position.** *What changed:* `s[h]`, `s*h`, `./c?t` at the program position make `readProgramPosition` refuse with the new `wildcard_program_word` kind (the shell expands the word against the filesystem, a shell among the matches — `touch sh; s[h] -c '…'` runs the local `sh`), so both read faces ask — an ask a person must answer (class `unverifiable_shell_text`; `bypassPermissions` and an allow rule do not clear it, and a run with no approver refuses the call); the glob word is a deny-only read (a deny-listed program path still refuses), its operands handed over (a deny operand still refuses), and a pattern a substitution completes is the same unreadable position: an unquoted `*`, `?` or `[` beside a parameter, command or arithmetic substitution in the program word (`s[$(printf h)]`, which is `s[h]` once the substitution lands; `"$DIR"/run*`) asks the same question a person must answer, where `touch sh; s[$(printf h)] -c '…'` ran with no ask at all in `bypassPermissions`; the shell rewriting such a word, its read stays the unresolvable one every rewritten program word gets (the read station's mandated, unestablishable reading — it does not drop to the pure pattern's deny-only read) — a substitution with no metacharacter beside it (`$(printf sh)`, `"$X"`) is an unknown program, not this question. An operand glob (`ls *.txt`) is unchanged — only the program word is the question, and a pattern is only what the lexer carves as one: a lone `[` or `[[` (the test command, `[ -f q ]`, `if [[ $x == y ]]`) and an unclosed `[abc` are literals that bash runs as written, so a conditional test reads as it did. *Who is hurt:* a command whose program is spelled with an unquoted glob, or with a pattern a substitution completes, now asks (the person's answer, as above) where it was auto-allowed. *How to migrate:* quote the program word, or name it without a pattern.
606
+ - **#2298 — an unknown program verb is read as `rm` would read the same words.** *What changed:* the removal guard has ONE planner. A removal the text names (`rm`, `rmdir`) is read by its own row; a program word the text does not settle (a loop word `$p`, a `$VAR`, a substitution, a launcher's shell-place program `su -s "$SHELL"`) could be any program, `rm` among them, so its words are read exactly as `rm` reads them — the options `rm`'s grammar selects, the operands it removes, judged by the same arm as the literal — wherever it stands (the top-level command, a `bash -c` / `eval` body, a launcher's composed line), rather than falling through `verb.basename ∈ REMOVAL_COMMAND_SET` to silence. No flag shape gates it: `$T -r <ws>`, `$T <ws>`, `for p in rm; do $p -r <ws>; done`, `$T -r -- -f <ws>` and `$X -r -f ~` ask (`dangerous_delete`) as `rm -r <ws>` does, and a recursive forced form whose operand the shell rewrites (`$T -rf "$W2"`) asks as `rm -rf "$W2"` does (`unverifiable_delete`). The planner reads `rm`'s own refusals (next bullet), so an unknown program whose words `rm` would refuse or would remove nothing protected with stays quiet: `$PYTHON -m pytest .` and `$PYTHON -m pytest -rfE .` (`-m` / `-E` are no `rm` option), `$EDITOR .`, `$GREP -r TODO .`, `$SUDO chmod -Rf 755 .`, `$KUBECTL apply -R -f .`, `$CP -rf build/ .` (`rm` refuses `.`), `$EDITOR file`, `$EDITOR $X`. `$SUDO cp -rf build/ /usr/` asks: read as `rm`, those words remove `/usr` (whether `$SUDO` is a wrapper is not in the text). A bare protected operand asks too, as the literal `rm ~` does in 7.55.0 and in the reference's own check (which cards every `rm` target whatever its flags): `$EDITOR ~`, `$EDITOR <ws>`, `$X /usr`, `"$EDITOR" "$PWD"`, `$OPEN ~` read as `rm ~` and ask, although a real `rm` with no `-r` / `-R` / `--recursive` refuses a directory operand and removes nothing — the planner does not yet read that fact (a top-level operand may be a file it would remove, which the text alone cannot tell; #2389); `$EDITOR ~/.bashrc`, `$EDITOR <ws>/x`, `$EDITOR README.md` stay quiet. An unknown program handed literal shell text is not read as a shell: `$X -c 'rm -rf ~'` (and `$SUDO bash -c '…'`, `"$SHELL" -c '…'`) runs in `bypassPermissions` with no card — default mode asks about it as a read it cannot establish — where the literal `sh -c 'rm -rf ~'` is the mandated dangerous removal; the same on 7.55.0, a candidate ticket (#2372). Loop values are not resolved, so a flag the loop binds (`for f in -rf; do $p $f <ws>; done`) is an operand word, and `<ws>` is then judged as `rm "$f" <ws>` would remove it (a dangerous removal); the loop-beat binding is untouched. *Who is hurt:* a command whose program is a variable, a loop word or a substitution and whose words, read as `rm`'s, remove a protected location (a workspace directory or one of its ancestors, a home, the filesystem root, a top-level directory) now asks — a question a person must answer (`bypassPermissions` and an allow rule do not clear it; with no approver the call is refused) — whatever its flags (7.55.0 and the first cut of this entry asked only with both `-r` and `-f`), including `$SUDO cp -rf build/ /usr/` and `su -s "$SHELL" … -rf <home>`, which was an allow-clearable unplaced read (the #1421 ⑪ behavior is superseded, open to veto); and one whose recursive forced words carry an operand the shell rewrites (`$CP -rf "$SRC" "$DST"`) now asks `unverifiable_delete` where it ran. *How to migrate:* name the program literally (`cp -rf build/ /usr/`), or settle the name in the command (`CP=cp; $CP -rf build/ /usr/` reads `cp`).
607
+ - **The removal planner reads `rm`'s own refusals (behavior change, open to veto; a divergence from the reference).** *What changed:* two facts of `rm` itself are read, for a literal removal and an unknown program alike (one planner): an operand whose last path component is `.` or `..`, trailing separators stripped (`.`, `./`, `..`, `../`, `src/..`, `~/.`), is skipped — POSIX `rm` step 1, which GNU, BSD and BusyBox `rm` all keep (`rmdir(2)` fails on such a component too); and an argv carrying an option no `rm` implementation declares, where every implementation reads it as an option (before the first operand: `-m`, `-E`, `--bogus`), runs nothing — getopt reports the invalid option; the refusal is read only off the words in front of the first one whose text the program does not receive as spelled (a substitution, a parameter, a pattern, or a value the lexer leaves unresolved), and the first of those words that is an argument for certain — a word of nothing but expansions that may yield no word (`$E`, `$(true)`, `"$@"`) may not be there, so it ends nothing and selects nothing — where options are still being read (`rm` reads neither it nor a word before it as an operand: no operand and no `--` in front of it), is read as any option `rm` declares — one reading of the options `rm` receives, for the refusal and for the recursive forced test alike, so `rm -r$(printf f) <dir>`, `rm $'-\x72f' <dir>`, `rm -r$'\146' <dir>`, `rm -r$F <dir>`, `rm -$R <dir>`, `rm -r $'-\x66' <dir>`, `rm $E -r$F <dir>`, `rm "$@" $'-\x72f' <dir>` and `rm -v $(true) -r$F <dir>` are `rm -rf <dir>` and ask: as a dangerous removal where `<dir>` is a protected location (`~`, the workspace), and as an unverifiable delete (mandated, also in bypass) where `<dir>` is an operand the shell rewrites from a value the text does not settle (`"$X"`, `$(cat list)`, `$'\x2fusr'`) or the stream `… | xargs rm $'-\x72f'` fills in. A rewritten word that arrives as one word and that `rm` reads as an operand — one the text does not show beginning with a dash (`rm "$(echo -rf)" "$X"`; `rm $'\x2drf' "$X"`, whose value the text settles but the planner does not yet decode — #2412) — is read as the operand it may be (a word wholly unknown at an operand position is as likely an operand as an option, and reading it as one would ask about every `rm "$X"` — the open design question, #2413), and one past the first operand or `--` is no option here (`rm -r "$D" $'-\x66'` is quiet where the literal `rm -r "$D" -f` asks, and `rm "$X" $(true)` stays an operand); a word that may arrive as no word or as several — `rm $F "$X"`, an unquoted `$(cmd)`, and a brace list, `rm {-r,-f} "$X"` and `rm {-rf,} "$X"` (#2412) — is skipped like the `$E` above: it ends nothing and selects nothing; `rmdir` selects neither option, whatever the word (the `rm` and `rmdir` rows now declare every option GNU and BSD take: `optionsComplete`; their remaining switches — `-d`, `-i`, `-I`, `-v`, BSD `-P` / `-W` / `-x`, the long names — are read as the switches they are, so `rm -i x` is read as `rm -f x` already was: its operands are words rm removes, not reads, and a cluster's letters are no candidate). The program's arguments begin after the program word the program position names, not after the first word spelled like it: a launcher option value spelled like the program (`env -u rm rm -r$F "$X"`, `sudo -u rm rm …`) is no longer read as an operand of `rm`, for every reader of a program's arguments (this one, the write targets, the unplaced-option probes) — a narrowing, open to veto: such a line asks as its literal `-rf` twin does where it ran. The read station's acceptEdits license reads the same predicate (an operand rm refuses covers no write root), so acceptEdits accepts `rm -rf .` — and `rm` refuses it — while `rm -rf <root>` and `rm -r <root>/` still ask. `rm -rf .`, `rm -rf ./`, `rm -rf ..`, `rm -rf ../` and `rmdir ..` no longer ask; `rm -rf ./*` and `rm -rf *` (the pattern names every entry of the working directory), `rm -rf "$PWD"`, `rm -rf ../<ws>` and `rm -rf <ws>/` still ask; `rm -rf .*` is judged by what its matches reach — the `.` and `..` it expands to are refused by `rm`, and a dotfile inside the workspace is no protected location. The reference (Claude Code 2.1.296) resolves `.` against the working directory and asks; this reads what `rm` does. *Who is hurt:* a reviewer who relied on the ask for `rm -rf .` or `rm -rf ..` — `rm` refuses those operands and removes nothing there; and a command that builds an `rm` (or an unknown program's) option word the shell rewrites from a value the text does not settle and spells with a dash (`rm -$FLAGS "$DIR"`, `rm -r$(opt) "$DIR"`, `$T -$OPTS "$DIR"`, `rm $E -r$F "$DIR"`, `env -u rm rm -r$F "$DIR"`) over a target the text does not settle, which ran at no question under bypassPermissions where `rm -rf "$DIR"` was asked, and now asks as it does (refused with no approver) — the word may be `f` and the target is not known. *How to migrate:* nothing for `.` / `..`; for the option word, settle the target in the text (`DIR=./build; rm -$FLAGS "$DIR"` is judged where it lands) or point it below a root the shell provides (`"$TMPDIR/build"`), as for the literal `rm -rf`. *Why:* one planner for the literal and the unknown program, reading `rm`'s real effect, so `$PYTHON -m pytest .` and `$EDITOR .` stop getting bypass-proof asks.
608
+ - **#1628 — a child shell inherits the parent's exported functions.** *What changed:* a function exported with `export -f NAME` / `declare -fx NAME` is inherited by a child `bash -c` as a function, so a call to the name there is the function, not the builtin: `declare(){ :; }; export -f declare; bash -c 'declare X=…; rm -rf "$X/usr"'` no longer models the child's `declare X=…` as the builtin binding, so `$X` is unknown and the removal target is unverifiable (ask) where it was silent; with no function exported the child's `declare` is the builtin, as before. Carried through the plan start: the binding carrier's function keys mark each exported name `defined` in the child, and `PlanStart.exportedFunctions` hands every child text the shell builds each inherited name with whether its BODY was read (the two carriers are one fold's work, #2371); in-shell `source` and a local function definition already shadowed. A body the text holds is read where it is defined — its segments are judged there (`f(){ rm -rf <ws>; }; export -f f; bash -c f` is the dangerous removal) — so calling it in a child is no unreadable text (`f(){ echo hi; }; export -f f; bash -c f`, `declare -fx f; sh -c f` run as they did in 7.55.0; the first cut of this entry asked about every exported name). A body is read where it is defined, not where it is called: a relative target that names another place at the call site (`f(){ rm -rf proj; }; export -f f; bash -c 'cd ..; f'` in a workspace named `proj`) is judged at the definition, for a child's call as for a same-shell one (`cd ..; f`) — KL-1628-c, as in 7.55.0. A body nobody read is: a function delivered on a child's environment (`env 'BASH_FUNC_f%%=() { … }' bash -c f`), or exported with no definition the text holds (`source x.sh; export -f f; bash -c f`), is an inherited function whose body the engine never read, so a call to its name in the child is unverifiable shell text (ask / refuse). The inherited set reaches the text of an `eval`, a launcher or a command substitution (`eval "bash -c '…'"`, `$(bash -c '…')`), where it was dropped. A name the shell rewrites is not tracked: `export -f "$n"` is asked about all the same (measured, both trees), `env "BASH_FUNC_$n%%=…"` is not (KL-1628-a, as in 7.55.0). *Who is hurt:* a command that exports a function shadowing a declaration builtin and runs it in a child — directly, through `env`, or inside eval / substitution text — to set a removal target now asks. *How to migrate:* nothing — the ask is the honest reading.
609
+ - **Out of scope.** #1611 closes as premise-falsified (its `BOUND_SPELLINGS_MAX` fold cap was retired by #1498; the cells' residual weakness — a loop-bound program verb — is #2298's law, applied above). #2299 (IFS custom-separator field split, A7 / A11) is a separate lexer-semantics ticket.
610
+
611
+ ### The session list is one answer: `SessionStore.list(query?)` reads the store's rows — a restarted store lists them — pages them newest-first by the `(lastActiveAt, sessionId)` keyset, filters by the row's owner, and reads its query through one reading, `readSessionListQuery` (#1468 S3; behavior change, open to veto: `TtlSessionStore.list` lists persisted sessions, not its cache, orders them by the activity the repo records (a touch is recorded; `noteTaskRun` and a bare acquire no longer reorder), and a call without `limit` reads the first 50; hard-breaking: 1 — `TtlSessionStore.touch` answers a promise (ruled C-R732 note 2, open to veto): who is hurt — a subclass that overrides `touch` with a `void` return no longer type-checks (a `SessionStore` implementer is not affected); how to migrate — the override returns `Promise<void>` and awaits the parent; why — the S3 record law lands the activity on the row before `done` is pushed, so `touch` awaits `SessionRepo.touch?` (item (6)); closed sets: none; exports: +5 (2 runtime: `readSessionListQuery`, `SESSION_LIST_LIMITS`; 3 types: `SessionListQuery`, `SessionListQueryReading`, `SessionRow`); members: `SessionStore` gains type parameters `S` / `Q` (defaulted), `SessionStore.list` takes an optional query, `SessionRepo.list` answers `SessionRow[]`, +1 optional `SessionRepo.touch?`, `TtlSessionStore.touch` answers a promise) (@server @cli)
612
+
613
+ - **Before.** Three answers to "which sessions are there": `TtlSessionStore.list()` listed its live cache (a session evicted, forgotten or released under `evict: "forget"`, and every session after a restart, was missing — so `/resume` over a File root forgot every conversation on restart); `SessionRepo.list()` listed persisted metadata with no activity time and no owner (the File repo replayed every session's whole log to answer it); and a server backend answered with its own `listSessions` (owner filter, keyset cursor, limit) beside the core face. The kit's list case said "each live session".
614
+ - **Now.** `SessionStore.list?(query?: Q): Promise<S[]>` is the one list: every session ROW the store holds that the query admits, newest-first by `(lastActiveAt, sessionId)` (both descending; equal times order by id), at most `limit` rows after the `after` cursor; placed sessions are never listed. `SessionListQuery` = `{ owner?, includeUnowned?, after?: { lastActiveAt, sessionId }, limit? }` — no query, or `{}`, is the unfiltered list (every owner's rows, unowned included), first page; `owner` filters by the row's owner cell (exact match; the owner must be a principal name — a blank string, or one holding a control character, a line or paragraph separator or a format character other than the two joiners, is refused `config.invalid_argument`, #2265), `includeUnowned` (only with `owner`) adds the unowned rows; `limit` is an integer in `1..SESSION_LIST_LIMITS.max` (1000), absent = `SESSION_LIST_LIMITS.default` (50). `readSessionListQuery(raw, extensions?)` is the one reading every store's `list` takes its query through: the receipt law (a plain object, each member read once), each member judged, the default filled in; a bad value — a limit out of range or not an integer, a member of the wrong type, `includeUnowned` without `owner`, a malformed cursor, a member the store does not read — is refused `config.invalid_argument`, never mapped to a default. `SessionStore<S, Q>` takes the store's own summary and query: a backend whose list carries more per row, or filters by more (a text search), extends `SessionStoreSummary` / `SessionListQuery`, names its extension members to `readSessionListQuery`, and applies its filters before the page is cut — it is still assignable wherever a `SessionStore` is taken. `TtlSessionStore.list` reads its repo's rows (`SessionRepo.list`, now `SessionRow[]` = the metadata plus `lastActiveAt?`, the newest activity the repo RECORDS for the row) and orders by that time alone — never by what this process saw, so a dropped cache, a restart or another replica pages the same order and a cursor is never overtaken by a row whose time moved back. Activity is what is recorded: a row's writes, and a touch, which `TtlSessionStore.touch` now records through the new optional `SessionRepo.touch?(id)` (the File repo sets the log's modification time to now, the bytes untouched; the in-memory repo keeps a per-row time); over a repo without `touch` the order is what its writes record, else `createdAt` — the one reading, with no fallback to the cache. The cached view adds only what it alone holds, the run it recorded (`lastTaskId` / `lastTaskRunId`; after a restart a row lists without them), and only while the listed row names the view's occupant (`compareOccupants` — whatever head read the repo has). The File repo answers each row from its log's header and its modification time, one header read per session file, never a replay; the placed listing reads the same rows (its age = the same time).
615
+ - **A repo that names no occupant keeps its cached view's run on the list.** Whether a cached view still stands for a row is one judgement, read by both the list (does it lend the view's `lastTaskId` / `lastTaskRunId`) and `acquire` (does it serve the cached session): a row naming the view's occupant stands, a row naming another does not (nor does a row whose owner differs from the view's), and a row that names none (a token-less repo) stands over a repo with no head read — the cached view is its only evidence — but is dropped over a repo with a head read, where the row can be read again. Over the bundled Pg reference a real Runner's A, B, A lists both rows with their last run, as the list did before it read rows; the order is the creation order, since that repo records no activity time. Limit, and what a head-less repo's external writers can do: `KL-1468-s3-a` in `docs/KNOWN-LIMITS.md` (open to veto).
616
+ - **Behavior change (open to veto).** (1) **What changed:** `TtlSessionStore.list()` lists the sessions its repo holds, not its cache: after a restart, after `forget`, and after a `release` under `evict: "forget"` (the default over a durable repo), a session is still listed; under `evict: "delete"` (the default over the in-memory repo) a released session is gone, as before. A shell's `/resume` over a long-used File root shows the old sessions it used to forget. A session a background subagent wrote without a placement (a delegation runner mounted with no agent-row store) is now listed as an ordinary session — it is one: nothing marks it as a subagent's. (2) A call with no `limit` reads at most 50 rows (before: the whole cache, which a restart emptied); a caller that wants every row pages with `after` — the last row's `lastActiveAt` and `sessionId` — and asks for one row more than it shows to learn whether another page exists. (3) A File-root list costs one header read per session file on every page (the order needs every row's time); a root of tens of thousands of sessions pays that per call. (4) The File lane's activity time is the log's modification time, so a copy that does not preserve it (a plain `cp`, some sync tools) reorders the list — the risk the placed listing's age reading already had. (5) The order is the recorded activity — writes and touches — the reading server SQL stores already take (`updated_at`, bumped by `touch` and by every leaf move): `noteTaskRun`, and an acquire that writes nothing, no longer move a session up the list (a run moves it by what it writes, and by the touch the Runner makes as it ends); the kit's `noteTaskRun` case no longer asserts that it is activity — a narrowing of what a store must do, the same one-carrier reason as the rest of this entry. (6) `TtlSessionStore.touch` now answers a promise and rejects when its repo cannot record the touch (the File repo: a `SessionError("storage")`); the Runner's end-of-run touch already routes a rejection to `onError` and goes on (`run-terminal-adoption.ts`). A subclass of `TtlSessionStore` that overrides `touch` returning `void` stops type-checking (return a `Promise<void>`); an implementer of the `SessionStore` interface is unaffected, its `touch` is `void | Promise<void>`. Who is hurt: a host that relied on the list being the live set (it is the persisted set), on an unbounded `list()`, on `noteTaskRun` or a bare acquire reordering the list, on `touch` being synchronous (a subclass overriding it, above), and an author of a repo with no `touch` and no head read (the bundled Pg reference adapter, and anything shaped like it): its list is in creation order, and the list lends a cached run to a row on the cached view's own evidence (the paragraph above, KL-1468-s3-a).
617
+ - **How to migrate.** *cli* (`/resume`): read the first page, page with `after` when the user scrolls; nothing else changes. A `SessionRepo` that can record a touch implements `touch?`; one that cannot keeps its write-time order. *server* (stage two of the store boundary): the SQL and local stores implement `SessionStore<ServerSessionSummary, ServerListQuery>.list` and retire the parallel `listSessions` face (`security.ts:209`, `caching-session-store.ts:75`, `pg-session-storage.ts` / the TiDB twin's `listSessions`, `local-session-store.ts` `listSessions`): read the query with `readSessionListQuery(q, ["q"])` (the text search is the extension member, applied before the page), keep `updated_at DESC, session_id DESC` (the same order: `touch` and every leaf move bump `updated_at`), and move the cursor from an ISO `lastActivityAt` string to the epoch-ms `lastActiveAt`; the wire `GET /v1/sessions` projection (`lastTaskId` → `lastRunId`, ISO times) stays at the route. The local lane's own `lastActivity` sidecar retires: its rows come from `FileSessionRepo.list`, whose `lastActiveAt` is the log's modification time. A route that passed `includeUnowned` without an owner must not (it is refused).
618
+ - **Pins.** `test/backlog1468-s3-list.test.ts` (L1 the real loading path: a backend booted after a restart lists the sessions written before it, ordered by each log's modification time, and the placed listing ages by the same time; L2 the projection never invents — run ids absent after a restart, a cached view of a replaced occupant lends nothing (with and without a head read on the repo), a cursor taken before a restart pages the restarted store without a repeat, release under each evict policy; L3 the File repo lists by header — an interior corrupt record is the replay's disclosure, not the listing's; L4 the query reading — defaults, bounds, receipt law, extension members, identity; L5 a store with its own summary and filter is a `SessionStore` and filters before the page; L6 nine list mutants, each reddening exactly the kit cases that pin its law; L7 the repo kit's listing case reds a File repo whose touch records nothing or whose time a write moved back; L8 the one judgement of "the cached view stands for this row": a real Runner's A, B, A over the Pg reference lists both rows with their last run, the list and `acquire` answer alike in five cells of repo shape × row evidence, and a repo that keeps an owner but names no occupant lends nothing to a row created again under another owner or none); the kit (`sessionStoreContract`) gains eight list cases — rows not cache (a reopened store lists them), a touch recorded across a restart, a replaced occupant's run not lent, paging across a dropped cache, the owner filter, keyset paging with ties, the default page, a bad query refused — its list case is renamed "each session row once" and its `noteTaskRun` case judges the run seats only; the repo kit (`sessionRepoContract`) gains a listing case (each row once, its owner cell, a recorded time a later write or touch never moves back).
619
+
620
+ ### The approval record's option form gets its one shape reading, `readRuleOffer2`, and the consent record check reads offers through it (#2264; behavior narrowing, open to veto; hard-breaking: none; closed sets: none (the reading answers two of `RULE_OFFER_SHAPE_PROBLEMS`' words); exports: +2 (`readRuleOffer2`, type `RuleOffer2Reading`)) (@server)
621
+
622
+ - **Before.** A record option (`RuleOffer2`: a candidate index, or a batch of them with three optional card seats) had three shape readings: the consent record check's inline clauses (an object, a kind word, a batch with members, an integer index, the card seats' types — it read an accessor member once per word it compared, and ignored members the form does not declare), and the server's two zod transcriptions, one per approval-record store (`permission-rule-store-file.ts:91`, `permission-rule-store-sql.ts:269`), which refused an undeclared member and accepted a batch with no members.
623
+ - **Now.** `readRuleOffer2(raw)` answers `{ offer }` (a fresh object of the members it judged) or `{ problem: "malformed" | "unknown_word" }`: not a plain record, a member the form does not declare, an index that is not a non-negative safe integer, a batch with no members, a `segments` that is not a list of strings, a count that is not a non-negative safe integer, a reason row that is not exactly a segment and a reason ⇒ `malformed`; a kind or a reason that is a string outside its closed set ⇒ `unknown_word`. Each member is read once, a list by position with its length read once. Which seats a record of which kind must carry, and whether the indices name its candidates, stay the record check's — which now reads every option through this reading first.
624
+ - **Behavior narrowing (open to veto).** (1) A record whose option carries a member the form does not declare (a foreign writer's row, e.g. a stored rule text beside the index) is refused `record_malformed`; before it was read without that member. The record's `schema` stamp is the version gate, so a same-stamp row carrying one was not written by this form. (2) The refusal sentence for an off-form option is one sentence naming the reading's word — `offer N is not a record option this engine reads (malformed|unknown_word): …` — where it was one of four ("is not an offer object", "has an unknown kind", "batch offer N has no members", "reason row N is not a segment/reason row from the closed reason set"); a non-integer index is no longer echoed into it at all. (3) A store that hands back accessors: the kind and each reason are read once, so a value that answers differently on later reads is judged by its first answer. Who is hurt: a test pinning one of the four old sentences.
625
+ - **How to migrate.** *server*: both approval-record stores read each stored option with `readRuleOffer2` and refuse the row (their fail-closed replay) on a problem, retiring `RuleOffer2Schema`; a batch with no members, which the zod schema accepted and the core record check refused, is now refused at the read.
626
+ - **Pins.** `test/backlog1468-s3-list.test.ts` (R1 every option the engine mints reads back; every off-form value refused with its word; one read per member; the server transcriptions' acceptance, the empty batch named; the record check refusing an undeclared member); `test/backlog2102-offer-kind-denial-bound-vocab.test.ts`, `test/backlog1468-k1-vocabulary-twins.test.ts`, `test/design375-batch-consent.test.ts`, `test/permission-rule-consent.test.ts` re-pinned to the one sentence and the one read.
627
+
628
+ ### A filesystem glob is matched by one engine whose work is bounded by the pattern's length times the path's: the Glob and Grep tools' patterns, `.gitignore` lines and the shell read station's expansion of a command word no longer slow down with their number of stars (#2267; behavior change, open to veto: `**` stands for any run of characters, a line terminator in a path name included — the retired expression's `.*` stopped at one; hard-breaking: none; closed sets: none; exports: none; members: none)
629
+
630
+ - **Before.** Each `*` of such a glob was compiled to a `[^/]*` of a regular expression built from the glob's text, so the work of one test grew with the number of stars like a binomial of the name's length: a pattern of eight stars against a 32-character name took about 0.7 s, ten about 4 s, sixteen minutes — on the Glob tool's `pattern`, the Grep tool's `glob` (both model input), a `.gitignore` line (repository input), and a glob word in a shell command the read station expands to see what it would read (model input again). The permission-rule name glob (`wildcardMatch`) and the read-deny segments were already linear; these two compilers were the ones left. A `.gitignore` line of 65 536 characters did not slow the walk, it ended it: the expression was too large for the engine and the walk threw an invalid-regular-expression error.
631
+ - **Now.** `src/tools/fs/fs-glob.ts` is the one matcher of the filesystem dialect (`*` any run but `/`, `**` any run, `?`, `[class]`, `{a,b}`, anchored at the start or at any path segment). A site parses its own spelling into tokens; the engine compiles them to a small program and walks the path once, holding the set of instructions live at each position (an instruction at most once), so one test costs at most the program's length times the path's length plus one, whatever the arrangement of the stars. The scratch is two sets of the program's length, kept between tests for a program of up to 65 536 instructions: a long rule that dies at its first character costs what it touched, not a table of program × path cells. A program past that size (a pattern of more than 64 K literal characters, or of more than 16 K `*a` units — a star and its follower cost four instructions, so the size follows the pattern's shape, not its length alone) makes its scratch for each value that gets past the two checks below and drops it after the test: about 1.1 MB per test at 70 000 instructions, whatever the path (before, such a `.gitignore` line threw and ended the walk). Before the walk, two checks that can only say "cannot match": a value that lacks a literal every match holds, or does not end with the literal the pattern ends on, is refused; inside the walk, a program that opens on a literal starts a thread only where that literal stands; a run of stars is compiled as one star. No expression is built from the pattern's text (a `[class]` is one character wide, its own small expression cannot backtrack) and no cap on stars was added: sixteen or forty stars against 32 characters return at once on all four paths. The permission-rule name glob keeps its own engine (`wildcardMatch`): its `*` crosses `/` and it has no `?`, class or alternative — a different grammar, not a second reading of this one.
632
+ - **What stays.** The language each site spoke: an answer-for-answer comparison against the retired builders over every glob of up to three pieces and a seeded sample of longer ones agrees (the `.gitignore` lines' negation, trailing-slash and basename rules stay in `gitignoreMatcher`; the shell's leading-dot rule stays in `globSegmentMatches`). `?` and a class stay one UTF-16 code unit wide, an unterminated `[` or `{` still reads as the literal and still makes the Grep tool refuse the token, a pattern the shell leg cannot model is still `unestablishable`.
633
+ - **Behavior change (open to veto).** `**` was `.*` and did not cross `\n`, `\r`, U+2028 or U+2029; it now crosses them, as `?` and `*` already did. **Who is hurt:** a path name that contains a line terminator and is named by a `**` glob; that name is now matched (before it was silently missed). **How to migrate:** nothing.
634
+ - **Cost.** A gitignore-sized walk (30 rules, 30 000 paths) measured about 13 µs per path before and 10–13 µs after. The worst case stays the product of the two lengths, and it does not take a long path: a ring of empty alternatives (`*{,}` repeated 1 000 times, a 4 KB pattern) keeps every instruction live at every place and enters 397 318 (instruction, position) pairs against a 79-character path (16 KB: 1 588 318; 8 to 25 ms and 18 to 110 ms per test, measured on two machines), and a pattern of 2 500 alternating `*a` units against a 2 000-character path enters about 8 million (80 to 400 ms); the retired expression took 79 s to fail on a ring of only six such units against the same 79 characters, and did not return on the `*a` shape. Only the Glob tool's `pattern` and the Grep tool's `glob` can spell the first (a `.gitignore` line takes no `{}` and the shell station reads `{` as a letter), and both are model input with no length limit (KL-2267-a).
635
+ - **Pins.** `test/backlog2267-fs-glob-engine.test.ts` (§1 the engine's visit count stays within `size × (length + 1)` on sixteen stars and on globstars with stars inside alternatives, what one test leaves in the shared scratch does not reach the next, a run of stars is one star, a long rule rejected at its first character allocates nothing per test while a program past the retained size makes its scratch for each test, and a ring of 1 000 empty alternatives enters over 90% of the product against a 79-character path (the limit as stated above); §2 each of the four paths answers through the engine within a count bound and still finds the file that does match; §3 the language against the retired builders, about 4.0 M and 1.0 M comparisons; §4 `**` and a line terminator, `?` and an astral character); no pin reads a clock.
636
+
637
+ ### A call's safety axes are read under the names it answers to and nowhere else: a caller tool's irreversibility tier, reversibility probe and egress mark stay under its own name, an alias carries no copy of them, and a tie between two of the call's names goes to the tool that runs, so a call made under an alias is judged by that tool's own probe (#2266; two safety narrowings and two widenings, all open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @cli)
638
+
639
+ - **Before (v7.55.0).** The prepare safety scan copied each caller tool's tier, probe, egress mark and mark sources onto its aliases (the first claimer's), and the roster builder moved an alias's copy to the member the alias resolved to after a displacing mount or a removal — a second resolution of who answers to the alias, beside the one every reader already made (the gate resolves the model's spelling to the wire name of the tool it dispatches to, and the call's face lists that name). The copy went stale when a mount and the leg's selector moved the alias in two steps. Two caller tools sharing an alias, `Read{irreversibility: "maybe", aliases: ["X"]}` with a probe that never vouches and `Charge{irreversibility: "maybe", aliases: ["X"]}` with one that always does, on a full-hands leg with `allowTools: ["X"]`: the engine's `Read` mounted later and took the name, so `X`'s copy moved to `Charge`; the selector then unmounted the engine instance (it does not answer to `X`) and `X` answered to the caller's `Read` again, but its copy stayed `Charge`'s. The tier seat reads a call's names spelling first and keeps the earlier name on a tie, so a call by `X` was judged by `Charge`'s probe. Measured with a real Runner under `bypassPermissions`: the call ran `Read` unasked and consulted `Charge`'s probe (0 asks, `Read` ran once); the same call by `Read`, or the same tools on a leg with no hands, asked once on `Read`'s probe. The approval card of the same shape, with `Charge` also marked `egress`, said `egress: true` for `Read`.
640
+ - **Now.** A tool's marks live under the names they were laid on: a caller tool's own declaration under its own name (every caller tool of that name, as before), the engine shell seat under the shell's name, the MCP / A2A protocol fold under the server tool's minted name. A call is read under every name it answers to — the model's spelling, the wire name of the tool it dispatches to, that tool's other aliases — the gate's existing reading (`ruleNamesOf`, the call's face), unchanged. The scan writes no alias entry, and the roster builder no longer touches the axis tables (`RosterBuilder`, not a package export, takes the effect map alone). The one resolution of "which tool does this alias answer to" is the roster's, at read time. Every reader of the five tables was checked for a read under a spelling that skips the wire name: none — each reads through a name-readings port fed the gate-resolved wire name, or reads a mounted tool's own name (its roster row, the shell seat, the wiring manifest's lists).
641
+ - **Safety narrowing (open to veto).** (1) **What changed:** in the shape above a call by the alias is judged by the tool that runs — its own tier and probe, its own egress — so the call asks (1 ask, `Read` does not run, `Charge`'s probe is not consulted) and the card says `irreversible: true` without `egress`. (2) **Who is hurt:** a deployment that relied on another tool's probe vouching for a call that runs a different tool; none intended. (3) **How to migrate:** nothing; give each tool the marks that describe it.
642
+ - **Safety narrowing, the tie (open to veto).** (1) **What changed:** the tier seat — the one comparator the gate, the rule offer, the park's risk descriptor and the ask's risk axes read — takes the strictest tier among the call's names, and on a tie the call's wire name now keeps the seat (before: the call's earlier name, the model's spelling first), so the tool that runs judges its own call with its own probe. Before, a host tool `MyShell{irreversibility: "maybe", aliases: ["Bash"]}` with a probe that never vouches, kept by `allowTools: ["MyShell"]` on a full-hands leg, called as `Bash` with `ls`: the engine's shell seat had been installed under `Bash` (`maybe` with the shell shape probe) before the selector unmounted the engine shell, the two `maybe` tiers tied, and the shell shape probe vouched `ls` — measured, 0 asks, `MyShell` ran and its own probe was never consulted. Now 1 ask on `MyShell`'s probe, and it does not run. The tie changes whose probe judges the call and nothing else about it: whether the ask is read as the shell gate's (`shellGated`) does not move, because a host tool that declares `maybe` or `always` is marked as a deployment tool under its own name, so the call was never read as shell-gated under either seat (the same reading on both trees). A strictly higher tier under any of the call's names still wins (`MyShell{irreversibility: "always"}` asks either way). (2) **Who is hurt:** a host whose tool answers to an engine tool's name by alias and relied on the engine seat's probe vouching for its calls; the server documents `MyShell{aliases: ["Bash"]}` as a host shape. (3) **How to migrate:** give the tool the probe that describes it, or no tier. Not changed here: the shell seat is installed under the shell's names before the selector runs, so its tier still reaches a tool that answers `Bash` by alias after the engine shell is unmounted (over-careful: a tool declaring no tier, or a lower one, is asked on the shell's probe — in the modes that ask: measured with `MyShell` declaring no tier, under `default` `touch gone.txt` and `rm -rf gone` ask and `ls` does not, while under `bypassPermissions` that ask is an ordinary one and is let through, 0 asks) — a candidate for the next design car.
643
+ - **Widening, the other side of the tie (open to veto).** (1) **What changed:** the same shape with a host probe that vouches where the shell shape probe would not: `MyShell{irreversibility: "maybe", aliases: ["Bash"]}` whose probe always vouches, called as `Bash` with `rm -rf gone` — measured, 1 ask before (the shell shape probe under `Bash` kept the seat and refused it), 0 asks now (`MyShell`'s own probe vouched and it ran). (2) **Who is hurt:** a host that relied on the engine shell's shape judgment reaching its own shell tool through the alias while the engine shell is unmounted. Until the shell seat follows the engine shell's mount (the candidate above), the outcome is not monotonic in the host's declaration: the same tool declaring no tier is still asked on the shell's probe in a mode that asks, and runs unasked under `bypassPermissions` (KL-2266-a). (3) **How to migrate:** give the tool a probe that judges its commands, or `irreversibility: "always"`, or keep the engine shell mounted beside it.
644
+ - **Widening (open to veto).** (1) **What changed:** the reverse shape, `Read{aliases: ["X"]}` declaring no marks and `Charge{irreversibility: "always", aliases: ["X"]}` (or `egress: true`), same leg and selector: a call by `X` runs `Read` and is read on `Read`'s marks, so it is not asked under `bypassPermissions` (1 ask → 0; `Read` runs). `Charge` called by its own name still asks. (2) **Who is hurt:** a deployment that relied on one tool's marks reaching a different tool through a shared alias. (3) **How to migrate:** put the mark on the tool it describes. Which of two caller tools sharing an alias answers to it is the roster's order (the exact name, then the first claimer); refusing such a shared alias at the door is #2161's question.
645
+ - **Unchanged.** A caller tool NAMED exactly like an engine tool still marks that name: its marks reach the engine tool's calls (the designed channel; KL-2105-a). The shell seat, the protocol fold, the roster rows and the wake reconcile's effect reading (the scan still records each caller tool's effect under its aliases there).
646
+ - **Consumers.** *server* / *cli*: no wire key, row member, type or closed set changes; a call's ask and its card read the marks of the tool that runs.
647
+ - **Pins.** `test/backlog2266-axes-follow-answering-member.test.ts` (F1 the fail-open shape; F2 the same call by the tool's own name and on a leg with no displacement; F3 the widening shape and its controls; F4 the ticket's shape `Read{always, aliases: ["X"]}`, a defineTool product called by its alias, the first claimer's egress; F5 the scan writes no alias entry; F6 every gate-shaped call lists its wire name, and a request with no face reads the wire name alone; F7 the card's risk axes; F8 the tie: `MyShell{maybe, aliases: ["Bash"]}` judged by its own probe — a refusing probe asks, a vouching probe runs `rm -rf gone` unasked — the seat chosen on the tie with the marks a scan writes for a declared tier, and the `always` control). Re-pointed: `test/backlog126-definetool-axis-carry.test.ts` (the product-form scan pin reads no alias copy); `test/name-readings-behavior.test.ts` §1.3 (a tie goes to the wire name, then to the earlier name).
648
+
649
+ ### (#2269) Security fix, in part: a `defineTool` product cannot write an engine contract id by hand, or be dressed in one afterwards — the roster door that refuses a spec writing a `core.` contract id by hand now judges the descriptor a product carries with the same judge; an engine definition's contract object read off the definition and worn on a tool of the host's own still passes (see Not closed here) (#2269; security; behavior narrowing, open to veto: a product — or a plain object spread from one — whose descriptor claims `core.` identity the engine did not mint, or that no longer describes the object it rides on (an engine product renamed or given another schema), is refused at prepare with `config.tool_face_invalid`; behavior change: an engine definition's contract object and the descriptor the factory attaches for it are frozen; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @cli @client-core)
650
+
651
+ - **Before.** #2229 closed one door: a `TaskSpec.tools` *spec* declaring a `core.` contract id its catalog did not mint was refused. The other shape a roster entry takes was never asked. The public `defineTool` labelled any `core.` id it was given `origin: "core"`, the roster read that label to give the entry `source: "builtin"` and the card namespace `builtin:<name>`, and a `defineTool` product carrying a hand-written `core.ask_user_question@1` mounted as the built-in it was not — one declaration, two answers by the object's shape. The same held for a product dressed after the fact with the public `attachToolContract(product, { origin: "core", … })`, and for a product whose schema was changed afterwards (the descriptor downgrades to `legacy:` but keeps its origin). A built-in row reads its legs from the declaration, not from the tool's effect, so a write tool dressed this way could state `boundaryLegs: []` and be read by it.
652
+ - **Now.** One judge, two readings. The engine's mint is an object, not a string: a catalog contract object is frozen and every declaration copy shares it by reference, and the descriptor the factory attaches for one is frozen and recorded as the engine's own (`attachEngineToolContract`, internal); the public `attachToolContract` never records one, whatever `origin` it states. `callerEngineContractProblem` reads a roster entry's claim from its `contract` member (a spec) **or** from the descriptor it carries (a product, or a spread of one) and refuses the claim when the object it rests on is not the engine's: `config.tool_face_invalid`, the sentence the spec arm always had (`tool "<name>" declares the engine contract "<id>", which no engine definition minted for it — …`). A carried descriptor must also still describe the face it rides on — its wire name is the face's name and its structural schema digest (`computeShapeDigest`, the reading `getToolContract` already applies) is the face's schema's — or the face is no longer the engine's tool and is refused the same way; this is one rule for every face, not a case for products. An engine product a host mounts by hand (`createAskUserQuestionTool()`) still mounts as the built-in it is, and a plain-object spread of one that only re-renders its description is not refused — it mounts as the caller's own tool with a `legacy:` contract, as it always did.
653
+ - **Who is hurt.** A host that wrote a `core.<name>@<n>` id on its own `defineTool` product (or attached one with `attachToolContract`) and mounted it through `TaskSpec.tools`: the run now fails at prepare with `config.tool_face_invalid`, where it used to mount as a built-in. A host that spreads an engine product into a plain object and renames it or gives it another schema (it used to mount as the host's own tool with a `legacy:` contract) now has the run refused — it is no longer the engine's tool, so mount it as a tool of your own: build it with `defineTool` from your own spec. A host that changes an engine product's `parameters` in place after the factory built it is refused for the same reason. A host that deep-copies an engine product into plain data (a clone of its members, descriptor included, that does not carry the brand — a `cloneDeep` of the product, say) now has the run refused with `config.tool_face_invalid` as well: the copied descriptor is not the engine's object, and before this version the copy mounted as the host's own tool (source `caller`, a `legacy:` contract). Nothing else changes for a readable roster. `getToolContract(product)` still labels a hand-written `core.` id `origin: "core"` — the label is by prefix; what the door asks is whether the engine minted it. A host that mutated an engine factory's `contract` object, or the alias list of an engine product's descriptor, in place now meets a frozen object.
654
+ - **How to migrate.** Declare an id of your own, or none. To mount an engine definition, call its factory; do not write its id. Do not copy an engine product's members into a new object: mount the factory's product itself (a spread that only re-renders the description still is the engine's tool), or build a tool of your own with `defineTool` from your own spec. Nothing for a deployment that only mounts engine factories and its own tools.
655
+ - **Not closed here.** The proof is an object, and the engine hands the object out: an engine definition's contract object is the public `contract` member of its `ToolSpec` (`createTodoWriteTool().contract` — a root-exported factory and a typed member, no reflection and no cast), and the descriptor of an engine product rides on the product. Any host can read one off and wear it on a tool of its own, of any name and any schema — as the `contract` of a `ToolSpec`, of a spread of an engine spec whose name, schema and `execute` are replaced, or of a `defineTool` product — and the door accepts it: the row reads `source: "builtin"`, the card `builtin:<name>` and the legs the host's declaration states (`[]` for a write tool that says `boundaryLegs: []`). A real `prepareTask` gives the same rows before this version and after it; what this version removed is the hand-written id (on a product, or attached to one afterwards), the engine's descriptor carried onto an object it does not describe, and the rewriting of the engine's objects in place (they are frozen). The hazard #2269 was opened for — a host's write tool read as a built-in without boundary legs — is therefore still open for a host that lifts the object off an engine definition. Closing it is a design question (the proof cannot be an object the factories hand out: a mint token only the definition module holds, or a door that compares the definition itself — its `execute` or its brand — and not its contract object), tracked as a follow-up ticket. Also not closed: a host that changes an engine product's other declaration members in place after the factory built it (`effect`, `boundaryLegs`, the rule face — the descriptor describes name and schema only) keeps the built-in source.
656
+ - **Pins.** `test/backlog2269-product-engine-contract.test.ts` (A1–A3: the forged product refused with the spec door's code and sentence, on the real Runner lane as well, and the factory records no mint for it; B1–B3 controls: an engine product, a caller's own id, no contract; C1–C7: a spread of the forged product, a product dressed with the public attach (an engine id under origin `core`, an id of its own under origin `core`, the engine's id under origin `caller`), a schema swapped after the factory, a spread of an engine product, and the frozen objects; D1–D3: a descriptor lifted onto another object, an engine product's schema changed in place, a spread renamed; E1: a value under the carrier symbol that is no descriptor object — `null` — reads as a product that carries none, the same row as a product with none, no `TypeError`, on the prepare lane and on the real Runner); `test/backlog2229-rule-store-grammar-stamp.test.ts` L9 re-pinned to an engine definition the host mounts by hand (the seat reads who mounted it); `test/design469-r3-boundary-legs.test.ts` re-pinned the same way.
657
+
658
+ ### `@sema-agent/core-stores-sql` — the shell of core's SQL storage package (private, no stores yet), and the multi-package release base it needs: per-package wire manifests, package legs in the pre-tag check, the post lint and the stranger install, one bump for every version cell (#1468 N1, #1751; core public face: unchanged; hard-breaking: none; release procedure: one more manifest per published package from 7.56.0)
659
+
660
+ - **The package.** `packages/core-stores-sql/` is `@sema-agent/core-stores-sql`, cut at the root's version with `@sema-agent/core` as its exact peer, `private: true` (it is not published while it carries no stores). Its public face is neutral factories and neutral types only — `createSqlStorageBackend`, `createSqlDriver`, `baselineDdl`, `publishedColumns`, and `SqlStorageBackend` / `SqlStorageBackendOptions` / `RetentionCascadeHooks` / `SqlDriver` / `SqlTxConn` / `PgPoolLike` / `MysqlPoolLike` / `PublishedColumn` / `SqlDialect` (`"pg" | "mysql"`) / `SqlEngine` (`"pg" | "innodb" | "tidb"`); no store class, dialect class, statement list or ensure leg is exported (frozen in `packages/core-stores-sql/test/export-surface.snapshot.json`). Both factories first refuse a resolved core whose `CONTRACT_KIT_ENGINE_VERSION` is not the package's own version, then read every option loudly (a dialect word outside the two — `"tidb"` included — a pool of the other dialect, a retention word core's own declaration reader refuses, a hook that is not a function, a key this version has no option for), then refuse: this version returns no backend and no driver. `baselineDdl` runs the package's ensure roster against a recording pool (empty roster ⇒ `[]`; the recording admits only CREATE / INSERT), `publishedColumns` lists the columns a deployment may read in place (none yet). Package tests live in the root `test/` (mods-kernel's precedent).
661
+ - **How the repo resolves `@sema-agent/core` for a package that imports it.** One mapping: the root `tsconfig.json` maps `@sema-agent/core` to `./src/index.ts` (`compilerOptions.paths`) and includes `packages/*/src`, so the root typecheck compiles the package against core's source; `vitest.config.ts` reads the same `paths` entry as its exact alias. Alone, at build time, the package's own tsconfig sees core's built declarations (`../../dist/index.d.ts`), the consumer's view. Side effect: in-repo programs that import core by its package name (`eval/opt0-profiler`) now type-check against the source instead of `dist/`, so the root typecheck no longer needs a built tree. The root `.npmrc` (newly tracked; every other `.npmrc` stays ignored) carries one key, `legacy-peer-deps=true`: without it npm satisfies the package's exact peer by fetching the published `@sema-agent/core` into the lockfile, and fails with `ETARGET` at a bumped, not-yet-published version (both measured). A pin refuses any other key in that file.
662
+ - **`gate:packages` / `gate:packages:pack`.** A package that takes core is compiled in the repo by the root typecheck (the gate checks the root program holds every one of its files) and its surface is derived there; it compiles alone only at build, which is refused by name while the root's `dist/index.d.ts` is absent. Its stranger install puts the root's own tarball from the same tree beside it. Every package's smoke table must list every runtime export it has (the root's A-006.5 freshness as a whole-surface rule; mods-kernel already did). `docs/LAYERING.json` gains the package's row (`@sema-agent/core`, `node:fs`).
663
+ - **Wire manifests per package.** `node scripts/wire-changes.mjs <prevTag> <RC> --package <dir> [--version X.Y.Z --to-tag vX.Y.Z]` writes `wire-changes/<dir>@<version>.json` with the root manifest's own reader (the package's compiled files and frozen surface; `package` names it; a package new in the range is all additions; no criteria join). From 7.56.0 every published package (today: mods-kernel) must have one in the RC tree, based on the previous tag and current for its own inputs (its tsconfig, its snapshot, its source directory) — judged by `release:preflight` before the tag and by `ship-post-lint`'s WIRE leg after it — and the post's BREAKING lines answer the root's and the packages' entries together (each package manifest is first judged whole — a list it lacks is refused, never read as empty; `WIRE: none` must hold for all of them). Generation now refuses a `--to-tag` that names another version than the manifest's (the two-stage form computes before the bump, so `--version` is required there — this applies to the root manifest too).
664
+ - **Release scripts.** `scripts/bump-version.mjs <x.y.z>` sets the root version, each package's version and each exact core peer in one step and refreshes `package-lock.json` (`npm version --workspaces` leaves the peer at the old number — measured). `scripts/verify-fresh-install.mjs <version>` installs every published package after the root, anonymously, and runs its smoke table. The claim reader behind "which packages does this tree publish" is one function (`publishClaimsOfTree`, over a `{ listDirs, readFile }` tree), now shared by `verify-published-tags.mjs`, the post lint and the stranger install; the first two read a git ref's tree through `treeAtRef`, the stranger install reads its own checkout. `docs/RELEASE-TIERS.md`'s multi-package section is rewritten to match, with its untested npm claims replaced by readings (a local `npm pack` of mods-kernel reproduces the registry's `@sema-agent/mods-kernel@7.55.0` shasum byte for byte).
665
+
666
+ ### The prompt's model axis is the model's declared traits — `Model.promptTraits` replaces `promptGuidance` and the fable id reading, `TaskSpec.promptLean` replaces `promptProfile`, tool description variants are chosen by the same traits, and the task layer's identity digests its text (#1663, #546, #1793; hard-breaking: three; behavior change, open to veto; closed sets: `PromptTrait` 2 → 14; exports: +3 runtime (`PROMPT_TRAITS`, `PROMPT_TRAIT_BUNDLES`, `isPromptTraitName`), +3 types (`PromptTraitName`, `PromptTraitBundle`, `ToolDescriptionVariant`); members: −`Model.promptGuidance` +`Model.promptTraits`, −`TaskSpec.promptProfile` +`TaskSpec.promptLean`, −`ToolSpec.descriptionClassic` / `Tool.descriptionClassic` +`descriptionVariants`, −`ToolExecuteContext.promptProfile` +`promptLean`, −`RunWorkflowToolDeps.parentPromptProfile` +`parentPromptLean`, −`RunInternals.promptProfile`, −`CacheIdentityElements.policyDigest`, −`RawPrefixInputs.promptProfile` / `.fableMitigations`, −`PromptRuntimeFacts.promptProfile` / `.fableMitigations`, −`SectionRenderInputs.modelGuidance`; renderer `model-guidance` (so `PromptRendererId` loses that word) and section `core/behavior.model-guidance` retired) (@sdk @client-core @cli @server @settings-schema @web-admin @web-client @test)
667
+
668
+ - **Before.** Which prompt text a model received had three answers. The task's `promptProfile` (`simple` / `classic`) picked the short or long shape; the engine recognized the fable family from `model.id` (a boundary-aware regex on its last path segment) and lit the fable sections; and `Model.promptGuidance` appended free text at rank 450 for any model that carried it. A tool's long-form text sat in a second field (`ToolSpec.descriptionClassic`) that the mount swapped in when the profile was `classic`. The task layer entered the prompt identity as `(op, target, chars)`, so replacing the task's role text with another of the same length left the identity and the session's epoch record unchanged (KL-1604m1b-c).
669
+ - **Now.** (1) A model declares the traits it would light in the reference — `Model.promptTraits`, an exact set of names read through one predicate, `isPromptTraitName`: the reference's capability names that gate prompt text (`PROMPT_TRAITS`, fourteen; cli296.js B[7451052,7451700) `fvo` minus the effort / thinking / context / fast-mode / refusal-fallback / org-lock bits), its three bundles (`PROMPT_TRAIT_BUNDLES`; a declared bundle lights its members), or a deployment's own `x-<namespace>-<name>` (≤ 64 characters; the engine checks the spelling and gives it no meaning). Absent ⇒ `["lean_prompt"]` (today's default shape, bytes unchanged); an empty list is a declaration of no traits — the exact set of nothing — and takes the classic shape, so a form that submits `[]` for "none chosen" flips the model's shape (send the field absent for the default). A name the predicate refuses, or a value that is not a list, refuses the task (`config.prompt_trait_unknown`). (2) `TaskSpec.promptLean?: boolean` is the task's one switch: `true` adds `lean_prompt`, `false` removes it (the long, classic shape), absent leaves the model's answer; it moves no other trait. It rides every engine-built child (both delegation lanes, the observer sidecar, the verifier leg) and every workflow agent unless that spec writes one; a value that is not a boolean refuses the task (`config.prompt_selector_invalid`). (3) The effective trait set (`effectiveTraitsOf`) is the one input of the selector's `traits` dimension: the catalog's variants, every overlay's `when.traits` (center, deploy and task alike) and each tool's description variants read it through one function (`traitsHold`). No model id is read to choose text. (4) `fable5Model()` declares `promptTraits: ["lean_prompt", "fable_5_mitigations"]` (the reference registry's entry for the model). (5) Per-model guidance is an overlay: a deployment's model entry declares a trait (`x-acme-verbose`), and a center artifact's `add` to the `behavior` band — or a `prompt.compose` answer — carries `when: { traits: { allOf: ["x-acme-verbose"] } }`; it composes exactly for the models that declare it. An artifact's `requires.traits` names such a trait like any other (the reference's names keep their vocabulary order, a deployment's follow by text, so an artifact built before this release verifies unchanged). (6) A tool card's description is `description` (the unconditional text) plus `descriptionVariants`: at mount, the first variant whose `traits` condition holds replaces it. The four built-in tools that had a long form (Read, Glob, TodoWrite, Agent) declare it as `{ id: "classic", traits: { noneOf: ["lean_prompt"] }, text }`; their bytes are unchanged on every shape. The tool's `cardId` reports the variant: `builtin:<wireName>` for the unconditional text, `builtin:<wireName>#<id>` for a chosen variant (`legacy:` likewise). A malformed table — an id outside `[A-Za-z0-9._-]{1,64}` or repeated, an empty or unknown-trait condition, a blank text — refuses the task (`config.tool_face_invalid`). (7) The task layer's identity projection carries `text: "sid-sha256:" + sha256(session id, NUL, text)` instead of `chars`: the text never enters the record, the digest is the same in every process the session runs in, and a same-length replacement is a new record (`code_change`, `changed: ["task"]`). KL-1604m1b-c retires.
670
+ - **Hard-breaking (open to veto): the prompt shape is one boolean.** **What changed:** `TaskSpec.promptProfile` is retired for `TaskSpec.promptLean` (`"classic"` ⇒ `false`; `"simple"` ⇒ `true` or nothing); along the same chain `ToolExecuteContext.promptProfile` → `promptLean` (absent when no spec wrote one — never the parent's effective traits), `RunWorkflowToolDeps.parentPromptProfile` → `parentPromptLean`, and `RunInternals.promptProfile` is gone (no engine path wrote it). A spec still carrying `promptProfile` refuses the task by name (`config.spec_retired_key`, also before a resume's claim); a workflow tool wired with `parentPromptProfile` refuses at construction (`config.deps_retired_key`). **Who is hurt:** every request-key chain that carries the profile — sdk `TaskRequest.promptProfile`, client-core's `promptProfileWireCaps.ts` and the `request/taskRequest.ts` field matrix, cli's `SEMA_PROMPT_PROFILE` A/B lanes (`seamQuery.ts`, `seamQueryEngine.ts`), server's admission check, `spec-fields.ts` `promptProfileFromBody`, `resolve-spec.ts` and `TASK_BODY_DECLARED_KEYS`, web-client's turn-request ledger (`ui/lib/engine/turn-request-ledger.ts` lists the key), test's cli adapters that forward `SEMA_PROMPT_PROFILE` (read in the neighbours' origin/main, not run) — and a library host that writes the field. **How to migrate:** send `promptLean: false` where `promptProfile: "classic"` was sent; drop `promptProfile: "simple"` (or send `promptLean: true`). The keys rename in one wave (sdk → client-core → cli → server): an old client's `promptProfile` reaching a new engine is refused, never served lean.
671
+ - **Hard-breaking (open to veto): the model axis is the model's declaration.** **What changed:** `Model.promptGuidance` is retired (a model carrying it refuses the task by name, `config.deps_retired_key` — its text would otherwise silently not reach the model), with the `model-guidance` renderer and the `core/behavior.model-guidance` section (rank 450, block `model.guidance`); the engine no longer reads `model.id` to light the fable sections (`isFableFamilyModelId` and `resolveModelPromptTraits` are gone; the tool-registration model gate still reads ids — another axis); the internal `FABLE_5_PROMPT_GUIDANCE` constant is gone (its three lines are catalog text, `core/behavior.fable-classic.*`). **Who is hurt:** a deployment whose model entry carries `promptGuidance` (settings-schema `ModelEntry.promptGuidance`, the web-admin model form, server's `config-provider.ts` CLOSED projection and `web-admin-client/apply-effective.ts`) — every task on such a model now fails until the entry moves to traits; a deployment that runs a fable-family model id through a model entry that declares no traits — its runs lose the fable sections and take the plain lean shape (the 2×2: lean × `fable5Model()` and classic × `fable5Model()` unchanged; a model declaring `fable_5_mitigations` without `lean_prompt` gets the classic shape, by the exact-set rule); a reader of the manifest's `model.guidance` block. **How to migrate:** declare `promptTraits` on each model entry (the reference registry's capability list per model — the settings-schema preset table the design names; `fable5Model()` already declares its own); move each `promptGuidance` text into a center or deployment `add` to the `behavior` band whose `when.traits` names a trait the model declares (an `x-<namespace>-<name>` for a deployment's own guidance).
672
+ - **Hard-breaking (open to veto): a tool's long form is a description variant.** **What changed:** `ToolSpec.descriptionClassic` and `Tool.descriptionClassic` are retired for `descriptionVariants?: readonly ToolDescriptionVariant[]` (`{ id, traits, text }`); a spec still carrying `descriptionClassic` is refused by name (`config.deps_retired_key`) — where `defineTool` reads it, and at a run's roster snapshot for a raw `TaskSpec.tools` entry (the object a tool's `withAgents` returns included), which asks the live entry once with ordinary property lookup, so an own member, a non-enumerable one, a class tool's prototype getter and a Proxy's trap are one refusal with one sentence (a getter that throws on the key refuses the entry as unreadable, `config.tool_mount_denied`; a class tool without the key mounts as before); `ToolContractDescriptor.cardId` (and the manifest's `tools[].cardId`) gains `#<variant id>` when a variant was chosen. **Who is hurt:** a caller tool that declared `descriptionClassic`; a reader of `cardId` that compared it to `builtin:<wireName>` on a run without `lean_prompt`. **How to migrate:** write `descriptionVariants: [{ id: "classic", traits: { noneOf: ["lean_prompt"] }, text: <the old classic text> }]`; read `cardId` up to `#` for the card.
673
+ - **Behavior change (open to veto): the task layer's identity and two records it writes.** **Privacy:** whoever holds a session's exported records and a candidate text can now test whether that was the task's role text (an equality check, not a recovery; before, the record carried the text's length). **One extra record:** a session forked or imported under a new id records `code_change` naming `task` on its first leg (the digest is keyed by the session id). **On upgrade:** every session's first leg on this version records `code_change` naming `catalog` and `renderers` (the model-guidance section left the catalog and its renderer left the renderer vector) and, when the task layer has entries, `task` (the projection's member changed) — once.
674
+ - **Where the refusals land on a resume.** A re-supplied tool's card declarations — a `descriptionVariants` table the judge refuses, a retired `descriptionClassic` — are judged before the claim (`assertToolCardDeclarations`; the row stays pending and the corrected resume redeems the same token); the screen reads plain data and runs no caller code, so a retired `descriptionClassic` that only a lookup finds (a class tool's prototype getter, a non-enumerable member) is left to the roster snapshot's door and refuses after the claim with the same code and sentence (the roster-screen limit already stated for `boundaryLegs`); a `descriptionVariants` table that a class tool or a getter holds is not plain data to the screen either, and is judged at the mount, after the claim (KL-469-i). The model's `promptTraits` and a retired `promptGuidance` are model doors and refuse after the claim like the other model doors (KL-1950-a: judging them before would be a second model resolution against a catalog `swapDeps` may replace in between) — a store without `reopen` keeps that row consumed.
675
+ - **Wire.** The turn snapshot's cache identity is eight elements: `policyDigest` is gone (`prompt.assembled` `snapshot.elements`, `prompt.snapshot_changed`) — the traits are selector values, already digested by the identity's binding element inside `artifactDigest`. The selector record (`prompt.assembled` `selectors.traits`, the `prompt.compose` event) lists the effective set: declared names, bundle members, a deployment's own names. No neighbor reads `policyDigest` (sdk / client-core / cli / server origin/main, grep).
676
+ - **Not in this release.** The design's full M2b — every built-in tool card as catalog data, overlays on the tool-card band, `tool.describe` → overlay, the catalog-literal gate over `src/tools/**` — waits for M3's typed message parameters (two of the four long forms here interpolate mount facts). A second cache breakpoint at the end of the byte-stable prefix (A-K10: measured offline, +9 249 bytes reusable on a new session's first request; provider caching not measured) is its own change (#2281). `delivering_work` / `corrections` still compose on `fable_5_mitigations` rather than the reference's `bison_cairn` / `larch_cistern` (KL-1604m0-c; #1607's to decide, now that models can declare those traits).
677
+ - **Pins.** `test/backlog1663-prompt-traits.test.ts` (the trait-name reading's accept / refuse grid; the model door — unknown name, misspelled `x-`, non-list, retired `promptGuidance`; the manifest's selector record; the lean door and the retired `promptProfile`; the D24 path through a real center artifact; a bundle member reaching `when.traits`; the artifact requirement order; Read's card lean / classic with `cardId`; a caller tool's first-match variants; six malformed tables; a retired `descriptionClassic` under six readings of the key on the real Runner, one sentence with the factory's, an unreadable getter, the controls; #1793 same-length replacement, reproducible digest, fork's one record; a fable id with no traits). `test/prompt-golden-matrix.test.ts` refrozen: the 32 groups × 2 048 combinations equal the pre-change tree's matrix with the guidance input removed, group for group (the one declared byte change is the retired section). `test/tool-face-controls.test.ts` (variants by traits, child inheritance, `resolvePromptTraits`'s exact set and switch, no id read), `test/prompt-fable-preset-dedupe.test.ts` (the 2×2 and the exact-set cell), `test/prompt-golden-freeze.test.ts` (the guidance case is now a model-selected overlay, declared refreeze).
678
+
679
+ ### Three bounded query faces on `CheckpointStore` — `listOverdue`, `listBySession`, `deleteSessionCheckpoints` — with one reading of each query, and the file store's `delete` ledger event (#1468; additive exports: +18 (8 runtime: `readCheckpointOverdueQuery`, `readCheckpointSessionQuery`, `readCheckpointDeleteGuard`, `selectOverduePage`, `selectSessionPage`, `CHECKPOINT_LIST_LIMITS`, `checkpointDeadlinePassed`, `deleteGuardAdmits`; 10 types: `CheckpointDeleteGuard`, `CheckpointFacet`, `CheckpointOverdueCursor`, `CheckpointOverdueQuery`, `CheckpointOverdueQueryReading`, `CheckpointPage`, `CheckpointRowRead`, `CheckpointSessionCursor`, `CheckpointSessionQuery`, `CheckpointSessionQueryReading`); members: `CheckpointStore` gains three optional faces; a store-format forward change)
680
+
681
+ - **Before.** A deployment's operator half (the approval queue, the deadline sweeps, the decide and resume legs, the reconciler, the session delete) asked a checkpoint store questions the `CheckpointStore` interface had no face for — "the overdue pending rows of every scope, by deadline semantic", "the newest pending row of this session on these gate kinds", "the rows that parked this tool call, in any status", "does this session hold an expired row", "delete this session's rows if its owner is X" — so a backend answered them with methods of its own class (fourteen on the server's SQL store, with a second implementation of each on its file twin, which kept two side indexes — a scope registry and a session→token index — because the core file store could not enumerate across scopes, by session or across statuses). The two `reap` verbs each wrote the deadline rule for an uncomparable deadline inline.
682
+ - **Now.** (1) `listOverdue(query)`: the PENDING rows of every scope whose deadline has passed `cutoff` and whose gate reads as `facet` through `facetOf` (a question parked under an approval gate kind is `B`), ordered (deadline, token), keyset-paged — `{ rows, next? }` — at most `limit` rows (1..500, default 100); a candidate row whose shape the face cannot read, or whose summary cannot be projected, is left out and disclosed, and the page fills on from the next candidate (`next` is present when a candidate remains after the page's last row). (2) `listBySession(query)`: the rows of ONE session in any status, narrowed by `status` / `gateKinds` (an empty list matches none) / `toolCallId` / `scope` / `createdSince`, ordered (createdAt, token) `oldest` or `newest` first, at most `limit` rows (1..100, default 100); each row answers `{ kind: "row", checkpoint }`, or `{ kind: "unreadable", token, scope, sessionId, status, createdAt, problem }` (the token = the key the store files it under; `scope` `null` when it is not a string) for a row of the session whose stored shape the face cannot read — a filter excludes a row only on a member it can read — answered rather than skipped (an unreadable row is not an absent one) and rather than failing the page (one damaged row does not hide its readable siblings), and disclosed through the store's seat; the bundled file and in-memory stores produce it today for a hand-edited or later-engine row among the rows they hold — the file store's restart replay drops a row whose token, scope or status is not a string before any face can answer it (disclosed once, at the replay), so across a restart such a row is neither listed nor counted by the delete; a row whose session id is not a string belongs to no session, and the listing leaves it out and discloses it. (3) `deleteSessionCheckpoints(sessionId, { owner })`: the session's checkpoint rows that exist when the call starts (taken before the guard's head read, so a row another owner's new session parks while it is awaited is never deleted), in any status and every scope, only when the session's row exists and its owner equals the guard's (null-safe); otherwise `0`. Present exactly when the store was built with a session head read (`sessionHeadOf`, a session store's `headOf`); `FileStorageBackend` wires its own. (4) One reading per query (`readCheckpointOverdueQuery` / `readCheckpointSessionQuery` / `readCheckpointDeleteGuard`): a bad value or an unknown member is refused `config.invalid_argument`, never defaulted. The page selectors the first-party stores run (`selectOverduePage` / `selectSessionPage`), the bounds (`CHECKPOINT_LIST_LIMITS`), the deadline rule (`checkpointDeadlinePassed`, now read by both `reap` verbs too) and the guard's judgment (`deleteGuardAdmits`) are exported for a backend that answers the same faces. All three faces are optional and probed by presence; the store-contract kit judges each when a backend offers it (`checkpointStoreContract(make, runner, { sessionHeads })`).
683
+ - **Store-format forward change (open to veto).** (1) **What changed:** `FileCheckpointStore.deleteSessionCheckpoints` journals a new ledger event kind, `delete`. (2) **Who is hurt:** a deployment that rolls its binary back to 7.55.0 or earlier while a `delete` event is still in the live checkpoint ledger — that binary refuses the replay (`checkpoint.unsupported_version`, `reason: "ledger_event_unknown"`), loudly, for the whole data root; after a compaction has folded the event away the directory reads on the older binary again. (3) **How to migrate:** before rolling back, compact the checkpoint ledger (`FileCheckpointStore.compactNow()`, or let the store compact itself at its `compactEvery` count) so no `delete` event remains live, or stay on this version; a deployment that never calls `deleteSessionCheckpoints` writes no such event.
684
+ - **Who else is touched.** A store wrapper that forwards every member now forwards the new faces too, and the kit holds it to them; a store-contract binding whose backend drops the gate's approval card on the way back now also fails the kit's overdue case (the card decides the facet).
685
+ - **How to migrate (consumers).** *server* (stage three of the store boundary): compose the operator half on these faces and the existing ones — `findPendingTokenBySession` / `peekPendingScope` = `listBySession({ status: "pending", gateKinds, order, limit: 1 })`; `findCheckpointCandidatesForAsk` = `listBySession({ scope, toolCallId, createdSince })` read to the last page, an `unreadable` row or a row above `MAX_SUPPORTED_CHECKPOINT_VERSION` read as an unparseable candidate; `findDecidedApprovalsForBinding` = `listBySession({ status: "resolved", toolCallId, limit: 8 })`; `hasExpiredBySession` = `listBySession({ status: "expired", limit: 1 })`; the deadline sweeps = `listOverdue({ facet })` + `expire` / a denying resume; `listPending` = `listScopes()` × `listByScope(scope)` × `get(token)`; `peekScopeByToken` = `get(token)?.scope`; the session delete leg composes on the new face `deleteSessionCheckpoints(sessionId, { owner })`. Your own `deleteBySession(sessionId, owner)` keeps its contract and needs no change: it is not a member of the interface, the face's name is deliberately a different one, and the kit neither probes nor calls it (a store that carries it and not the new face is judged as not offering the face). The kit's `sessionHeads` option is a map the kit writes the heads of its delete case into, for a store that reads its session heads from memory; a store that judges the guard inside its own statement (an SQL store would) binds the kit by mirroring each entry the kit writes there into its session rows before the delete is asked. The checkpoint ledger's side indexes of the file twin then have nothing left to answer. One summary-projection gap stays (the approval queue's `boundInputHash` / `riskDescriptor` / `ruleOffers` / `ruleOffersAbsence` / `askClass` are not on `CheckpointSummary`, so the queue reads them with `get`): a candidate ticket, not a face.
686
+ - **Pins.** `test/backlog1468-p2-faces.test.ts` (the kit's three cases on both stores, each face offered; presence; the delete case judges a face that deletes nothing; the face's name is its own — a store carrying `deleteBySession(sessionId, owner)` and no new face compiles as a `CheckpointStore` and is judged as not offering the face, nothing probed and nothing called; the file store's restart and compaction; the unreadable arm on both stores and across a restart; the bundled backend's wiring; one reading per query; the five session reads composed against the SQL predicates they replace, with the two known differences named; the `get`-based scope read; the review's reproductions — a delete racing the id's re-creation by another owner, an overdue row whose summary cannot be projected, a row whose token member is not its key, a guard member supplied by `Object.prototype`, gate kinds read without calling the caller's array, a row whose scope is damaged, the page cut before any copy); the kit's three cases in `checkpointStoreContract`.
687
+
688
+ ### The mods kernel, wave two: the shell's stations, `call` and `budgetMs` columns, one side lock, answers received as data, the seat-trust law, `ClassicEventMap`, `prompt.mention` on the engine's bus, and `tool.call` between the gate's allow and the tool's body with `$.tool.call` — an extension's own call through the run's own per-call pipeline (#1933 slices K + M + S1, #1787, #1922, #1925; security; behavior narrowing and behavior change, open to veto; hard-breaking: 5, below; closed sets: `MOD_EVENT_TABLE` +25 stations and `prompt.mention` side surface → engine, `MOD_EVENT_TABLE` +1 station (`tool.call`, callable), `MOD_FAILURE_ANSWERS` / `ModFailureAnswer` +`refuse_until_run`, `ExtensionRequester.kind` new (`extension`), `UserMessageAttachment.notes` +1 word; wire: `tool_start.requestedBy`, `tool_end.requestedBy`, `tool_end.answeredBy`, `AskRequest.requestedBy` new optional members, `tool_end.notExecuted` one more source; exports: root +4 types (slices K + M) and +2 types (slice S1: `ExtensionRequester`, `ToolCallAnswer`), `@sema-agent/mods-kernel` +2 functions +9 types; `KERNEL_CONTRACT_VERSION` 2 → 3, once for both slices; slice S1 is 24 source files (four of them a type or an export line: `types.ts`, `index.ts`, `task-event.ts`, `tool-policy.ts`), above the 15-file slice guidance: the station and its call are one event, and splitting them would ship a station with no call — a transitional arm) (@server @cli @client-core)
689
+
690
+ - **The table.** The shell's events have stations in `MOD_EVENT_TABLE` (`ui.render`, `ui.resolve`, `ui.press`, `ui.input`, `ui.select`, `ui.scroll`, `ui.focus`, `prompt.fill`, `prompt.suggest`, `prompt.edit`, `command.run`, `command.describe`, `config.set`, `config.describe`, with CC 2.1.296's pinned keys), and ten surface methods (`ui.toast`, `ui.log`, `ui.notice`, `ui.invalidate`, `ui.selection`, `ui.copy`, `prompt.read`, `command.list`, `command.register`, `config.list`). Two columns: `budgetMs` — an event's own ceiling on every clock a handler runs under on it (`prompt.edit` 50 ms; a registration never widens it) — and `call` — the event is callable through `$`: the call reaches the host's entry for it (`ModBusInput.entries`), which raises the event once at its station under the caller's frames, its answer being the call's (a `{ deny }` is returned). A declared name the bus cannot call in this version throws, naming it (before: no member, a bare `TypeError`).
691
+ - **One side lock.** `uses.events`, `uses.calls` and `on()` read one predicate (`expandOnSide`): an exact name of the other side is refused (`config.mods_invalid`, naming the side); a wildcard selects its bus's names and must select one. Before, a surface name in `uses.calls` was dropped silently and an exact surface name in `uses.events` was admitted.
692
+ - **Answers are data (#1787).** Every handler answer is received once as data — a primitive, or a plain record or array copied whole and frozen; a function, a class instance, a symbol, a bigint or a cycle the handler built is a site violation. A sealed object — frozen data all the way down — of the dispatch's own `e`, or of what the handler's `next` answered (taken the moment it came up, before the handler could touch it), is kept as itself; anything else is received like the handler's own value, so an object it built and planted inside a mutable member of what came up is still refused. What a chain's bottom answers — a station's core, a `$` method's seat value — is received by the same receipt before any handler sees it: frozen all the way down (an object of another kind handed as it is), so a handler that writes into it throws, its failure is reported like any other, and what it tried to plant never reaches the station or the caller. Before, a class answer was handed to the station as it was, so a `tool.check` answer whose `decision` getter read `deny` for the check and `allow` after was taken as `allow`, and `classic.UserPromptSubmit` read a `block` that emptied after its check. `prompt.section`'s own data check is retired (the bus's is the one); `prompt.compose` and the `classic.PreToolUse` decision receipt meet a non-data answer as the bus's violation sentence (`answered a value that is not data at …`).
693
+ - **The seat-trust law (#1925).** A dispatch of an event whose failure column refuses names its answer ranks (`ModDispatchOptions.ranks`: 0 passes, `top` refuses outright — the engine's three judging stations, `prompt.mention` and `plugin.register` do; a dispatch that names none is not run). A less trusted seat's answer never stands over a more trusted seat's stricter one: a task-seat handler that answered after its `next` may not refuse less than what the deployment's handlers below it answered (a deployment handler may overrule another below it; the floor travels up with the answer); one answering without its `next` having answered — never called, or still running — while a deployment handler below it never got to judge stands only with an outright refusal; anything else is a violation and the station refuses. A station's ranks read everything that makes an answer stricter: `classic.PreToolUse` ranks deny 3, an ask a person must answer (`requiresRealApproval`) 2, a plain ask 1 — so a task handler that calls `next` and answers a plain ask over a deployment's mandated ask is refused, as it is without `next`. Before, a task `user` handler answering `allow` without `next` passed over a deployment `append` guard's deny (`tool.check`, `classic.PreToolUse`, `classic.UserPromptSubmit`). No tier is special — the law reads seats. Stricter than CC 2.1.296, which has no task seat (its managed hooks sit at prepend and merge with what is below them).
694
+ - **Re-entry.** On a refusing event, a handler above the raiser that judges and has no `.catch` is a guard that cannot answer: the dispatch refuses for it (`failed`, cause `reentry`, the guard named), so a nested `$` call never passes a guard of the chain it was made from. Skip events pass it over as before. (Reached only through a callable event's entry; no wave-one path re-enters a refusing event.)
695
+ - **The dispatch's record.** A station's answer check reads `run.bottomStarted` (`ModDispatchRun`); a cancel after the bottom started is `{ kind: "aborted", bottomStarted: true }`.
696
+ - **`ClassicEventMap` (#1922).** Each `classic.*` event a station raises with its `e` and its answer form (keys held to the table's `classic.*` rows with a station; `ClassicEventName`, `ObservedAnswer`); `dispatchClassic` / `observeClassic` / `screenUserPrompt` construct their `e` against it, and `RunnerDeps.mods` / `TaskSpec.mods` hold `EngineModRegistration`s, whose `on` types a classic handler by the map (kernel: `MappedOn`, `ModTypedNext`, `ModTypedHandler`, `ModEventPair`, `ModEventMap`).
697
+ - **`prompt.mention` (slice M).** Raised for each declared attachment before the engine reads it (§10.9): `e = { mention, agentId?, path?, offset?, limit? }` (`mention`, `agentId` pinned); the chain's bottom is the engine's read of what is handed down (a path entry's path and line window may be re-spelled; the read rules judge what is read, and a guard above a re-spelling handler sees only the declared path — a guard that must judge the file that will be read hangs at `append`, below every handler that may re-spell: the seat-trust law governs answers, not what a handler hands down); a handler answers `next(e)`, `{ deny }` or `{ type: null }` (not attached, the frame naming the extension) and never supplies contents; a failing handler refuses (not attached). The frame's record carries the note `extension_declined`. All three supplies (`path`, `content_text`, `content_image`) are raised alike; no handler, no dispatch.
698
+ - **Hard-breaking (open to veto).**
699
+ 1. *`KERNEL_CONTRACT_VERSION` 2 → 3.* What changed: the event table's stations and columns, the side lock, `$` callable events, the answer receipt, the seat-trust law, the re-entered guard and `prompt.mention`'s side (above). Who is hurt: a host that loads a kernel copy beside the engine and compares the number — none today (server, cli and client-core load no kernel copy). How to migrate: take the kernel from the same core version.
700
+ 2. *A refusing event's dispatch must name its ranks.* What changed: `ModBus.dispatch` of an event whose failure column refuses, with no `ranks`, is not run (`failed` by the engine, reported). Who is hurt: a host raising `classic.PreToolUse`, `classic.UserPromptSubmit`, `tool.check`, `prompt.mention` or `config.set` on a bus of its own. How to migrate: pass `ranks: { of, top }` — 0 for an answer that passes the event on, `top` for an outright refusal.
701
+ 3. *Other-side names and non-data answers are refused.* What changed: an exact other-side name in `uses.events` / `uses.calls` (or `on()`) refuses the prepare; a handler answer that is not data is a site violation — on a refusing station the dispatch fails and the station refuses (the call, the prompt, the attachment, the candidate), on a skip station the handler is skipped and the skip is reported on `onError`. `plugin.register` is a refusing station: a gatekeeper that fails — a throw, an overrun, or a refusal carrying a member that is not data (`{ refuse, cause: new Error() }`, `{ refuse, at: new Date() }`, a frozen class instance) — refuses the candidate (`mods.registration_refused`), never admits it by a skip. Who is hurt: a registration declaring a name its bus never reads (it was never usable), a handler answering a class instance or carrying a function, a gatekeeper that throws (its candidates are now refused, not admitted). How to migrate: drop the other side's names (or use a wildcard); answer plain records and arrays.
702
+ 4. *The seat-trust law.* What changed: a task-seat answer less strict than a deployment answer below it, or one given without `next` over a deployment handler that did not judge, refuses on the judging stations. Who is hurt: a task registration that allowed (or added context) without `next` in a deployment with `append`-tier guards. How to migrate: call `next(e)` first and answer at least as strictly as what it returned, or refuse outright.
703
+ 5. *The failure column's closed set gains `refuse_until_run` (slice S1).* What changed: `MOD_FAILURE_ANSWERS` / `ModFailureAnswer` is `refuse`, `skip`, `refuse_until_run`. Who is hurt: a host that switches over the column with two arms (a TypeScript exhaustiveness check fails) or tests `=== "refuse"` (it reads `tool.call` as a skip event). How to migrate: read `failureRefuses(failure)`, the kernel's one reading.
704
+ - **Signatures (the wire tool lists both as signature changes; neither hurts an implementer).** `ModAnswerCheck` gains a fifth argument `run: ModDispatchRun` — a check written with four still fits; a host that itself CALLS a check it holds passes the run. `ModBus.dispatch` takes any object as `e` (was `ModEventPayload`) — a wider parameter; a bus a host implements still fits the method. `ModDispatchOutcome`'s `aborted` arm gains an optional `bottomStarted`.
705
+ - **Not hard-breaking, named.** `TaskSpec.mods` / `RunnerDeps.mods` are typed `EngineModRegistration[]`: a `ModRegistration<EngineDollar>` still fits; an inline handler of a `classic.*` event is now typed by `ClassicEventMap` (a TypeScript host that read a member outside it annotates `e: ModEventPayload`).
706
+ - **Consumers (the shell's stations, the bus, `prompt.mention`).** @cli — L-1109: the shell's stations, the `call` column, entries and the event ceiling are in the table (the shell raises them; config.set dispatches name ranks); `prompt.mention` is the engine's (W2C2 drops its mention piece; the three supplies are sent as today); render `extension_declined` on a frame that was not attached. @client-core — re-export the kernel at this version; the side per name and the station columns are read off `MOD_EVENT_TABLE` (never copied); `UserMessageAttachment.notes` may carry `extension_declined`. @server — `TaskSpec.mods` / `RunnerDeps.mods` element type; a host-built dispatch of a refusing event names its ranks; publishing the contract number on a handshake face is the server's design (`KERNEL_CONTRACT_VERSION` from the core it packs).
707
+
708
+ - **`tool.call` (slice S1; INTEGRATION-CORE §10.10).** Raised on the leg's bus for every execution of a call the gate let go — the live loop's lanes through one new execution seat (`AgentLoopConfig.executeTool`), the resumed leg's approved call through the same function; no handler, no dispatch. `e = { tool, tool_use_id, agentId?, input }`, all four pinned. A handler answers `next(e)` (what comes up is a frozen view `{ result: { content, isError } }` of the real result), `{ deny }` before the body (not executed; `extension "<name>" refused this call …`), or `{ result }` in place of the body — taken only when the call's latest gate record is an allow no one settled, released or cleared, and the body has not started; otherwise a violation. A deny and a result are answers in place of `next` (the `$` methods' law): given while the handler's own `next(e)` still runs, they are a violation and the call is refused before anything below can start the body. A new failure answer `refuse_until_run`: a failure while the body has not started refuses the call; once it has, the body's real result stands (a late deny or a late result is such a violation, the handler reported on `onError`). The start is one fact the dispatch records, read the same for a deny as for a result: a deny given after the body started is a violation whether the body's result came up through the handler's `next(e)` or the body threw out of it — a handler that catches the body's throw and answers `{ deny }` (or whose `.catch` does) does not turn a call that ran into one that did not: the body's own outcome stands, `tool_end` carries no `notExecuted`, the model reads the tool's own error, the verification turn is armed, the call stays in `classic.PostToolBatch`, and `$.tool.call` answers the tool's own result rather than `{ deny }` (open to veto: before, a call whose write had happened was reported to the model as "refused … before it ran"). A `.catch` that gives no answer a station takes over what the run below threw — one that answers nothing, answers a form the site does not take, replays only `next`, or throws itself — is now said on `onError` (on every event; the throw still stands) — it was dropped without a line; a host that counts `onError` lines sees one more for each such `.catch`. A cancel before the body ends the call `Operation aborted`. Its ranks: a deny refuses outright (the seat-trust law, #1925, holds here as on the other judging stations). Whatever an extension answered in place of the body, the call's post-execution stations stay silent; `tool_end` carries `notExecuted: true` and `answeredBy`.
709
+ - **`$.tool.call({ tool, ...input })`.** The event called (`uses.calls: ["tool.call"]`): the engine's entry runs the loop's own per-call pipeline for the named mounted tool — one function the sequential lane now runs too (`runToolCall`), through `AgentHarness.callTool` under the running prompt's config: `tool_start`, the whole gate (rules, mode, `classic.PreToolUse`, `tool.check`, the ask), `tool.call`, the body, the post-execution stations, `tool_end`. It runs as the run's principal; the registration name is only an origin. It is not part of the conversation (no tool-result message, no `classic.PostToolBatch` batch). Every event its pipeline raises is raised under the caller's frames and origin, carried by one async-scoped record of the call that is live only while the call runs — a dispatch from a timer or a detached promise the call started, after it settled, is refused on the leg bus and reported. It answers `{ result: { content, isError } }` or `{ deny }` (returned). It throws on a missing `tool`, a name the run mounts no tool for, outside a running prompt (a station raised before the loop, the objective's `classic.UserPromptSubmit` screen included), and for the engine's `Agent`, `AskUserQuestion` and `Workflow` — known by their catalog contract, whatever name they are mounted under — naming each one's own door. It never shares the run with a call that has the run to itself: judged at the gate's allow exit on the arguments the gate lets go, with the loop's own predicate (`isBatchSafeToolCall`) — one that is not batch-safe is let go only when no other call is in flight, a batch-safe one only while no call that is not batch-safe has been let go; otherwise the hook layer refuses it there (two made at once may both be refused, never both run). It has no park lane: an ask for it is put in-stream or not at all, and the run never parks for it.
710
+ - **What a call that never ran leaves behind (behavior change, open to veto).** A call the engine refused before it was let go, or one an extension answered in place of its body, no longer arms the final-verification turn (`TaskSpec.finalVerification`: a write that never ran produced no deliverable), live or seeded from a resumed session's rows (`details.notExecuted`), and never joins a `classic.PostToolBatch` batch — read off the occurrence's evidence, not off a marker the post-execution station consumes.
711
+ - **Wire.** `tool_start` / `tool_end` and the approval card an ask for an extension's call puts to the person carry `requestedBy: { kind: "extension", name }` (`ExtensionRequester`, a display fact; the call's occurrence is its one record). `tool_end.answeredBy` names the extension that answered in place of the body; `tool_end.notExecuted` has one more source (an extension answered in place of the body). The persisted `toolResult` row repeats `notExecuted` but not who answered.
712
+ - **Against the reference (CC 2.1.296; ids registered in the divergence book).** `input` is pinned (CC lets `tool.call` rewrite it — a rewrite belongs to `classic.PreToolUse`); a deny or a result after the body started is a violation (CC's `isLateRefusal` only warns); a result is taken only on a call no one was asked about and before the body started (CC takes it at any time); CC's `{ result, context }` `context` is not taken (context is `classic.PostToolUse`'s); `$.tool.call` answers the station's own view, not CC's `{ result, text }`; the in-flight rule and the absent park lane have no CC counterpart (CC neither batches by `cd` nor parks). The three doors are CC's (`j2e`, cli296.js:249224).
713
+ - **Consumers (`tool.call`, `$.tool.call`).** @server — pass `requestedBy` through on `tool_start` / `tool_end` and the approval card, and project it in `trace/project`; `answeredBy` and the extension source of `notExecuted` (a call an extension answered never ran — a reader that counts `notExecuted` as replayable, or bills by it, meets one more source). @cli — the card's line of origin ("extension X wants to run …") and the transcript row's; a call an extension answered is not drawn as run. @client-core — project `requestedBy` and `answeredBy`; `ExtensionRequester.kind` is a closed set of one (`extension`); `MOD_FAILURE_ANSWERS` is read off the kernel at this version.
714
+
715
+ ### One deployment host floor — `RunnerDeps.hostFloor = { deny?, only? }` — read by the gate and by the engine executors that connect on the Runner that declares it (a delegated child prepared by another Runner reads that Runner's deps, KL-2212-b): the fetch tool on every hop, task images, each MCP http dial before a pooled connection is leased, every A2A request and redirect hop, WebSearch's SearXNG backend on its request and every redirect hop; `WebFetchConfig.allowHosts` and `RunnerDeps.allowImageUrl` retire into it (#2212, design/1489 car B; security; behavior narrowing, open to veto; hard-breaking: H1 `WebFetchConfig.allowHosts` removed, H2 `RunnerDeps.allowImageUrl` removed, both open to veto; closed sets: `CHECKPOINT_CONFIG_REFUSAL_CODES` 44 → 45 (+`config.host_floor_invalid`); new codes `config.host_floor_invalid` and `config.host_floor_refused` (open `config.*` family); exports: +5 (`compileHostFloor`; types `HostFloor`, `HostFloorDeclaration`, `HostFloorPosture`, `HostFloorReach`); members: +1 `RunnerDeps.hostFloor`, −1 `RunnerDeps.allowImageUrl`, −1 `WebFetchConfig.allowHosts`, +1 optional `ToolExecuteContext.hostFloor`, +1 optional `WiringManifest.hostFloor`; signatures: `materializeMcpTools` and `materializeA2aTools` gain a trailing optional `hostFloor`) (@server @cli @client-core @connectors @test)
716
+
717
+ - **Before.** "This deployment never reaches X" had no seat. WebFetch carried its own host list as a factory option (`allowHosts`, which only whoever built the tool could pass — the deployment's deps could not), task images had a predicate that *replaced* the default image guard (`allowImageUrl`), and neither an MCP http endpoint nor an A2A peer was bounded by anything: an A2A request without declared headers followed every redirect (`redirect: "follow"`), so a peer's 307 took the request to a host no declaration named. A deployment that needed a bound gave up the fetch tool (server's multi-tenant arm removes WebFetch).
718
+ - **Now.** One declaration in the host rule's spelling (`example.com`, `*.example.com`; read by the rule side's one host-pattern reader, `readHostPattern`, and matched by its one matcher, `hostPatternAdmits` — the rule parser and `domainRuleAdmits` call them too), compiled once in the config doors (`compileHostFloor`). `deny` entries are never reached; `only` present bounds everything else (`only: []` = nothing the six readers below reach); an `only` entry naming a private address or `localhost` lifts the private-network floor for that host. One hop judge (`hopRefusal`, `src/core/host-floor.ts` — `isPrivateHost` moved there, its one home) is read by six readers: **the gate**, a deny-only narrowing layer core owns, leading the block — a call whose URL slot names a host the floor does not admit, or no host it can read, is denied (`decisionReason: "rule"`) before any rule, mode (`bypassPermissions` included) or approver, and the rewrite re-check and the resume re-check read it like the other narrowing layers; **the fetch tool** on its first URL and every redirect target, read from the run's context (`ToolExecuteContext.hostFloor`), never a factory closure; **task images** (https, the floor, the private-network floor; still no redirects); **MCP http** at every dial, before `McpConnectionPool.acquire` — a refused server is skipped `failed` / `invalid_config`, never contacted; **A2A** on every request and redirect hop; **WebSearch's SearXNG backend** on its request and every redirect target, read from the run's context like the fetch tool's (the tool hands it in `opts.hostFloor`), a refused hop never requested (#2325). The fetch tool, A2A and the SearXNG backend follow redirects through one loop (`fetchFollowing`): each hop judged before it is requested, the method of a followed hop decided once (303, or 301 / 302 answering a POST → GET with no body; 307 / 308 keep both; a GET or HEAD request keeps its method); what each takes is its own law (the fetch tool: same-host only, others handed back; A2A: none with declared headers, any judged hop without, up to 20 as `fetch` did; the SearXNG backend: any judged hop, up to 20). The wiring manifest reports the posture (`hostFloor: { onlyMode, denyCount, onlyCount }`, never the entries) iff declared.
719
+ - **H1 — `WebFetchConfig.allowHosts` removed (open to veto).** *What changed:* WebFetch's host list is no longer a factory option; `webFetchToolSpec` / `createWebFetchTool` / `assembleCodeTools({ webFetch })` refuse an `allowHosts` member by name (`config.deps_retired_key`), and the tool reads `RunnerDeps.hostFloor` from the run on every hop. *Who is hurt:* a deployment or a harness that passes `allowHosts`; it now fails at the factory. In the neighbouring repos' origin/main (read, not run): server, cli, client-core and sdk pass none; **@test** does — `repro/webfetch-ssrf-internal-address-boundary/repro.mjs:180,274` (`allowHosts: ["127.0.0.1"]`, the reachable control) and seven more repro scripts (`core548-g1g5-criteria/g2-construct.mjs`, the `webfetch-summarization-gap` / `webfetch-tool-error-isError-blind` repros, two `.negative-scratch` copies; `core7230-G-20260918/g1*.mjs` pass `allowImageUrl`, H2). *How to migrate:* move the list to `RunnerDeps.hostFloor.only`. This is a capability change in two ways: the same list now bounds task images, MCP http and A2A — list those endpoints' hosts too or they are refused — and an entry that names a private address (`127.0.0.1` for a local MCP server) lifts the fetch tool's private-network floor for that host on every port; to keep the fetch tool narrower than the floor, add rule-layer tightening (`WebFetch(domain:127.0.0.1)` deny, or an ask on `WebFetch` plus `RunnerDeps.allowedTools: ["WebFetch(domain:docs.test)"]`).
720
+ - **H2 — `RunnerDeps.allowImageUrl` removed (open to veto).** *What changed:* a task image URL is judged by the one guard (https, the floor, the private-network floor); no predicate replaces it. The seat is refused by name at construction and at the deps door (`config.deps_retired_key`, row in the retired-seat table). *Who is hurt:* a deployment that used the predicate for a CDN list or a resolve-and-pin check (none in the neighbouring repos' origin/main — read). *How to migrate:* a CDN list → `RunnerDeps.hostFloor.only` (it bounds every structured egress); a resolve-and-pin check has no seat in this version (the connection layer is design/1489 car D) — send such images inline.
721
+ - **H3 — a host the floor does not admit is refused at every reader (behavior narrowing, open to veto).** *What changed:* with a floor declared, the six readers above refuse; a refusal is not cleared by any rule, mode or approval. *Who is hurt:* a deployment that declares a floor and still names MCP / A2A endpoints, image hosts or fetch targets outside it. *How to migrate:* list them in `only` (or take them out of `deny`).
722
+ - **H4 — the private-network floor's exception is the deployment's `only` entry (behavior change, open to veto).** *What changed:* a private host is reachable by the fetch tool and by task images only when an `only` entry names it (before: an `allowHosts` entry for the fetch tool; nothing for images unless the predicate replaced the guard). *Who is hurt:* nobody who migrated by H1 / H2. *How to migrate:* as H1.
723
+ - **A host has no empty label, and a pattern's `*` is judged on the host as read (behavior narrowing, open to veto).** The one host reader (`readSpelledHost`, `src/core/host-target.ts`) refuses a spelling with an empty label once its trailing dots are read off — `.evil.test`, `a..test`, `*..test` — where it read one: such a pattern matched only a URL spelled with the same empty label, never the host it was meant to name (7.55.0 accepted `WebFetch(domain:.evil.test)`, and it did not reach `evil.test`). A pattern's `*` is its operator only as the leading ASCII `*.`; the host after it is read by the URL parser and holds no `*` as read, so a full-width or small asterisk the parser folds (`WebFetch(domain:*.evil.test)`) is refused (`unsupported.wildcard`) where 7.55.0 minted the exact host `*.evil.test` under the text `WebFetch(domain:*.evil.test)`, its text and matcher fields disagreeing. A floor entry so written refuses the compile (`config.host_floor_invalid`, the sentence naming `*.<domain>` for "every host below"), a rule so written refuses to parse (`invalid.grammar` / `unsupported.wildcard`), and a URL naming an empty label (`https://a..test/`) names no host the rules can read. `evil.test`, `*.evil.test` and a trailing dot read as before.
724
+ - **A stored rule row whose text this grammar reads as no rule is a row the rule lane cannot judge a call against (behavior narrowing, open to veto).** (1) **What changed:** the person's rule lane reads the text of each stored row a judgment of the call may read — a row of a name the call is judged under, and every path row when the path grammar judges it — with the rule reader (`parseRuleText`). A row whose text it reads as no rule (an earlier grammar's rule this one refuses: a 7.55.0 empty-label row) is a row the lane cannot judge the call against, its existing law's call form: as a deny or an ask that is eligible for the call it still tightens by its stored fields, and a call none of the rows refuses is put to a person (a real approval, class `rule_unreadable`, bypass-immune; the reason names the row and the reader's refusal: "the stored deny rule WebFetch(domain:.evil.test) is no rule this engine reads (… names a host with an empty label …)"); as a grant it grants nothing in the lane's judgment (the run's path tables outside the lane still read a stored path row by its fields, KL-2212-c). Before, the lane judged such a row by its fields alone — the empty-label deny reached nothing, so the call ran. The session partition keeps such a deny or ask of its session for the lane (it dropped it, disclosed); it still drops such a grant and every row whose stored fields its text does not spell. A row whose text reads keeps its reading — the 7.55.0 full-width-star rows (text `WebFetch(domain:*.evil.test)`, fields the exact host `*.evil.test`) included: their fields, not their text, are what the lane matches, as before (KL-2212-c: one screened view of the person's rows is the closing shape). (2) **Who is hurt:** a person whose store holds such a deny or ask: every call of its tool that no deny refuses asks; calls of other tools are untouched; such a grant stops applying. A durable row stays removable (`removePersistedRule` by its text); a session row ends with its session, as session rows always do (the persisted-store removal refuses a session scope). A sync round still moves such a local row into quarantine (the round's result names it), after which its calls are judged without it; the 7.55.0 reach of an empty-label deny (only a URL with that empty label) stays refused by the fetch tool, which refuses such a URL. A checkpoint whose session rules hold such a row refuses its restore whole (the restore's law), and a session's rules row holding one in `toolDeny` (stamped by 7.55.0 or by this version) cannot be read, so its rules refuse every call. (3) **How to migrate:** rewrite `.evil.test` as the two patterns `evil.test` and `*.evil.test`, and a full-width `*.` as `*.`; then remove the old durable row. **Cost:** a call reads the text of each such row once more (measured on a loaded host: about 150 µs for a shell row, 7–17 µs for a path, name or host row).
725
+ - **Smaller changes (open to veto).** One hop judge for MCP, A2A and images means a declared MCP http endpoint, an A2A URL or an image URL with credentials in it, or whose host does not read back as one host (`https://2130706433../`), is refused before it is sent even with no floor declared (before: `fetch` refused the first, the second was dialed); a declared MCP endpoint that is not http(s) (`ws:`) is now classified `invalid_config` with `config.host_floor_refused` where it was `unknown` ("fetch failed"). A2A follows its redirects by hand with `fetch`'s 20-hop bound, its redirect set and its method rule. A redirect, for the fetch tool too, is a 301, 302, 303, 307 or 308 (the fetch standard's set and the reference fetch tool's); a 300, 304, 305 or 306 carrying a Location is now the answer (before, the fetch tool followed or handed back any 3xx with a Location). The declaration is read once, at `new Runner`, through the engine's declaration receipt and kept as a frozen copy (a Proxy, a getter that throws, a non-plain object refuse there, `config.host_floor_invalid`), so a resume's pre-claim screen and its prepare judge one declaration. The fetch tool's description line now reads "Private/internal hosts are refused unless this deployment names them, and the deployment may limit which hosts are reachable" (model-visible). Its refusal and the image guard's are reworded around the floor (`host "x" is denied by this deployment's host floor (hostFloor.deny)` / `is outside the hosts this deployment reaches (hostFloor.only)`; `image url not fetched — …`).
726
+ - **Upgrade (fail-closed).** A WebFetch approval parked on 7.55.0 and resumed on a Runner whose floor excludes its host is denied by the resume's narrowing re-check — not executed, the approval spent, no `resume.target_moved` (the parked row is unchanged). Declare the floor after draining parked fetch approvals, or accept that refusal once.
727
+ - **What the floor is not.** The six readers' structured egress only (a search backend a deployment injects itself, such as a provider's search API, holds its own egress, #2325), and a shell command's network is the deployment sandbox's; a host is judged by its spelling, not its resolved address (KL-2212-a; design/1489 car D). It is the declaring Runner's: a delegated child prepared by another Runner (a deployment's split child-execution runner) compiles that Runner's own `hostFloor`, and one whose deps carry none has no floor (KL-2212-b; the carry is the next car's: the floor becomes a row of the tighten-only clamp table). **@server** puts Agent children on `subRunner` (`src/boot/stage-06-runners.ts:~433`, read in its origin/main): put `hostFloor` in `createSharedRunnerDeps` so both Runners carry one declaration — until the clamp-table row lands, a sub-runner without the floor has unbounded egress. The rule layer's `WebFetch(domain:…)` rows are not projected into it (a rule answers "ask about this call?", the floor "can this deployment reach it?"; Claude Code projects them into its sandbox allowlist, divergence candidate DV1).
728
+ - **Pins.** `test/backlog2212-host-floor.test.ts` (A the compile: posture, one spelling, reach, lifts, sixteen bad values and two near misses, the prepare and the resume refusals, the two retired seats; R1 the gate: deny / only / bypassPermissions / allow-all / unreadable / a caller's rewrite / the manifest; R2 every hop: a redirect into a denied host never requested, a same-host hop followed, a named private address, a rebound `defineTool` product, outside a Runner; R3 images: denied, a named private https host, http still refused; R4 MCP: never dialed and the pool never asked, through the Runner; R5 A2A: a denied peer, a 307 into a denied host never sent with its no-floor control followed, declared headers follow none; the redirect loop's method rule and hop bound; a parked fetch approval resumed under the floor). Re-pinned on the new seat: `backlog1489-host-grammar` G2d, `security-fixes`, `tools-data-coverage`, `known-issues-2026-06-15`, `full-body`, `webfetch-*`, `tool-use-result-details`, `backlog1074`, `backlog1950` (recipe), `public-knob-liveness`. The empty label and the stored-row screen (same file, two FIX1 blocks: the floor compile, the rule parse and the URL; the folded star on the rule and the floor; a session's rules row stamped 4 and 5; the session door; the real lane on the durable and the session door under default and bypass, the other tool's call, the readable deny beside; every grammar directly — the grant, the deny's fields, the path row through the read face, the minted controls) — seven mutants, each red.
729
+
730
+ ### A principal's name has one judge, `principalNameOf`: a rule owner, a session owner, an adoption target, a sync round and a provider's bucket read a name through it, and a blank name is refused at every entry that needs one (#2265; behavior narrowing, open to veto; hard-breaking: none (the type surface is unchanged; the two store contract kits are stricter, see below); closed sets: none; exports: +1 runtime (`principalNameOf`); members: none) (@server @cli @client-core)
731
+
732
+ - **Before (v7.55.0).** "Is this string a principal's name" was read three ways. The rule owner's principal arm (`readRuleOwner`) refused the empty and the whitespace-only string. The sync entry, `adoptFilePermissionRuleStore`, `adoptLocalDataRoot`, the rule-directory adoption marker's reader, the file provider's `forPrincipal`, `ownerStoreOf` and the lanes' three copies of "is this task anonymous" refused the empty string only, so a whitespace-only principal was a bucket of its own (a durable file named by the hash of the spaces), a sync target and an adoption target. The session row's owner cell — `ownerCell` under every bundled repo's `create` / `fork` / `importEntries`, `SessionStore.register` and `fork`, `readOwnerCell` reading a stored cell, `SessionListQuery.owner`, `CheckpointDeleteGuard.owner` — and the data-root adoption marker's reader (`toPrincipal`) took any string, the empty one included: `register(id, "")` recorded an owner of `""`, and a fork made for a task whose principal was `""` recorded it too. And the three persisted-rule entries — `addPersistedRule`, `removePersistedRule` and `effectivePermissionRules` (through `resolveOwnerStore`) — took their target as given (`structuredClone` of whatever object came): a blank name, a class instance and a record whose `kind` is outside the closed set were each a bucket, so an add wrote a rule row into the bucket of `" "` (or of a bogus-kind owner's `principal`) and the read entry served it back as a rule row (the 7.55.0 blackbox's G10.a, measured on 7.55.0).
733
+ - **Now.** One judge, `principalNameOf(raw)` (`src/core/principal-name.ts`, a leaf with no import of its own; package root): a string with visible content (something is left once white space and every Unicode format character are taken away) that holds no control character (U+0000–U+001F, U+007F–U+009F), no line or paragraph separator (U+2028, U+2029) and no format character (category Cf: the zero-width space, the bidirectional controls, the byte order mark) other than the two joiners U+200C / U+200D, kept exactly as written, never stripped or normalized (` alice ` is a name, and not `alice`; `陈` and `kit-alice` are names); the empty string, a blank string, a string holding one of those characters and every value that is not a string name nobody. The five faces read through it: the rule owner's principal arm; the session owner cell (every bundled repo's `create` / `fork` / `importEntries` through `ownerCell`, `SessionStore.register` and `fork`, a stored cell through `readOwnerCell`, the list query's `owner`, the delete guard's `owner`); the adoption target (both entries and the three markers that record it); the sync round's principal; the provider's bucket (`ownerStoreOf`, the file provider's `forPrincipal`, the lanes' and the person-path reader's "anonymous" test, and the owner a fork row is minted for from a task's principal). An entry that needs a name refuses anything that is not one; a face that tolerates having no principal reads only ABSENCE (`undefined`, and the empty string) as none and refuses a value that was provided and is no name (the task's principal, below) — and no face reads a blank string as a name. The target of `addPersistedRule`, `removePersistedRule` and `effectivePermissionRules` is read by the owner reading, whose principal arm is the same judge. Three readings, six spellings of the test, become one function.
734
+ - **Behavior narrowing, the shape of a name (open to veto).** (1) **What changed:** a string holding a control character (U+0000–U+001F, U+007F–U+009F), a line or paragraph separator (U+2028, U+2029) or a Unicode format character other than the two joiners (the zero-width space `"\u200b"`, the bidirectional controls such as `"\u202e"`, the byte order mark `"\ufeff"`) anywhere in it — `"ali\u0000ce"`, `"ali\u0007ce"` among them — is no name, and neither is a string with no visible content (white space and format characters taken away leaves nothing: a name made only of joiners, zero-width spaces or byte order marks is blank); refused, not stripped or normalized, at every entry the blank refusal above uses and with the same sentence family (`session owner must be a principal name (…)`, `failed (committed: no)`, `adoption_bad_request`, `corrupt_session` for a stored cell); before, each of them was a name, a bucket and an owner of its own, and `readRuleOwner` answered an owner for it. The joiners U+200C and U+200D are allowed, as the names of people need them between letters (Persian, Arabic and several Indic scripts: `"می\u200cخواهم"`, `"a\u200db"` pass; `"\u200c\u200d"` alone does not); the judge does not test where a joiner stands, so a joiner at the start or the end of a name with other visible content is a name too, and joiners are kept as written. (2) **Who is hurt:** a host whose principals carry one of the refused characters — an invisible one pasted from a directory or a ticket — and a deployment already holding a session row or a rule bucket named that way (the row reads as `corrupt_session`; the bucket is no longer reached). (3) **How to migrate:** pass the principal as the identity provider spells it without those characters; repair a stored cell as for a blank one.
735
+ - **Behavior narrowing, session owners (open to veto).** (1) **What changed:** a blank string (`""`, `" "`, a tab) is no owner: a repo's `create` / `fork` / `importEntries`, `SessionStore.register` (first write), `SessionStore.fork`, `SessionListQuery.owner` and `CheckpointDeleteGuard.owner` refuse it with a `TypeError` / `config.invalid_argument`, before any row is laid down; and a row already holding a blank owner cell is read as `corrupt_session` (the file listing skips it with a disclosure, `open` and `headOf` refuse it) — it names nobody, and reading it as anonymous would admit every principal. (2) **Who is hurt:** a host that registers or forks a session under a principal that can be blank (a missing variable spelled `""`), and a deployment whose store already holds a row created that way — a fork made for a task whose principal was `""` was one. (3) **How to migrate:** register the real name, or `null` for an anonymous session; repair an existing row by writing its header's `owner` cell to a real name or `null` (file: the first `meta` line; a SQL store: the owner column). A fork made for a task whose principal is no name now records no named owner, so it takes the source row's — the same as `null`.
736
+ - **Behavior narrowing, adoption and sync (open to veto).** (1) **What changed:** `syncPermissionRules`, `adoptFilePermissionRuleStore` and `adoptLocalDataRoot` refuse a whitespace-only principal / target as they refused `""` — before any transport call or write; the rule-directory marker reader and the data-root marker reader (which accepted `""` as a target) read a marker whose target is no name as corrupt. (2) **Who is hurt:** a host that synced or adopted toward a whitespace-only principal, and a root already adopted to one (its marker now reads as corrupt and refuses the boot gate). (3) **How to migrate:** pass the verified principal; repair such a marker by hand to the real name.
737
+ - **Behavior narrowing, the task's principal (open to veto).** (1) **What changed:** `TaskSpec.principal` is ABSENCE (omitted, or the empty string, as the engine has always read it) or a principal name; a principal that was PROVIDED and is no name (white space alone, a string holding an invisible or control character, a non-string) is refused at prepare with `config.invalid_argument` before any bucket is read — on a resume, in the re-supplied config too — and `ownerStoreOf`, the file provider's `forPrincipal` and `resolveOwnerStore` refuse it the same way; before, a whitespace-only principal was a bucket of its own and a non-string reached the zero-rule floor by accident. (2) **Who is hurt:** a host that forwards an unvalidated principal (a trailing newline from a file, white space from a form, a value with a pasted zero-width space) — its task now fails at prepare instead of running with no rules. (3) **How to migrate:** pass the verified principal as the identity provider spells it, or omit the key.
738
+ - **Security note: only absence is no principal.** On a deployment that declared local-owner rules (`RunnerDeps.localOwnerRules: true`) a task with NO principal reads the machine owner's standing approvals — that is the declaration. A principal that was provided and is no name must therefore never be read as "no principal": were it, a caller who appends one invisible character to the principal a host forwards (`bob` plus U+200B, `bob` plus NUL, U+202E before `bob`, white space) would read the owner's approvals, and the offer door would mint "always" rules into the owner's bucket. The judge therefore reads absence, a name and a bad value apart (`readPrincipal`, `src/core/principal-name.ts`); the task door and every bucket resolver refuse the third; and the pin `h1` runs a real Runner on a local-owner deployment (the forged principals are refused before any bucket is read and run nothing; omitted and `""` are still the owner; `bob` is still his own bucket). Measured with the review leg's probe: 7.55.0 ran none of the forged tasks, an intermediate form of this change ran all of them, this form refuses all of them (`executed=0`, no bucket asked).
739
+ - **Behavior narrowing, the persisted-rule entries (open to veto).** (1) **What changed:** `addPersistedRule` and `removePersistedRule` read their `principal` argument by the owner reading — a bare string is a name (a blank one is no name), anything else must be a plain-record owner (a class instance, a blank principal, a `kind` outside `RULE_OWNER_KINDS` and a missing `kind` are no owner) — and answer `{ status: "failed", committed: "no" }` before any bucket is asked for; `effectivePermissionRules` / `resolveOwnerStore` throw for an `owner` that is no owner (a bare string included) and for a `principal` that was provided and is no name (the empty string is absence, as everywhere: the no-principal bucket, as before). (2) **Who is hurt:** a host that passed a class instance as the owner (its members were read, its prototype ignored), a blank name, or a bogus-kind owner to these entries — its rule landed in, or was read from, a bucket no verified principal selects —. (3) **How to migrate:** pass the verified principal as a string, an owner as a plain `{ kind: "principal", principal }` / `{ kind: "local-owner" }` record, or omit the principal for the floor; a rule row an earlier add wrote into such a bucket stays in the store and is no longer reachable through these entries (the SQL twin below).
740
+ - **Contract kits.** `sessionStoreContract`'s first-`register` case now also refuses `""` and a blank string, and `sessionRepoContract` gains one case (a blank owner on `create` and `fork` is refused and leaves no row). A store that implements the interface itself (a SQL store) runs red on them until it reads its owner through the judge.
741
+ - **Not changed.** The consent entries (`resolveCallerOwner`) keep 7.55.0's behavior for every string: a principal that was provided beside an owner is STATED — it contradicts a different owner, and a provided value that is no name (`bob` plus a newline or an invisible character) contradicts too, never "not stated"; with no owner it is refused; only the empty string and an omitted principal are absence. The record reader still asks whether a stored approval record's `principal` field is PRESENT with `!== ""` and then judges it with the name reading (a stored blank owns nothing). The session-rule stores key a row by `principal ?? null`, so the empty string keys a row of its own there, not the anonymous row — as before this version (a follow-up). The engine-wide `principal || undefined` reads on a resume's identity continuity compare strings and are not name judgments (the task door above admits only absence or a name). `syncPermissionRules`' own dispatch on a typed `owner` is not changed here (an owner whose `kind` it does not know still takes the principal path there when its `principal` agrees with the round's).
742
+ - **Consumers.** *server*: pick up `principalNameOf` (blank AND shape: a name holding a control, format or separator character other than the joiners is refused, not stripped) for the SQL permission-rule store's `forPrincipal` and the session stores' owner writes (`register` / `fork` / create). The SQL `forPrincipal` (its `principal === ""` test, the write side's twin of the file provider's) keeps `undefined` and `""` as the zero-rule floor and must REFUSE a provided value that is no name instead of giving it a bucket (or the floor); a route that forwards a request's principal to a task, to `addPersistedRule`, `removePersistedRule` or `effectivePermissionRules` gets the refusals above for a malformed one. Until the SQL stores read through the judge they keep a blank-string bucket and a blank owner that the file lane now refuses, and fail the stricter kit cases; existing blank-owner rows need the repair above, and a bucket a blank name selected on a SQL store keeps the rule rows an earlier add wrote there (delete them by hand; the core entries no longer reach that bucket). *cli*, *client-core*: no wire key, member or closed set changes; a task whose principal is provided and is no name now fails at prepare with `config.invalid_argument`.
743
+ - **Pins.** `test/backlog2265-principal-name-one-judge.test.ts` (J the judge (blank, the shape half: zero-width space, NUL, BEL, with `kit-alice` and `陈` as controls), its root export and its leaf form; a the rule owner; b the session owner on both bundled repos, through the store, a repo that judges nothing, a stored blank cell on the file lane, the list query and the delete guard, and the Agent tool's fork arm; c both adoption entries and the three marker readers; d sync; e `ownerStoreOf`, the file provider, the rule lane, the offer door and the person-path reader; h the task door on a real Runner (the forged principals refused before any bucket is read; absence still the owner; the resume screen); g the persisted-rule entries: a blank string, a class instance and a bogus-kind owner at `addPersistedRule` / `removePersistedRule` / `effectivePermissionRules`, controls for a name, an owner and the local owner; g4 the consent entries beside an owner; S a source scan that no `principal === ""` / `typeof principal !== "string"` spelling grows back). Re-pointed: `test/backlog1468-s3-list.test.ts` L4a (an `owner: "o"` filter, no longer `owner: ""`).
744
+
745
+ ### A caller's tool named `AskUserQuestion` is an ordinary caller tool on the content-question lane and, on the resume path, for a person's edit (see the #2300 resume-path section below): both read whose tool a call is off its face — the engine's own mount of the question definition — never off its name; its card, the org layer's question exemption and a decide's `answer` guards still read the name (#2300; security; behavior change, a widening, open to veto; hard-breaking: one, open to veto; closed sets: none; exports: none; members: none) (@test @server)
746
+
747
+ - **The rule.** One judge answers "is this call the engine's question tool?": one of the engine's own mount sites mounted the tool the call dispatches to (`ToolCallFace.engineMounted`) and its contract is the catalog's question contract (`core.ask_user_question@1`) — the judge the persisted-rule lane's content-routing seat already read. The content-question ask class (origin `content_question`, with its bypass immunity and its classifier, sandbox and allow-rule exclusions), the content-ask route to `resolveContentAsk`, the durable park's sync-first decline, an ancestor's question float and `createDurableQuestionPolicy()` read it; the two name sets, the four name comparisons and the park's card-by-name read they used before are gone. The refusal "AskUserQuestion could not be parked … the question was NOT shown to anyone" is said only for the engine's own question tool, when its park truly failed.
748
+ - **Behavior change, a widening (open to veto).** What changed: a caller's tool named `AskUserQuestion` — a `TaskSpec.tools` entry, or a `createAskUserQuestionTool()` product a host mounts itself (a host's mount is not an engine mount) — takes the ordinary caller lane: with an approver present and the person's rule bucket unreadable it is put to the approver (origin `rule_store_unavailable`) instead of being refused with the parking sentence and a notice that the approver reported nobody reachable (nobody had been asked); with a durable park armed and a live approver it is put to that approver in-stream instead of being parked as a question; an allow rule naming it clears its ask, `bypassPermissions` releases it, auto's classifier judges it and `createDurableQuestionPolicy()` no longer asks about it — as for the same tool named `AskHuman`. The same holds for a caller's tool that wears `AskUserQuestion` as an alias (`{ name: "MyQuestion", aliases: ["AskUserQuestion"] }`, called under either name): #1194 held such a tool as the question under both of its names on these two seats, and now it is an ordinary caller tool on them — `bypassPermissions` releases a policy ask on it and the durable question policy lets it run; the engine's own question tool stays bypass-immune. Who is affected: a deployment that mounts a tool of its own under the reserved name: an ask about it used to end in that refusal (or a question park), and now it is asked about, cleared or refused like any caller tool. Why: the 7.55.0 criteria (⑦ G7.a) state that a caller's same-named tool is asked; the lane read the name while the rule lane's seat read the face — two readings of one fact.
749
+ - **Security — the ancestor's settlement.** An inherited constraint hands an ask on the engine's question tool back unsettled (a "float"), so that the delegated leg's own content lane routes the question; that float now reads the same judge. Without that line, this change would have let a caller's same-named tool in a delegated leg skip its ancestor's mandate. The floated ask, no longer a content question at the leg's gate, would have reached the leg's own approver instead of the ancestor's: measured, the ancestor's refusing approver was not consulted, the leg's allowing approver was, and the tool ran. Now a caller's `AskUserQuestion` under an ancestor is settled by that ancestor, as `AskHuman` is; the engine's own question still floats to the leg's question face.
750
+ - **Hard-breaking (open to veto).** What changed: `runToolGate` treats a call as the engine's question tool only when its request carries a face (`ToolGateInput.toolFaceOf`) with `engineMounted` and the question contract; a bare `AskUserQuestion` request is an ordinary call — its ask goes to `resolveAsk`, not `resolveContentAsk`, inside a declared sandbox a sandbox-local fold ask on it is admitted like any other — and `createDurableQuestionPolicy()` answers `allow` for a bare request. Who is hurt: a host that drives `runToolGate`, or calls the durable question policy, itself — outside a Runner — for the engine's question tool without stamping a face. Inside a Runner every request the gate and its policies judge carries the live roster's face (the resume's pre-claim re-check, which runs before a roster exists, carries the face its parked row recorded, mount site included — the #2300 resume-path section below), so the engine's own question tool reads as before; server's question gates (`createDurableQuestionGate`, the leader leg's `createDurableQuestionPolicy()`) are Runner policy seats judging those requests and see no change for it. How to migrate: pass `toolFaceOf` answering the face of the leg's question tool — `toolCallFaceOf(roster, "AskUserQuestion")` over the leg's `ToolRoster` (the engine's own mount row carries `source: "builtin"`) — or a `ToolCallFace` written with `engineMounted: true` and `contractId: "core.ask_user_question@1"`. Why: a bare name cannot tell the engine's question tool from a caller's tool that took the name, and the content lane, which never asks the permission approver, must not be entered on a guess.
751
+ - **Consumers.** @test — re-test G7.a (7.55.0 criteria ⑦) on this build: the caller's `AskUserQuestion` is asked, as `AskHuman` is. @server — `createDurableQuestionGate`'s live-context leg still reads the name; composing `createDurableQuestionPolicy()`'s answer (an `ask` there means the engine's question tool) keeps the one judge with no new import. @cli — no wiring: the parking-sentence refusal no longer appears for a caller's same-named tool, and its ask comes with the card the shell already renders.
752
+ - **Not changed here.** The org layer's question exemption, the reserved approval card and the resume path's `answer` guards still read the name (a person's edit of the parked call no longer does: the #2300 resume-path section below). A caller's tool named `AskUserQuestion` still renders the question card on its permission ask. When such a tool parks (a durable park armed, no live approver), it is resumed as a question: an allow without an `answer` is refused with `checkpoint.invalid_outcome` unless the resuming leg has a live question face — fail-closed. Moving these reads to the same judge needs the parked row to record whose tool it was; the pending action's identity group carries no mount site. The deny notice for the engine's own question tool, when no question face is wired or a forced durable gate's park failed, still says the approver reported nobody reachable: the content-ask outcome does not record whether a question face was consulted.
753
+
754
+ ### The read table says what the programs do: valgrind, setpriv, chroot and prlimit read by their options, `tar -C` an ordered base, a long name's prefix read per grammar, a `git -c` setting git runs later a code body, a name too long for the filesystem an absence; an unreadable-text card offers a way out only where it is true, and the `maybe` tier's ask says what it found (#2141, #1667, #1833, #1847, #922, #2142; behavior widenings and narrowings, each open to veto; model-visible text; hard-breaking: none; closed sets: `FileErrorCode` 14 → 15, +`name_too_long` (registered in `docs/CLOSED-SETS.md`); exports: none by name (`FileErrorCode` gains a member); members: none) (@server @cli @client-core @test)
755
+
756
+ - **valgrind, setpriv, chroot, prlimit (#2141; widening, open to veto).** What changed: the four launcher rows declare the options their real binaries were run with (valgrind 3.19's core and Memcheck options and each tool's output file, values only after `=`, `--` stepped, a long name only whole; util-linux setpriv and prlimit 2.38; GNU coreutils 9.1 and macOS chroot), so `valgrind --version`, `valgrind --leak-check=full ./a.out`, `setpriv --reuid=65534 ls` and `prlimit --nofile=1024 ls` read the program behind the options instead of refusing the program position — in bypass they run, where they were `unverifiable_shell_text` and denied (TB: 36 refusals over 26 tasks). Who is hurt: a deployment that relied on these launchers' options being refused; an undeclared option (`valgrind --log-socket=…`, setpriv's newer `--landlock-*`) still refuses, and the files valgrind writes (`--log-file=`, `--xml-file=`, a tool's out file) are judged as its write targets — a name carrying valgrind's own `%q{NAME}` environment substitution is no place the text settles, and is asked. A permission rule reads through the same options, because the rule reader's wrapper table is the projection of these rows: `Bash(rm:*)` denies `valgrind --leak-check=full rm -rf x`, `setpriv --reuid=65534 rm -rf x`, `prlimit --nofile=1024 rm -rf x` and `chroot --userspec=1:1 / rm -rf x` as the rule (they asked before as calls that "could not be read against the permission rule"), and a call through them that runs another program is no longer that ask under every Bash content rule. The rule reader also reads a wrapper's options only before the operands it owes, as the program-position reader does (getopt's "+" in GNU chroot / flock / timeout, POSIX getopt in BSD chroot): `chroot / -u root rm -rf x` runs `-u` and is read so (with chroot's options declared it would have read `rm`), and `timeout 5 -s KILL rm -rf x` / `flock FILE -n rm -rf x` read `-s` / `-n` as the program where they read `rm` — the read station refuses all three as an unreadable program position, so no call that ran before runs now. How to migrate: nothing; a deny or ask rule on the program applies through these launchers (KL-2141-a lists the launcher rows still without a grammar).
757
+ - **The way out on an unreadable-text card (#2141; model-visible text).** What changed: `Unverifiable shell text (fail-closed unless cleared): …` keeps "write the text into the command itself (a `-c` string, a here-document or a here-string)" only on a text built when the command runs, where writing it out is read; a text the lexer could not read says it was not read and offers nothing (written into a `-c` string it is the same text), and a segment whose program the walk could not name carries the program-position reader's own sentence (`"valgrind" is given the option "--log-socket=…" where the program it runs would be — which program runs is not readable without that word's option table — not auto-allowed`), the one sentence the read station gives the same segment; after a launcher's bare `--`, a word spelled like an option is named for what it is (`"env --" ends the options, and "-i" after it is a name spelled like an option where the program it runs would be — which program runs is not read from such a word — not auto-allowed`), where both faces called it the launcher's option before. Who is hurt: a reader that matched the old sentence of the last two; the model is no longer told to do what is refused again. How to migrate: match the class (`unverifiable_shell_text`), not the sentence.
758
+ - **The `maybe` tier's ask (#1667; model-visible and person-visible text for every Bash ask the shape check does not clear, not only launchers).** What changed: an ask raised because a `maybe`-tier tool's probe did not show the call reversible says `tool "<name>" was not shown to be reversible — explicit approval required`; `performs an irreversible action` stays the `always` tier's alone (a real irreversibility mark). The built-in shell probe's `reason` — the card's `probeReason`, never the message the auto-mode classifier is shown — is the compound classifier's own sentence, verbatim (`command "nice" is not in the read-only allowlist`). A launcher in front of a read-only program still asks in default mode: CC 2.1.296 asks too (its read-only check strips only `command` / `builtin` / `noglob`; KL-1667-a). Who is hurt: a surface or a test that matched `performs an irreversible action` on a Bash ask — this tree re-pins `backlog239-probe-reason`, `backlog482-compound-readonly-e2e`, `backlog696-read-boundary-any-doctrine`, `backlog7340rg-headless-denial-sentence`, `inherited-gate-rb40-review-gaps`, `backlog768-bash-canonical-recheck` (a shape-tier card now carries this account: the cells read its class), `poll-loop-classifier` and `backlog502-out-of-root-read-mandate` (the probe's not-reversible verdict now carries `reason`) and the shape-probe cells of `backlog2141-read-table`. How to migrate: read the class, not the sentence; @cli: render `probeReason` beside the message — it is now present on every such Bash card; @test: fixtures that hold the old sentence take the new one.
759
+ - **`tar -C DIR` (#1833; widening and narrowing, open to veto).** What changed: `-C DIR` / `--directory DIR` is the ordered base of the operands written after it (`OptionSpec.rebases`, bsdtar 3.5.3 and GNU tar 1.34 measured): `tar -C docs -cf out.tar file` reads `docs/file` alone; a second `-C` resolves against the first (one naming its own place starts over); the operands before it, the archive, a `-T` list and an `-X` file are read where the command stands; DIR itself is a place it names, no root — except where an operand after it is one the text does not settle (a pattern the shell expands where the command stands, a rewritten value): DIR is then the root that operand is judged by, as before. Who is hurt: a deployment whose rows under DIR stopped a `tar -C DIR <other member>` (it no longer does: tar opens only the member it is given) — and, the other way, a member now judged where tar opens it. How to migrate: nothing; KL-1826-a is closed.
760
+ - **A long name's prefix (#1847; narrowing).** What changed: the grammars whose every measured binary refuses `--versio` (perl, ruby, python, node, php, jq, sqlite, bun, git, docker / podman, GNU find, valgrind; macOS and Debian) read a prefix of a declared long name as a name they do not declare, with the existing doubt; where one release or implementation takes a prefix (curl 7.88 against curl 8, GNU bc / dc, gawk) the prefix stays the option — the reading that misses no value. The binary gate carries one cell per grammar (`--versio` against `--version`): a grammar that says "none" fails where its binary takes one. Who is hurt: nobody running a real binary — those programs refuse the prefix. How to migrate: nothing; KL-1836-a is closed.
761
+ - **`git -c KEY=VALUE` (#922; narrowing).** What changed: a setting git hands to a shell — or runs as a program — later (git-config(1), gitcredentials(7), git-send-email(1) and gitattributes(5): `core.pager`, `core.editor`, `sequence.editor`, `pager.<cmd>` unless boolean, an `alias.*` led by `!`, every credential helper in its three forms (`!` snippet, absolute path, `git credential-<value>`), the diff / merge / filter drivers, `difftool` / `mergetool` commands, `core.sshCommand`, `gpg.program`, send-email's `toCmd` / `ccCmd` / `headerCmd` / `sendmailCmd` / `smtpServer`, …; `OptionSpec.runsLater`) is a code body of git's — given by `-c KEY=VALUE`, or by `--config-env KEY=VAR`, whose value the environment holds (asked: the text is not in the command): the segment re-enters the shell as a `trap` action does, its text a nested plan whose deny hits refuse. Under a deny row on `deny`, the read station refuses `git -c 'core.pager=cat deny' log`, `git -c 'core.editor=cat deny' commit` and `git -c 'alias.x=!cat deny' x` (it vouched them before); without one, it asks about such a setting as a read it cannot establish (`probe`), which a whole-tool allow (`allowedTools: ["Bash"]`) no longer clears — `git -c core.pager=cat log` was allowed under that allow and asks now. `git -c core.autocrlf=false log` keeps the station's vouch; in default mode it asks as it did, because any global `-c`, `--config-env` or `--exec-path` makes a git line not read-only (the reference's rule; unchanged here). Who is hurt: a session that allows Bash wholesale and sets one of these keys for a harmless text (`git -c core.pager=cat log`) — it asks now. How to migrate: set the pager by `--no-pager`, or answer the ask. The same keys given through `GIT_CONFIG_*` / `GIT_PAGER` environment words are not read (KL-922-a). An empty value names no command (git launches no pager for `core.pager=`, `credential.helper=` resets the helper list): `git -c core.pager= log` and `git -c credential.helper= push` stay what they were.
762
+ - **`fc` running a stored history line (#922, C-R727 note 12; narrowing, open to veto).** What changed: bare `fc`, `fc -s [pat=rep] [first]`, `fc -e EDITOR` and `fc -e -` run a line of the shell's history list — which a non-interactive shell keeps too (`history -s 'echo X'; fc -s` prints X under bash 5.3) — so the line they run is one no word of the command spells, read as a text built when the command runs, the same reading as `eval "$X"`: `history -s 'rm -rf ~'; fc -s` is `unverifiable_shell_text` (bypass-proof) where it ran under bypass with no card. Each form is read as bash 5.3 runs it: `-s` and `-e -` RUN the line and win over `-l` (`fc -l -s`, `fc -ls`, `fc -s -l`, `fc -le -`, `fc -e - -l`, `fc -lr -s`); `-e EDITOR` edits then runs without `-l`, and beside `-l` only lists (`fc -e vi -l`). The builtin's options end at `--` and at its first operand, which bash takes as the leading text of a history entry: `fc -l -- -s` lists from the entry that begins `-s`, `fc -- -l` runs the entry that begins `-l` — with no such entry bash reports `no command found` and runs nothing (bash 5.3.15; 3.2 says `history specification out of range`), so `fc -- -l` is read as running a stored line (refused / asked) even where history holds no such entry, the stricter side (the permission-rule reader, which reads every word for a command-running option without fc's precedence, reads `fc -l -- -s` and `fc -e vi -l` under a Bash content rule as running one — stricter, that reader only). That reader now finds a short command-running option in a cluster or with its value attached (`fc -ls`, `fc -e-`, `compgen -CV …`, `complete -C=V …`, `mapfile -C'cmd' …`), so such a call is unreadable against a Bash content rule as its spaced spelling already was (narrowing). `fc -l` alone lists: `history -s` (storing), `fc -l` / `fc -ln` (listing without `-s` / `-e`) and `history -p` (expanding to standard output) run nothing and are unchanged in every mode, a Bash content rule included (the permission-rule reader reads `fc` as itself; its `-e` names the editor it runs). Who is hurt: a command that re-runs history through `fc` — it is refused in bypass and asks in default mode. How to migrate: write the command itself instead of re-running it from history.
763
+ - **`name_too_long` (#2142; widening, open to veto; additive closed-set member).** What changed: `FileErrorCode` gains `name_too_long` — a name the filesystem cannot hold (POSIX `ENAMETOOLONG` for a path component past `NAME_MAX`) — and NodeExecutionEnv answers it where it answered `unknown` (a path past `PATH_MAX` whose components are short stays `unknown`: such a file may exist, reached from a directory below it); the shell read station reads it as an absence, as `not_found`. The TB cause: the station hands an inline program text (`python3 -c "…"`) to the deny judge as a spelling, and placing it asked the filesystem about a first component of ≥ 256 bytes; the `unknown` answer was read as a lookup that did not answer, so the command was the environment's silence (`unverifiable_shell_text`, mandated, bypass-immune) — denied in bypass (TB: 15 of 102 multi-line `python3 -c`; the predicate "first segment ≥ 256 bytes, no unresolved `$`" separates them 15 / 0, test's readings). Who is hurt: an `ExecutionEnv` consumer that switches exhaustively on `FileErrorCode`; @server: `RemoteExecutionEnv` (`remote-env.ts`) maps ENAMETOOLONG to `name_too_long` — until it does, a remote env answers `unknown` and keeps today's bypass denial (KL-2142-a); @client-core: the error-code table gains the word. How to migrate: add the member to a table keyed by `FileErrorCode`.
764
+ - **One reading each.** The decline sentence of a segment whose program the walk could not name has one home (`declinedProgramSentence`, the read station and the removal guard); the station's lookups read an absence through one predicate (`isAbsence`: `not_found`, `not_directory`, `name_too_long`).
765
+
766
+ ### A person's edit of a parked call runs as written unless the resumed leg's roster says the call is the engine's own question tool — never the call's name — and the pre-claim edit re-check judges every call's edit under the face its parked row records, the tool's mount site now among the members (#2300 resume path; security; behavior change: a widening for a caller's tool named `AskUserQuestion` and a narrowing for the engine's own question tool, both open to veto; hard-breaking: one — the parked row gains a member and a requirement word, a store-format forward change, open to veto; closed sets: `RequirementWord` 31 → 32 (+`mount_site`, registered in `docs/CLOSED-SETS.md`); exports: none; members: +1 optional `engineMounted` on the parked tool-approval row) (@server @cli @dgx @test)
767
+
768
+ - **Before (measured through the real Runner).** A caller's tool named `AskUserQuestion` (a `TaskSpec.tools` entry; the engine's own question tool not mounted) parked headless with a durable park armed, then resumed `allow` with the person's edit `{ path: "/edited" }` — with an `answer`, or with a live question face on the resume: the model's bytes `{ path: "/model" }` ran and the edit was dropped; the same tool named `AskHuman` ran `/edited`. Two resume reads compared the parked call's name: the resolver (which dropped the edit) and the pre-claim edit re-check (which skipped the frozen ancestors and the resume-edit base policy for an edit on a call of that name).
769
+ - **Now, one judge.** The resolver drops a person's edit only when the face of the parked call on the resumed leg's roster says one of the engine's own mount sites mounted the question definition (`isEngineQuestionFace`, the judge the gate lane's seven stations read); a caller's tool of that name — a `TaskSpec.tools` entry, or a `createAskUserQuestionTool()` product a host mounts itself — runs the person's edit as written, as `AskHuman` does. The pre-claim re-check runs before any roster exists, so it judges an edit under a face rebuilt from the parked row; it no longer skips by name and judges every call's edit — the frozen ancestors, the resume-edit base policy, the rewrite re-judgment — and the row records the mount site the call parked with (below), so the face it judges under says of the mount site what the face the gate judged the call under said. Two name readings retired (`resume.questionArgsShown`, `resume.editRecheckQuestionSkip`); none added.
770
+ - **The parked row records the mount site (the same release; fixes a regression of the line above).** Before: with the name skip gone and no mount site on the row, a face rebuilt from the row was the engine's for no call, so a deployment policy built the way `createDurableQuestionPolicy()`'s documentation says (return its `ask` for the engine's question tool, otherwise refuse) answered the re-check of the engine's own question tool with its "otherwise" arm — measured through the real Runner: the question parked, and a person's `allow` + `answer` + `updatedInput` (even the shown bytes) was refused before the claim with `resume.constraint_rejected` ("this agent may only ask questions"), where the release before resolved the row; with no `updatedInput` both resolved. The row held a capability, a contract and a shape digest, none of which tells the engine's mount of a definition from a host's mount of the same definition (`callerMountSource` reads a host-mounted engine product as `builtin` too), so the identity could not be derived from what the row held. Now: the park writes the roster entry's mount site (the fact a live face's `engineMounted` carries; read off the entry like the identity members beside it, never off the request's face, which the policy judging the call can write) onto `pendingAction.engineMounted`, and `parkedCallFaceOf` puts it back on the face the re-check judges; the same measured shapes resolve, an edit that changes the bytes is still judged (a policy that refuses the changed batch refuses it, the row pending), a caller's tool of any name and a host-mounted engine definition record none. Still true after this: the face rebuilt from a row is not the whole live face — it carries no rule name values and no approval card, which a live face derives from the roster (#2406) — and the resume's approval-object comparison does not compare the mount site (#2405), so a definition with the same identity that a host re-mounts between the park and the resume is judged on its live face by the post-claim belt, not refused before the claim.
771
+ - **Hard-breaking (open to veto) — the parked row gains a member and a requirement word.** What changed: every parked tool-approval row of a tool the engine mounted (every built-in, the question tool among them) carries `pendingAction.engineMounted: true` and the row lists the requirement word `mount_site` (`REQUIREMENT_WORDS` 31 → 32; the row digest holds the member); a row of a caller's or a protocol tool carries neither. Who is hurt: an engine release before this one that reads such a row — a rolling deploy where a worker of this release parks and an older worker resumes, or a rollback with parked approvals (a person who downgrades a single-machine shell, @cli, whose engine derives the durable approval domain on over its local store, and a deployment of @dgx rolled back, meet it the same way) — refuses it before the claim as a newer row (`checkpoint.unsupported_version`, `detail.reason` `version_newer`, `detail.requires` lists `mount_site`; the row stays pending — read from `judgeCheckpointRequirements` and the resume screen, not run on an older binary); a host's own `CheckpointStore` that stores the row member by member (a whitelisting serializer) and drops `engineMounted` — the store contract kit's approval-members cell now round-trips it, and a row that comes back from such a store lists `mount_site` with the member gone, so the resume refuses it before the claim as damaged (`checkpoint.invalid_outcome`, `detail.reason` `requirements_damaged`; the row stays pending and no worker can resume it); and a row parked by an earlier release has no member, so a person's edit on its engine question tool is judged as an ordinary tool's by the pre-claim re-check (the measured refusal above, under such a deployment policy). How to migrate: resume or drain parked approvals before moving a worker back; roll workers forward together; a store implementation carries the member (run the kit); for a row parked by an earlier release, decide the engine question without `updatedInput` — the row is still pending and decidable.
772
+ - **Behavior change, a widening (open to veto).** What changed: a person's edit on a parked caller's tool named `AskUserQuestion` now runs (it was dropped and the model's bytes ran), after the same pre-claim and post-claim re-checks any caller tool's edit meets (the frozen ancestors, the resume-edit base policy, the PreToolUse re-screen). Who is affected: a deployment whose tool wears the reserved name and whose approver edits its parked calls. How to migrate: nothing to do for a person who edits to change what runs; a host that sent `updatedInput` on such a decide to be ignored stops sending it.
773
+ - **Behavior change, a narrowing (open to veto).** What changed: the engine's own question tool resumed with an `updatedInput` that the deployment's resume-edit policy denies is refused before the claim (`resume.constraint_rejected`, the row stays pending); it used to execute the shown questions and ignore the edit. The two ancestor arms of the same re-check (a frozen ancestor that denies the edit; an opaque inherited chain, `resume.constraint_unprojectable`) now reach it the same way — read from the code, not measured. Measured: with a policy refusing the edited batch, before `{ row: "resolved", terminal: "completed", the shown questions answered }`, now `{ row: "pending", thrown: "resume.constraint_rejected" }`; with no edit, with an edit the policy accepts, and with a policy-decided edit the readings are unchanged. Who is hurt: a client that sends `updatedInput` on a question approval's decide (the resolver still ignores it). How to migrate: send the decide without `updatedInput` — the row is still pending and decidable. Why: an edit is judged before the claim under the face its parked row records (the mount site among the members, below), and judging every edit is the reading that cannot widen anything; the resolver, which holds the roster, drops the edit.
774
+ - **Consumers.** *server*: do not forward `updatedInput` on a content-ask decide; an edit on a caller-named `AskUserQuestion` now runs and can be refused pre-claim like any caller tool's. *test*: re-run the edit shapes of the 7.55.0 criteria ⑦ (G7.a) on a caller's `AskUserQuestion` with `answer` and with a live question face — the person's bytes run, as for `AskHuman`.
775
+ - **Not changed here.** A decide's `answer` guards still read the name (`pendingContentAskCallId`): a caller's `AskUserQuestion` is still resumed as a question — an `answer` on it is accepted where `AskHuman` refuses it, and an allow with neither an `answer` nor a live question face is refused with `checkpoint.invalid_outcome` — as are the reserved approval card and the org layer's question exemption; the row now records whose tool it was, and moving those readers onto it is the ticket that holds them.
776
+
777
+ ### What a tool's `execute` returns is received once, at the tool door: `defineTool` copies the content blocks and `details` (and a thrown error's `details`) into the engine's own data, so no later reader runs the tool's code again (#960 slice one; behavior change, open to veto; hard-breaking: five, open to veto; closed sets: none; exports: none index-exported — internal readers only: `appendOwn` / `dataMember` in the receipt module, `notStartedResult` / `carryNotStartedReceipt` / `isNotStartedDelegationResult` in `src/core/delegation-not-started.ts`, the last moved from `src/agents/subagent.ts`; members: none) (@server @cli @client-core)
778
+
779
+ - **The rule — the door reads what the wire reads.** The content array and `details` (and a thrown error's `details`) are received once by the engine's one receipt of a caller's object, read with the members the JSON wire carries, recursively, and running none of the tool's serializers (`ReceiptMode.wire`, `others: "copy"`): every object keeps its own enumerable members (a getter called once; a non-enumerable member is never read; the prototype is not kept) and a list its own indices; an object that would be serialized through a `toJSON` — found off property descriptors up its prototype chain, never run — is absent, its members with it, except the platform's own `Date.prototype.toJSON` (by identity): a `Date` arrives as its ISO string (`null` for an invalid date), read off its own time value, never through a method the tool could override. What JSON carries no value for (a function, a symbol, a bigint), a cycle, a Proxy and a getter that throws are left out. The door and `JSON.stringify` differ on three things: a `NaN` or an `Infinity` stays a number in the copy and becomes `null` only where the wire writes it; a boxed primitive (`new String("hi")`, `new Number(5)`, `new Boolean(false)`) is read as an object by its own enumerable members (`{ "0": "h", "1": "i" }`, `{}`, `{}`) where the wire writes the primitive, and a Proxy as a whole value is absent where the wire would write its target (run through its traps, which the door does not do). There is no structured clone at the door; the receipt's leaf law (`ReceiptMode.leaves`) serves the mods observer copy alone. So nothing of the tool's runs after its getters are read once — no `toJSON`, no `toString` — and nothing a tool kept off the wire reaches the copy. Nor does anything a prototype holds: each member is asked own before it is read (a hole is absent, never looked up), and every copy is filled on no prototype and handed its ordinary one when full, so an index accessor planted on `Array.prototype`, a `type` / `text` accessor on `Object.prototype` or a `Number.prototype.toJSON` is never run by the door. A `text` / `image` / `document` block is then rebuilt from its own fields. The card reducer and the `tool_end` error-arm lifts read that data — a list by its own indices — instead of guarding each read.
780
+ - **Behavior change (open to veto).** What changed: a block whose `type` answers `text` and then `image` reaches the model and `tool_end.output` as its text (it was refused as undecodable media); a `text` getter that raises on its second read no longer turns a successful call into `isError`; a `details` getter that raises on its second read no longer drops the `tool_end.structured` card, and a `details.type` that answers differently per read is one card everywhere; a `details` member whose getter throws at its first read is left out and the call succeeds with no notice (before, the whole call came back `isError` with the getter's message — KL-960-a names the absence, this is its other side). Who is affected: a tool whose returned values change between reads — every reader now sees the first answer.
781
+ - **Hard-breaking (open to veto) — no reference.** What changed: a result's content blocks, its `details` and a thrown error's `details` are the door's copies in their wire form, never the objects the tool returned; a contract block carries only its contract fields; a class-instance `details` arrives as a plain record of its own enumerable members (an `Error` as its enumerable members — `message` and `stack` are not); a function in `details` is left out. Who is hurt: a host or test that compares them by identity with the tool's objects, mutates those objects after returning them, reads a non-contract field off a content block, or relies on a `details` prototype accessor or a function in `details`; and a tool that returns a Proxy as its `details` (the card it drew, `tool_end.structured` and the persisted row go with it: before, the Proxy's target was read through its traps). How to migrate: compare by value; return a plain record, not a Proxy; carry extra data in `details` as own enumerable members of plain objects. Why: a reference lets the tool's code run again in every later reader (the defect this closes).
782
+ - **Hard-breaking (open to veto) — a value carrying a `toJSON` other than `Date`'s, a user's or the platform's, is absent; a `Date` is its ISO string in process too.** What changed: an object in `details` or content (or a thrown error's `details`) that would be serialized through a `toJSON` other than the platform's `Date` one — a class with a `toJSON` method, a record with a `toJSON` function or accessor, a `Date` whose `toJSON` was replaced — is absent at the door, members included — the members its answer carried on the wire before included (a `toJSON` returning `this` no longer carries the record's public members), and a `Buffer` (its `toJSON` is Node's) and a `URL` (its `toJSON` is the platform's: the href the wire wrote is gone; a `URLSearchParams` has none and arrives as `{}`) among them; its `toJSON` never runs. A `Date` arrives as its ISO string, as the wire always carried it (a `Date` made in another realm carries that realm's `toJSON`, not this platform's, and is absent); an in-process consumer that read a `Date` object off `details` reads a string. Who is hurt: a tool that relies on a `toJSON` to shape its `details`, or puts a `URL` there (the shaped value no longer reaches `tool_end.structured` or the transcript); a host that called `Date` methods on a `details` member in process. How to migrate: put plain data in `details` (the shape the `toJSON` would have answered); parse the ISO string (`new Date(s)`). Why: the door runs none of the tool's code (a thrown value's own code never runs, #1553) and keeps off the wire whatever a `toJSON` would have kept off (C-R746 note 2).
783
+ - **Hard-breaking (open to veto) — a `Map` or a typed array in `details` is what the wire carried.** What changed: a `Map` (or `Set`) arrives as `{}` plus its own enumerable members and a typed array as its index record (`{ "0": 7 }`, every number itself: a `NaN` or an `Infinity` element stays a number in process and is written `null` where the wire writes it, as in any list) — exactly what `tool_end.structured` and the persisted row always carried (a `Buffer` carries a `toJSON` of Node's and is absent, above). **The door's cost** (7.55.0 handed `details` on by reference, so all of it is new): linear in the size of `details`, with no size bound. A dense list or a record is read live, in order, as `JSON.stringify` reads it — its member names fixed when it is met, each member asked own just before it is read and walked to its end before the next, a primitive assigned where it is read, no property descriptor and no definition per member (a list that proves sparse is read off its own members instead — past 64 holes, one whose holes are more than half the indices read so far; KL-960-b). So a getter that changes an earlier member's insides or removes a later member is read as the wire reads it (an earlier state of this release read every member off descriptors captured before any getter ran, and copied a member a getter had already removed); a typed array's elements are copied natively into an engine-owned `Float64Array` and assigned off that copy. Measured on one machine at load 4–8, the door against a native `JSON.stringify` of the same value: a list of 1M numbers 17 ms vs 27 ms, 1 000 × 1 536 embedding numbers 37 vs 115 ms, a 1M `Float32Array` 44 vs 131 ms, a 3M `Uint8Array` 175 vs 238 ms — at or below the JSON write; many small records cost more per member, 100 000 records of two members 79 vs 8 ms (≈ 10×), 1 000 records of 30 members 3 vs 1 ms; the factor grows with machine load. Through a whole Runner call a typed array's index record also costs memory the by-reference hand-off of 7.55.0 did not: a 1M `Float32Array` added about 350–390 MB of heap and 0.8–1.4 s on one machine. A bigint typed array has no JSON form and is `{}`; an in-process consumer that read the `Map` or the typed array out of `details` reads the record. Who is hurt: an in-process reducer that read a `Map` / typed array out of `details`. How to migrate: put the data in plain records and lists. Why: a clone of such an object at the door would be a second form beside the wire's (and a class-instance clone carried members the wire never read — C-R746 note 1).
784
+ - **Hard-breaking (open to veto) — a block is its own enumerable fields.** What changed: a content element whose `type` / `text` are a class's prototype accessors is no block: it is received as the wire reads it (its own enumerable members, here `{}`), and the model gets the `[invalid content block normalized to text]` salvage text where it read the accessors through the prototype before (`tool_end.output` already showed `{}`). A class whose fields are own members is a block like any record. Who is hurt: a tool that builds content blocks from a class with getter fields. How to migrate: return `{ type, text }` records (or own fields). Why: one reading for the model and the wire, the one the `TextContent` / `ImageContent` / `DocumentContent` types describe (own data fields).
785
+ - **Members a tool kept off the wire stay off (C-R746).** A non-enumerable `details` member and a list's non-index member — absent on the wire before this change — and every member of a `toJSON` carrier (at the top, inside a class instance, or reached through another carrier; the answer that rode the wire before no longer rides) are absent at the door, on `tool_end.structured` and on the transcript row; no `toJSON` of the tool's is called on content, `details` or a thrown error's `details`.
786
+ - **Hard-breaking (open to veto) — unreadable content is one answer.** What changed: a `content` that cannot be read (a revoked proxy) returns the structured refusal "content must be a string or an array of content blocks" (`isError`) instead of the thrown TypeError's text; a content element whose `type` cannot be read is received as data instead of failing the call; an absent element (a hole, `undefined`, an index that throws) is left out instead of failing the call. Who is hurt: a test pinning the old failure. How to migrate: pin the refusal or the result.
787
+ - **The delegation tool's "no child ran" is the engine's receipt, and it crosses the door with the object (unchanged from 7.55.0; a narrowing of an earlier state of this release, open to veto).** What it is: the `Agent` tool's refusals before any child ran (an unknown type, an empty prompt, worktree isolation unavailable, a cap, a revive it cannot make) are minted in one place, which records the refusal's `details` object in a table only engine code can write; every copy the engine makes of a recorded object is recorded too — the tool door's copy of what a tool returned, and the `details` the large-result offload rebuilds around a spilled string — so the receipt travels with the object, whatever name the tool is mounted under (two doors in a row keep it), and the memory content-origin wrap exempts a delegation result from the exposure mark only when its `details` carries that receipt. A result that merely says no child ran is a child's report: a caller's delegation adapter (a tool declaring `agentToolFaces` / `agentListing`) returning `details.childRan: false` — with or without an `attestation`, the refusal's own words included — is judged delivered and marks as any report does. Relative to an earlier state of this release, which carried the fact as the data member `details.childRan: false` and trusted it from any tool that declared a delegation face (a tool could skip the mark by saying so): that member is gone and the statement buys nothing. Relative to 7.55.0 the refusals' `details` read as they did (`{ error, code }`) and only an engine-minted refusal is exempt; one widening (open to veto): a refusal whose `details` carries a string past the offload threshold (a fork whose session store threw a long error) lost its exemption on 7.55.0 when the offload rebuilt `details`, and marked the session for a call in which no child ran — it is exempt now. Who is affected: against 7.55.0, a deployment whose delegation refusals carry such strings (its session is no longer marked by them); against the earlier state, a caller's delegation tool that wrote `childRan: false` (it now marks) and a consumer that read the member (it is absent). Not closed here: a host's own code that calls the delegation tool, keeps the refusal's `details` object and returns it under different content — or writes the child's text into that very object — is exempt, as in 7.55.0 (the receipt names the object, not the content beside it or what is written into it).
788
+ - **Fixed — the one receipt never asks `Array.prototype` (every mode).** Before: the receipt's own bookkeeping (its stack of containers, its path of names, the copies it filled) pushed onto and assigned into ordinary lists and records, so an index accessor someone planted on `Array.prototype` (or a setter on `Object.prototype`) ran inside every receipt — the deps `new Runner` reads, a declaration, the org rules, a mods payload or answer — and the receipt then threw a `TypeError` instead of answering (v7.55.0 measured: the mods payload mode and a configuration mode both threw, the planted accessor called twice). The same held for an inherited `get` / `set` on `Object.prototype`: the receipt's own property descriptors inherited it, so a member's definition read it and failed as an invalid descriptor. Now its lists have no prototype, every copy is filled on none and every descriptor it writes has none, so the planted code is never called and the receipt answers as it would without it. Who is affected: nobody whose process leaves the built-in prototypes alone.
789
+ - **Not closed here.** Content a PostToolUse hook rewrites is not rebuilt by the door's block law, so the content-side guarded readers stay; a `details` member the door cannot read is absent with no notice (KL-960-a); a built-in function or the array iterator replaced in the process after the engine loaded (`Array.prototype.slice`, `Object.keys`, `Array.prototype[Symbol.iterator]`) is still called by the door (KL-960-d).
790
+
791
+ ### The release post lint's three ledger legs read the marker a ship writes, not the version string: CAPABILITY wants a row beginning `| <post> | core <version>`, PLANS a `SHIPPED(` row of the release that cites the post, FEATS an `anchor-core` that says `shipped v<version>`; `--comms-root <dir>` names the comms checkout (#2272; release tooling only; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
792
+
793
+ - **Before.** Each leg passed when its ledger file merely contained the version number, and a release's number is in the ledgers long before it ships. For 7.55.0 the CAPABILITY file was satisfied by the server's own 7.55.0 release section and by core's 7.54.0 row (its sentence `… 7.55.0 起改普通 clone`), the PLANS leg by the window row of 7.55.0 (status still `OPEN(`), the FEATS leg by two anchors that said `已并 7.55.0 stage`. A gate that cannot be red on the day it matters is not a gate.
794
+ - **Now.** (1) CAPABILITY: a line of `audits/CAPABILITY-CONSUMPTION.md` that begins `| <post> | core <version>` (or the post says `CAPABILITY: none`, unchanged). (2) PLANS: a row of the `## core` section whose window cell begins with the version, whose status cell begins `SHIPPED(` and whose line cites `[<post>]`. (3) FEATS: at least one cited feature's `anchor-core:` front-matter field contains `shipped v<version>`; AUDITS is unchanged. The ledgers are read as the comms tools read them: a table row is cut at every pipe that is not escaped (spaced or not), a front-matter field is one `key: value` line, a header counts only with both fences. A version is a whole token (`7.55.0.1`, `7.55.01`, `7.55.0x`, `7.55.0+build.1`, `7.55.0-rc.1` are other numbers). `<post>` is the `id:` in the draft's front matter, so the draft to lint is the claimed `posts/NNNNN-core.md`; a draft without it is refused once, by name. `--comms-root <dir>` names the comms checkout for these legs and the bugs leg (default unchanged: `sema-comms-core`, then `sema-comms`; the rulings leg keeps its own search). Each marker was taken from the rows the real ledgers carry, and replayed on them: on the comms state one commit before the 7.55.0 ship record the old legs read green and the new ones read red on all three; on the state after it, green on all three; the seven CAPABILITY rows since 7.49.1 and twenty PLANS `SHIPPED(` rows read as ships.
795
+ - **Who is hurt.** The publisher, once: the three ship rows are written after the post number is claimed and before the lint runs; a row written earlier no longer satisfies a leg. So is anyone who keeps a `capSectionFile` template for `scripts/post-ship.mjs` in the old `## core 发布:` section shape (next bullet). Nothing at runtime: `src/` is untouched and nothing in the package changes.
796
+ - **`scripts/post-ship.mjs` step ③ writes the row the leg reads (release tooling only; hard-breaking for the template file, open to veto).** What changed: step ③ used to insert a `## core 发布:<version>(…)` section (the registration shape of 7.39.0–7.48.0) before the first such heading; the registrations since 7.49.1 are one `| <post> | core <version>(minor;…):… | <consumers> |` row each, which is what the CAPABILITY leg reads, so two shapes answered one question. Now the `capSectionFile` template (the key keeps its name, so an old manifest does not silently skip the step) is ONE row, its `⟨PLACEHOLDER⟩` tokens are filled, the lint's own predicate `capabilityRegistrationProblems` must accept it for this version and ship post, and it is inserted after the numbered-row run that holds the last core registration row. Who is hurt: whoever keeps a template in the section shape (or a row keyed by another post or version) — the run stops with `capSectionFile must hold ONE registration row` / `is not the registration the ship lint reads` before anything is written, dry run included. How to migrate: make the template one line `| ⟨SHIP⟩ | core ⟨VERSION⟩(minor;提货单 [⟨PICKUP⟩] · 判据帖 [⟨CRIT⟩];tag v⟨VERSION⟩ `⟨R⟩`):<capabilities> | <consumers> |`; `insertCapSection` is gone, `insertCapRow` and `requireCapRow` are the two new exports (tooling only).
797
+ - **Pins.** `test/ship-post-lint-ship-time-legs.test.ts`: per leg, three readings that must pass and three or more that must not, quoted from the real rows (CAPABILITY `CAP-P1`–`P3` / `N1`–`N3`, PLANS `PLANS-P1`–`P4` / `N1`–`N6`, FEATS `FEATS-P1`–`P3` / `N1`–`N3`); the legs against a throwaway comms checkout (opt-out, a draft without an id, a missing ledger file); the CLI's `--comms-root`; `CAP-N4` is the old section shape alone, refused. `test/post-ship-script.test.ts`: the row goes at the end of the numbered run holding the last core row (two runs, a text-first row, no anchor), the template is refused unless it is one row the lint accepts (section, several lines, another post, another version, another repository), and a `run()` on a temp comms root leaves ledgers whose CAPABILITY and PLANS legs both pass. Per-fixture mutation tables (34 for the lint, 10 for post-ship, none survived) are in the car's receipt.
798
+
799
+ ### A named agent runs in the background, where its name reaches it: `Agent({ name })` takes the background lane whatever `run_in_background` says, and the synchronous report no longer claims `resumable:` (#1697; behavior change, open to veto; hard-breaking: one, open to veto; model-visible text; closed sets: none; exports: none; members: none) (@cli @client-core @test @server)
800
+
801
+ - **The rule.** A name is an address. On a mount with a background lane, a call that names its agent takes that lane — a third OR term beside `run_in_background` and a definition's `background: true`, the shape of the reference's teammate branch, which runs a named spawn in the background and never reads `run_in_background` (CC 2.1.296). The background lane is the one lane that keeps an agent addressable after it settles: by name, SendMessage delivers to it while it runs and continues it after it settled (retained session or durable record). A named synchronous child was a name that addressed nothing — its report said `resumable: true`, and the next SendMessage by that name was refused "a completed foreground agent is not resumable" (test [10335] E3).
802
+ - **The `resumable:` word means one thing.** On every model-facing face it answers "will the next SendMessage continue this agent?". A synchronous child has no task row for SendMessage to resolve, so the synchronous report carries no `resumable:` line; a retained synchronous child is continued by the deployment's resume handle (`SubagentSteerHandle.resume`), which the report's footer states and the handle carries. The background lane's completion frame keeps its `resumable` field (unchanged).
803
+ - **Hard-breaking (open to veto).** What changed: on a mount with a background lane, `Agent({ name, run_in_background: false })` launches in the background — its receipt is the async card and its result arrives as a completion notification (or through `TaskOutput`), not inline; a named child refused at its own prepare is reported as a failed background run, not as the call's refusal. Who is hurt: a model, host or test that named an agent and expected its result inline; a reader of the synchronous report's `resumable: true` line (none found in the server, cli or client-core trees — they read the notification frame's structured `resumable`). How to migrate: to have the result inline, do not name the agent (an unnamed `run_in_background: false` run is unchanged); to keep it addressable, name it and wait for its notification. A deployment continuing a synchronous child keeps using the steer handle.
804
+ - **Model-visible text.** The `name` parameter reads "Where the SendMessage tool is mounted, the name makes the agent addressable via SendMessage({to: name}); either way a named agent always runs in the background (omit name to run one synchronously)" on a mount with a background lane (the address claim keeps its mount qualifier; the lane fact holds whether or not SendMessage is mounted), and "a label only: agents on this mount run synchronously, so a name does not make one addressable" on one without; `run_in_background` adds "(a named agent always runs in the background)", and so does the tool description's "Pass `run_in_background: false` for a synchronous run …" sentence in both of its variants (one sentence, `SYNC_ESCAPE_SENTENCE`); the classic (non-lean) variant's continuation sentence "Use SendMessage with a completed agent's ID to continue it with its context intact" — unqualified while the lean variant named both conditions — is now the lean variant's own sentence (where the SendMessage tool is mounted, and when session retention or durable named-agent revival is enabled; one sentence for both variants, `continuationSentence`); SendMessage's no-match note reads "a completed foreground agent is not addressable (nothing retains its transcript) — name an agent or run it in the background to keep it addressable"; and the async launch card's `task_id` line, which taught "Use SendMessage with to: '<id>', summary: … to continue this agent" whether or not SendMessage is mounted, now says "Where the SendMessage tool is mounted, use it with to: '<id>', summary: … to continue this agent" (the card is built by a factory that cannot see the mount, as the description is; the reference's card is unqualified because it always mounts SendMessage).
805
+ - **Consumers.** @cli: a named agent's card is the async card — render its row from the background lane's frames (`task_started` / completion), as for any background agent; the shell's own list keeping a finished named agent visible while it is continuable is the shell half of #1699 (open). @client-core: no wire key, member or closed set changes. @test: re-run [10335] E3 on this build — the spawn receipt is the async card and SendMessage by the name continues the agent. @server: no wiring; a host that drove `run_in_background: false` with a name to get an inline result now gets the async card.
806
+ - **Not changed here.** Re-using a name still resolves to the newest agent under it (#1696), and ListAgents does not yet state how long a finished agent stays continuable (#1699): both need one reading of "can SendMessage reach this agent", which today lives inside SendMessage's delivery ladder only (design/1697 §4). On a mount with no background lane a named spawn runs synchronously as before. A named fork still takes the fork lane's background branch (not continuable, as before).
807
+ - **Pins.** `test/backlog1697-named-agent-background.test.ts` (the E3 shape on a real Runner: async card, then SendMessage by the name continues it; an unnamed foreground child stays synchronous with no `resumable:` line; a mount with no background lane; the schema texts — all four red on the base tree); re-pinned `test/rb253-256-agents-receipt-footer.test.ts` (the retained synchronous report carries no `resumable:`; the launch card's mount-qualified follow-up line, also in `test/rb288-295-agents-face.test.ts`), `test/backlog1110-agent-child-inherits-scenario-surface.test.ts` (its rig waits by the lane rule; a named teammate pinned for both asks), `test/prompt-golden-1302.json` (the `name` text on a mount with no background lane). The measurement behind #1698 rides `test/backlog1698-one-notification-block.test.ts` (one block per completion on every engine path; two only when the host wakes the session with its own copy while the engine drains its parked one — KL-1698-a; the one-mint-point fix belongs to the wake layer, @client-core @cli).
808
+
809
+ ### The person whose run drives a tool shell is told which variables of the engine's environment the credential scrub and the own-credential rule withhold from that shell — names only, never a value, and the way out for each: `inherit_env.names_withheld`, read off the execution environment's own answer `ExecutionEnv.withheldEnv` (#1963; ruled C-R744; user-visible; differs from the reference implementation, which says nothing, open to veto; hard-breaking: two, open to veto; behavior change: one, open to veto; closed sets: `ENGINE_NOTICE_CODES` 100 → 101, +`inherit_env.names_withheld` (audience `user`, minted by the engine); exports: none (the split is the engine's own); members: +1 required `ExecutionEnv.withheldEnv`) (@server @cli @client-core @test)
810
+
811
+ - **The rule.** Which entries a tool shell's child does not receive is decided by one composition — `inheritedHostEnv(policy, findings)` and the environment's explicit grants — and the execution environment hands that answer over as the read `ExecutionEnv.withheldEnv` (`src/engine/harness/types.ts:720`): the findings, names and rules, never a value; `[]` when nothing is withheld (or nothing is inherited, a shell environment built rather than inherited from this process); `"unknown"` when the adapter cannot say. `NodeExecutionEnv` reads it off the same merge its `homeDir` and `cdPath` read, announcing nothing. As each call to the engine's own shell tool starts on a root leg, the run reads the answer and tells the session's person, once per session per name set (the session's announced record; a second call or a second message of the session says nothing, a newly withheld name is told again). It is read at each call, not once at the start, because the answer grows during a run: a credential the engine takes in when it first routes a model (a model entry's own credential header), dials a peer or builds a seat is withheld from the next child, and a shell call always follows a model request. The notice says: `detail.state: "withheld"` with `names` and, when any, `ownCredentials`; `"unknown"` with no names, saying the engine could not determine them. An answer that is neither a list of findings nor `"unknown"` — absent included — is never read as `"unknown"`: it is the operator's `config.execution_env_capability_invalid` (`detail.key: "withheldEnv"`) and the person is told nothing. The model is never told a withheld name (it would point it at the engine process's own environment, KL-1753-a; the way out is the person's).
812
+ - **One split for both audiences.** One split answers `{ names, ownCredentials }`: `ownCredentials` are the withheld entries that are this deployment's own credential — a declared name, or a value equal to a declared or engine-held credential value — by the same judgment the own-credentials subtraction withholds them by, whichever rule withheld them first. The rest are the scrub's: `inheritEnv: "all"`, or an allow list naming them, hands them on; no policy hands on the own half. `config.secret_env_scrubbed` and the person's notice read it, so they never disagree about which names are the deployment's own (pinned).
813
+ - **Behavior change (open to veto).** What changed: `config.secret_env_scrubbed`'s `detail.ownCredentials` names every withheld entry that is this deployment's own credential; under `"scrub"` a credential-shaped declared name (`SERVICE_AUTH_TOKENS`, `MODEL_API_KEY`) used to count as the scrub's only, so the list was short of exactly those. Who is hurt: a reader treating `ownCredentials` as "what the shape rules missed". How to migrate: that reading is the findings' `kind` (`own-credential`), which is unchanged — as are the findings themselves and any count keyed by their kind.
814
+ - **Hard-breaking (open to veto) — `ExecutionEnv.withheldEnv`.** What changed: `ExecutionEnv` gains a required read-only member `withheldEnv: readonly SecretEnvFinding[] | "unknown"` (`src/engine/harness/types.ts:720`, the shape of `cdPath`). Who is hurt: every `ExecutionEnv` implementation fails to compile until it answers — @server's seven adapters (`RemoteHostExecutionEnv`, `RemoteContainerExecutionEnv`, `RemoteK8sExecutionEnv`, `RemoteSshExecutionEnv`, `RemoteLocalDockerExecutionEnv`, `RemoteDeviceExecutionEnv`, `RemoteAdbExecutionEnv`) and any host's own adapter or test double. How to migrate: a host-lane adapter returns the findings of the composition its shell child reads (`composeHostSubprocessEnv({ policy, allow: [] }, findings)` on the server's host lane), less the keys its explicit grants re-supply; an adapter whose shell environment is built rather than inherited returns `[]`; one that cannot say returns the literal `"unknown"`. Why required: an optional member would let a host lane that never answers stay silent forever, which is the defect this change closes.
815
+ - **Hard-breaking (open to veto) — the notice catalog.** What changed: `ENGINE_NOTICE_CODES` gains `inherit_env.names_withheld` (audience `user`; `detail: { state, names?, ownCredentials?, sessionId, runId? }`; it always carries `sessionId`). Who is hurt: a consumer switching exhaustively over the catalog, or holding an audience or whitelist table that must account for every user code — this was the one way to learn of a withheld variable (the operator's line), it is now one of two. How to migrate: route it to the session's stream and render the two halves; or refuse it with a written reason.
816
+ - **Consumers.** @server (S-1679): answer `withheldEnv` on the seven adapters (the host lane through the same composition its `cdPath` reads); enter the code in the session-stream whitelist (its dedupe unit is the session) — the transit then carries it; no server-side sentence is needed. @cli: render by code, the two halves apart; `-p` has no way to send `inheritEnv: "all"` today (the shell sends it on the interactive lane only), so the scrub half's way out is the shell's to word — a switch, or a sentence saying the interactive lane hands them on. @client-core: the code is a new user row in a table keyed by code. @test: `-p` with a credential-shaped name and a declared own name exported, and a prompt that makes the model run a command ⇒ one sentence per session, at the first shell call, the halves split right, no value anywhere; a second call or message ⇒ none; a run whose model calls no shell ⇒ none; `"all"` ⇒ the own half only.
817
+ - **Not changed here.** A per-exec `env` that re-supplies a withheld name still has it listed (the read has no exec); a delegated child is not told (the person reads the root leg), even on an environment of its own — so a session whose only shell calls are a delegated child's is never told — and neither is a session whose only shell is the Monitor tool, though its spawn reads the same shell environment (the notice is read where the engine's shell tool starts); under an allow-list `inheritEnv` the variables outside the list are not inherited either and are not findings, so "does not receive N variable(s)" counts the scrub's and the own-credential withholds, not every variable the list leaves out; a host that wires no `onNotice` gets the notice's text on the console (`console.warn`, names only), once per session per name set where the operator's console line was once per process; the split reads the declaration as it stands when the notice is made; on a host lane shared by several people, each is shown the names (never the values) of the engine's credential-shaped variables, which that lane's shell can already reach (KL-1753-a; KL-1963-a).
818
+
819
+ ### The gate-record screen reads two more facts a reader of a persisted row used to judge by hand: `settlement.when` is a finite number (I10), and a winner's `decision` beside its record names the direction the record names (I11, `winnerDecision`) (#2271; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none (one optional key on the existing `screenGateOutcome` options); members: none) (@server)
820
+
821
+ - **Before (v7.55.0).** `screenGateOutcome` read the shape and the closed-set words of a `GateOutcome` (I1–I9) and nothing about two facts a row's reader needs beside them. (1) `settlement.when`: any value passed, so a reader that orders or shows the moment a wait ended judged "is it a finite number" at its own site, while another reader of the same record put the raw value on its wire — one bad moment, two answers. (2) The direction of a durable row's winner: `ResolvedOutcome.decision` (`allow` / `approve` / `edit` | `deny` / `reject`) and the record it carries (`gateOutcome`: the settlement kind's direction, the disposition's) could say opposite things and each reader took whichever it looked at.
822
+ - **Now.** One screen, two more invariants (`src/core/gate-outcome.ts`). **I10**: `settlement.when`, when a settlement is present, is a finite number (`typeof` number and `Number.isFinite`) — `NaN`, `±Infinity`, `null` (what JSON writes for a non-finite number), an absent member and text are each one `I10: settlement.when … is not a finite number (epoch ms)` sentence, in both readings. **I11**: a caller reading a durable row's winner states the winner's word beside the record — `screenGateOutcome(winner.gateOutcome, { reading, winnerDecision: winner.decision })`, the key present whatever it holds, read as `reading` is read (an options object that inherits it states it too) — and the screen reads it: the word is one a winner holds (`allow`, `approve`, `edit`, `deny`, `reject`; one `I11: winnerDecision … is none of the words a winner holds (…)` sentence otherwise), its direction (allow: `allow` / `approve` / `edit`; refusal: `deny` / `reject`) is the direction of the settlement kind (`SETTLEMENT_IS_REFUSAL`; `I11: the winner's decision "x" is the allow direction but the settlement kind human_refused ended the wait as a refusal`) and of the disposition (`allowed` / `denied`; `I11: … but the disposition reads denied`). A record with no settlement is not judged for its absence (the settlement arm reads a settlement kind that is there); without the key nothing is read. Same sentences, same invariant numbers, in the `archive` and the `producer` reading.
823
+ - **Behavior narrowing (open to veto).** (1) **What changed:** a record whose `settlement.when` is not a finite number now has a defect where it had none, in both readings. In the engine this is the gate's own composition: the producer reading refuses at the mint (`the tool gate composed an outcome that violates its own record: I10: …`), and a tool-gate result whose outcome carries one is reported and its frame carries no outcome. The one engine source of a moment other than `Date.now()` is `RunnerDeps.now` (the `approver_unavailable` settlement, `src/core/runner/prepare-ask-lane.ts:247`). (2) **Who is hurt:** a deployment whose injected `RunnerDeps.now` answers a non-finite number (that settlement is then refused at the mint instead of filed with a bad moment); a reader of archived rows that passed a non-finite `when` through the screen (a row the file backend wrote after a `NaN` reads back with `null`) — it now gets a defect for it, and a reader that withholds a record on any defect (the server's trace projection) withholds it. (3) **How to migrate:** a deployment's `now` answers epoch milliseconds (`Date.now` is the default); a reader that handled a bad moment itself can drop its own check and read the screen's.
824
+ - **Consumers.** @server (S-1959) — `src/approval-gate-currency.ts:144` (`Number.isFinite(settlement.when)`) retires: the screen at `:140` now reads it; pass the winner's word at the same call, `screenGateOutcome(recorded, { reading: "archive", winnerDecision: winner?.decision })`, and the direction the module header at `:30-33` lists as unjudged is judged there (the reading stays the row's own: a winner/record disagreement is a defect, so `unknown`); `src/trace/project.ts:319` (`screenGateOutcome(v)`, which puts `settlement.when` on the wire at `:461`) gets I10 with no change — a non-finite moment now takes its existing report-and-withhold path. The kind × party pairing stays unjudged (KL-1265-b). The key is optional: a caller that does not pass it sees I10 and nothing else new.
825
+ - **Not changed.** Every other invariant, the retired-member registry (a winner's words are not a gate record's closed set: none has left), the exports (`screenGateOutcome`'s options type gains `winnerDecision`; no new runtime name), the producer sites (the gate's exit and its station screen read I10 through the same screen; neither has a winner beside the record).
826
+ - **Pins.** `test/backlog2271-gate-screen-when-and-direction.test.ts` (I10 per value over both readings, every settlement kind, the JSON `null`, the mint, a NaN clock; I11 per word over every approval / refusal kind, each arm alone, a word outside the set per value, an absent key reading nothing; the real durable lanes — a tool approval decided by a person (allow, deny) and by the host's SLA sweep, a plan review approved, edited, rejected — read clean against their own decision and are named by the arm a corrupted row breaks).
827
+
828
+ ### WebSearch's SearXNG backend is the host floor's sixth reader: `RunnerDeps.hostFloor` judges its request and every redirect target as it judges the fetch tool's (#2325; security; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `hostFloor` in the `opts` argument of `WebSearchConfig.search`) (@server)
829
+
830
+ - **Before.** `createSearxngSearchBackend` sent its request with the platform's own redirect following and read no floor: under `hostFloor: { only: [] }` a WebSearch still dialed the instance, and under `deny: ["127.0.0.1", "localhost"]` it dialed 127.0.0.1 and followed a 302 to `localhost` while the same run's WebFetch of the same URL was refused; nothing judged a redirect on its way to `169.254.169.254`. The floor's notes in this release said the floor binds "every engine executor that connects" and that `only: []` leaves "no structured egress" — a claim this one executor did not meet.
831
+ - **Now.** The backend sends every request through the one redirect loop (`fetchFollowing`) under the one hop judge (`hopRefusal`): its URL and each redirect target are judged before they are requested, and a refused hop is never contacted. The search rejects with `config.host_floor_refused` and the judge's own sentence — `SearXNG request is not sent — host "localhost" is denied by this deployment's host floor (hostFloor.deny); refused`, or `… is outside the hosts this deployment reaches (hostFloor.only); refused` — the words the fetch tool's refusal carries; the tool returns it as any backend failure (`Error (WebSearch): the search backend failed.`, the text fenced) — with the generic retry advice, because the failure classifier reads the message text and not the error's `code`, where the fetch tool says no rule, mode or approval clears a floor refusal. The floor is the run's: `createWebSearchTool` hands it to the backend in `opts.hostFloor` (`ToolExecuteContext.hostFloor`; the key is present only when a floor is declared), never a factory closure, so one backend under two Runners answers each Runner's own floor. The loop follows every judged redirect (301, 302, 303, 307, 308) that carries a Location, up to 20 — `fetch`'s own set and bound; with no floor a local instance answers and a 302 to localhost is followed as before (three differences below). A redirect's response that the loop follows or refuses is released — its body cancelled — for the fetch tool's and A2A's use of the loop too. This reader holds no private-network floor: the instance URL is the deployment's own declaration (a local instance is the usual one), as an MCP endpoint and an A2A peer are, so a redirect onto a private address is stopped only by a floor that does not admit it.
832
+ - **Behavior narrowing (open to veto).** (1) **What changed:** with a floor declared, a WebSearch whose SearXNG host — or the host of a redirect it answers with — the floor does not admit is an error result; and a redirect target the hop judge cannot read (credentials in the URL, a host that does not read back as one host, a scheme other than http(s)) is refused with or without a floor, as the fetch tool and A2A refuse one. (2) **Who is hurt:** a deployment that declares a floor and mounts the SearXNG backend without admitting the instance's host (a local instance: `127.0.0.1`). *server*: `src/plugins/web-search.ts` (`searxngSearch`, read in its checkout at 3ff6dec7, not run) passes the tool's `opts` to this backend unchanged, so the floor reaches the searxng leg of its search plugin the moment the Runner declares one; its brave and tavily legs are its own `fetchImpl` calls and read no floor. (3) **How to migrate:** add the instance's host to `hostFloor.only` (an entry naming a private address also lifts the private-network floor for it, which this reader does not hold), or take it out of `deny`.
833
+ - **Three differences with no floor (open to veto).** (1) The injected `fetchImpl` is called once per hop: the platform used to follow inside one call. An implementation that adds a credential header (`Authorization`, `Cookie`) now adds it on a cross-origin hop too, where the platform's own following dropped `Authorization` — scope such a header to the origin it is for (KL-2325-b); the default backend sends `Accept` alone. (2) An empty `Location` header is read as no header, so the 3xx is the answer, where `fetch` would follow it to the same URL (KL-2325-a; the fetch tool and A2A read it so already). (3) `fetchImpl`'s first argument is the URL's string, not a `URL` object (`typeof fetch` allows both).
834
+ - **Not changed.** A search backend a deployment injects itself — server's brave and tavily legs, a provider-native search — holds its own egress and may ignore `opts.hostFloor`: the floor does not bind it. `probeSearchBackend` (an onboarding connectivity check that runs outside any Runner) calls the backend without `opts`, so it dials the instance whatever floor a Runner declares: the floor is a Runner's deps. The result-side `allowed_domains` / `blocked_domains` filters are the model's, not the deployment's, and are unchanged.
835
+ - **Pins.** `test/backlog2325-websearch-floor.test.ts` (D1–D8 the backend itself: `only: []` refused at the first hop with nothing requested; a 302 into a denied host refused on the second hop with the target never requested; a denied base; the metadata address under `only`; a chain of admitted hops, relative and absolute, followed; the no-floor control; every hop requested by the one loop with `redirect: "manual"`; no private-network floor; the loop's other refusals; an unchanged HTTP error; D9 the loop cancels the body of a redirect it follows or refuses and leaves a handed-back one and the answer alone. L1–L6 on a real Runner: the sentences, the controls, one backend under two Runners, `opts.hostFloor` present only when declared, one sentence with the fetch tool's).
836
+
837
+ ### A call runs, and is judged and shown as, the instance it dispatches to: an approved resumed call runs the tool its roster row was minted from, a deferred tool's placeholder stands for one mounted instance and runs that one, and the approval card's preview and a per-call row's boundary legs are read off that same instance (#2283, #2284; ruled C-R745; security; four narrowings, one widening and two behavior changes — the first of those two moves both ways: it widens one call shape and narrows another — all open to veto; hard-breaking: none; closed sets: none; exports: none; members: none on the package surface) (@server @cli)
838
+
839
+ - **The rule.** Which tool object a call is about is fixed where the call is judged and carried to its execution; nothing on the call's own path looks the name up again a second way (one reader outside it does: the `$.tool.call` door that tells the delegation tool by its bound `execute` takes the first caller tool of that name, `specOf` in `prepare-task.ts`, so with two caller tools under one name the door may judge another instance than the one the harness runs). A roster row is minted from the instance the dispatcher resolves its name to (the later mount under a shared name, the harness's yield); a resumed approval's two stations judge the row under the parked wire name, and the call runs the instance that row was minted from. A deferred tool's placeholder is minted for one mounted instance; the harness resolves a call to its placeholder before the gate, as it resolves every call to its tool, so the call carries that instance to its execution. The approval card's preview and the cross-boundary seat's per-call legs resolver are read off the instance the dispatcher resolves the call's name to. Retired: four first-match lookups — the resumed call's over the mount array, the placeholder's when it executes, the preview's by name or alias, the per-call resolver's by name. Claude Code 2.1.296 resolves a tool use's tool once, when the use starts (`l2`, `cli296.js:249209`: `fn(s.options.tools, …)`), and hands that object to permission and execution (`runToolUse({ tool: M, … })`); it has no durable resume and no placeholder object.
840
+ - **Security narrowing, #2283 (open to veto).** What changed: a parked approval whose wire name two mounted instances share — a caller tool and the engine tool that shadows it (the hands band mounts after `TaskSpec.tools`: `Write`, `Bash`, …), or two caller tools under one name — runs, once approved, the instance the live loop dispatches the name to and the stations judged, the later mount. It used to run the first one mounted, which the approval card did not describe: a caller's `Write` ran in place of the approved engine `Write` and the file was not written; a caller's `Bash` ran in place of the engine's `touch`. Who is hurt: a deployment whose resumed approvals ran its own shadowed tool — that tool no longer runs on resume, as it never ran live (prepare already warns that the built-in wins). How to migrate: give the caller tool a name of its own.
841
+ - **Security narrowing, #2284 (open to veto).** What changed: a deferred tool called directly whose instance a host re-dial (`reconnectMcpServer`) or a refresh replaces while the call is being judged — an approver's card up, say — runs the instance the call was resolved to; an MCP instance whose server now lists the tool differently is refused before it is sent (`mcp.tool_declaration_changed`), as a call to the same tool not deferred already was. It used to look its name up again when it ran and send the newly listed instance, which nobody judged: measured, a re-listing that declared the tool destructive while the card was up was sent and run on the new connection. A re-dial that withdraws the tool while its card is up now answers with the same refusal instead of the placeholder's "not active yet, call ToolSearch" teaching. Who is hurt: a host that changes a deferred tool's declaration while one of its calls is being judged and expected the call to run anyway — the model calls again and that call is judged on the new declaration. How to migrate: nothing.
842
+ - **The two lanes now agree, including their shared gap (behavior change, both ways: a widening for the stale-shape call, a narrowing for the new-shape call; open to veto).** A deferred call and a call to the same tool not deferred now read alike: the instance is bound when the call is judged, and the MCP listing check it meets before sending compares the raw name, the safety axis, the read-only and "a person must answer" marks and the task-support refusal — not the input schema. So a schema-only re-listing while a card is up is not re-judged on either lane: the bound instance runs and validates the arguments against the schema it was judged with (KL-2284-b). Measured: a re-listing that adds a required parameter, the call lacking it — not deferred, sent on the base and on this build; deferred, not sent on the base (its late lookup validated against the new schema) and sent on this build; the reverse, a call carrying a parameter only the new schema allows — deferred, sent on the base and refused on this build with the old schema inlined in the invalid-arguments answer. Who is hurt: a server that re-lists a tool with a stricter schema while one of its calls waits on an approver, and relies on the engine to refuse the stale shape (the server's own validation still applies). How to migrate: re-list with a name or safety-axis change, or have the model re-issue the call after the re-listing.
843
+ - **Security narrowing, the approval card's preview (open to veto).** What changed: the preview an ask and a parked row carry (`AskRequest.preview`) is the projection of the tool the call dispatches to; two tools under one name showed the first one's preview, and a caller tool shadowed by an engine tool (a caller `Write` beside the hands band) lent the engine call its own preview — the person saw the caller tool's account of a call the engine tool ran. Who is hurt: a host whose shadowed tool's preview appeared on the engine tool's card. How to migrate: nothing.
844
+ - **Security narrowing, a per-call row's boundary legs (open to veto).** What changed: the cross-boundary seat reads a `"per_call"` row's legs resolver off the row's own instance, the later mount; it read the first tool under the name, so a caller tool sharing the name of an engine per-call tool (`TaskStop`, `TaskOutput`, `Artifact`) had its own `boundaryLegsFor` judge the engine tool's calls (a caller tool's declared legs never make its own row per-call). Who is hurt: nobody known (pinned at component level: the lane and a real roster builder). How to migrate: nothing.
845
+ - **Widening, the resumed engine ToolSearch (open to veto).** What changed: a parked `ToolSearch` call (a policy that asks about it), approved, runs on the resumed leg. It used to miss its tool there — the search tool is a roster row but not in the mount array the old lookup read — and failed with `resume.tool_unavailable`, reopening the row for a decision that could never run. Who is hurt: nobody known; a deployment that asks about ToolSearch and relied on the approval never running. How to migrate: deny it instead of asking.
846
+ - **Behavior change (open to veto).** A placeholder schedules as the instance it stands for (`executionMode`) and counts repeated invalid shapes per instance: a refresh's new instance gets a placeholder of its own, read off that instance, where one placeholder per name kept the scheduling of the instance mounted at prepare and its count across refreshes (under the `static` materialize strategy, where a placeholder stays after an invalid call, a model that alternates invalid deferred calls with refreshes that replace that tool's instance starts the count again at each refresh — a refresh that keeps the instance keeps the count, and under the default `swap` the first invalid call replaces the placeholder with the real tool; a run with no turn or budget ceiling has no other end: KL-2284-d).
847
+ - **Not closed here.** A call is fixed to its instance when the harness resolves it, a moment before the gate reads its row; a refresh landing between the two has the gate judge the new row while the call runs the instance it was resolved to (KL-2284-a: only the MCP lane replaces instances during a leg, and those refuse a changed declaration — though not a changed schema, KL-2284-b — before anything is sent). The preview and the per-call resolver read the dispatcher's resolution over the live mount array, the selection the row is minted by, not the row's recorded instance (KL-2284-c, a transitional form ruled C-R745 note 2): the two differ while a re-dial has replaced instances and not yet re-minted the roster, and on an engine-lane row (ToolSearch), which the mount array does not hold; by today's declarations no instance a re-dial replaces carries a preview or per-call legs (MCP tools are minted with neither), and the equivalence is pinned (E1). A parked MCP call resumed after its server re-listed the tool with other annotations (`destructiveHint`) runs on the resumed leg's fresh instance: the approval's tool identity (capability, contract, shape, implementation revision) does not hold the declaration the MCP lane's own check compares (measured on the base and on this build; a candidate ticket).
848
+ - **Consumers.** @server — a durable resume through the decide route runs the tool its parked row names as the engine dispatches it; a deployment mounting a caller tool under a hands or MCP name gets the engine instance on resume (the warning it already logs at prepare names the collision); the `reconnectMcpServer` route: a deferred call being judged across a re-dial is refused if the declaration changed, and a schema-only change is not re-judged on either lane (KL-2284-b). @cli — the refusal is the existing `mcp.tool_declaration_changed` tool result (`details.code`, `delivered: "no"`); the preview an approval card renders may differ for a shadowed tool (it is the running tool's now); no new frame, member or code.
849
+ - **Pins.** `test/backlog2283-2284-judged-instance-runs.test.ts` (R1 caller `Write` + the band's `Write`, parked and approved: the file is written, the caller's tool does not run; R2 two caller tools under one name: the later runs; R3 controls: the live loop runs the later instance, one tool runs on resume; R4 a parked name held only as another tool's alias misses loudly; P1 the preview of two same-named tools is the later one's, and a shadowed caller `Write` lends the engine call none; B1 a per-call row's legs come from the row's member (component); E1 the mint's selector answers every row's recorded instance by wire name and alias, the engine-lane row pinned as the one difference; D1 a deferred MCP tool re-listed destructive during its ask: refused before it is sent; D2 the same not deferred, refused on both trees; D3 an unchanged re-listing runs; D4 the next call runs under the new declaration; D5 a withdrawn tool: refused, not taught). Re-pinned: `test/prepare-tool-disclosure-mount.test.ts` (the placeholder holds its instance after a replacement; one placeholder per instance), `test/b2b-directcall-invalid-args-activation.test.ts` PIN ⑦ (the withdrawn-name arm is gone; the unrenderable arm stays), and the placeholder fixtures of `rb422-deferred-directcall-hardening` / `design388-write-fences` (`real` in place of `resolveReal`).
850
+
851
+ ### A leg's end record carries what its session is still owed: every unfinished background task that the leg's `{ owner, scope, sessionId }` access reaches (the reach the TaskOutput mount reads, whether or not that tool is mounted) is read as the poll the session's next leg would make of it, and a leg's end is judged once — and the registry has one reading of a leg's live background tasks, every one (#2277, #2160; security; behavior narrowing, open to veto; fix, user-visible: false orphan notices; hard-breaking: none; closed sets: none; exports: none by name; members: +2 `TaskRegistry.liveTasks`, `TaskRegistry.owedDeliveries`; the session record's shape is unchanged) (@server @client-core @cli @test)
852
+
853
+ - **The rule.** A leg's end (the settle, a suspend before its row) writes the device lane's session record as the tree stands joined with what the session is still OWED: each task the leg's access (`{ owner, scope, sessionId }`, the reach a TaskOutput mount would read; a deployment without that tool owes the same) reaches that has not finished — pending, running or parked — delivers into a later leg of the session, another tree, so it is judged as that leg's poll of it (`TaskRegistry.owedDeliveries`, the poll's own `boundaryLegsOf(…, "output", …)`, then the one obligation table): a background agent or workflow is content from beyond the device, a background shell travels its own environment's leg (on the target: nothing; off it: an outside read). What that owes — the off-target-read bit — joins the record, not the tree (a lane still on the tree is not put to a person for it). A reading that throws reads as one delivery from beyond the device and is reported on `onError`; the lane's reports are contained against a synchronous throw, so an `onError` that throws undoes no reading and stops no leg end (behavior change: a throwing `onError` no longer rejects the lane's prepare or a station's reading either); an `onError` that returns a rejecting promise still leaves that rejection unhandled, once per report — the same leak as before, with one more report point (a host callback is contained in several ways across the engine, and this lane has its own try / catch). A tree that never armed reads nothing.
854
+ - **A leg's end is judged once.** The leg end writes the state of that moment joined with what is owed, at most once; it no longer writes again for what other lanes of the tree grow while it waits on the store (their content is theirs; what is delivered to this session is owed and already joined). A prepare's and an exit's writes keep their judged-again form.
855
+ - **Behavior narrowing (open to veto).** *What changed:* on a device lane, a turn that ends with background work in flight (an agent or workflow running, pending or parked, or a background shell off the target) writes the off-target-read bit into the session's record; the session's next leg reads it. *Who is hurt:* `multi_tenant` deployments with a device lane — after such a turn every later device call of that session asks a person (the bit is monotone); a task-scoped background agent the same teardown aborts counts too (its notification can carry what it produced before the stop), and so does a background agent that a foreground child started, when the child's teardown aborted it and its delivery went to the child's session — the root session's access reaches it, so after the root's turn ends the root's later device calls ask, though that agent never read beyond the device. `single_user` deployments see no change (the bit is recorded, no judgment reads it). *How to migrate:* no configuration change; to avoid the cards, let background work finish within the turn (a same-tree `TaskOutput` poll moves nothing across the boundary) or do not launch background tasks in governed device sessions.
856
+ - **The notice's catch-up sentence is retired.** `config.cross_boundary_record_unwritten` no longer has the "an earlier record of this write landed … Until #2160 lands …" sentence: a leg's end writes one record and never a catch-up, so a failed leg-end write on a leg that wrote nothing before is a failed first write and the governed sentence ("the next leg … reads the content after its newest record as off-target, so its device calls are put to a person") is true of it. A parked leg wrote its suspend record before its settle's: if only the settle's append fails, the newest record is the suspend's, which already holds everything the session was owed, so the resumed leg reads no content after its newest record as off-target — its device calls are put to a person only if the suspend record itself carries the owed bit (an unfinished background agent at the suspend does), not because of the failed settle — and the sentence, said for `phase: "settle"`, over-warns there (nothing the session was owed goes unjudged). A governed **prepare**'s failure now says what is true there (it said more): the leg's next write — an exit before its call runs, a suspend before its row, its end — owes the record again, and only if none lands does the next leg read the content as off-target. `detail` is unchanged.
857
+ - **One reading of a leg's live background tasks (fix, user-visible).** `TaskRegistry.liveTasks(access)` answers every pending, running or parked task the access reaches, newest first, with no display cap; the leg end's owed deliveries, the post-compaction listing, the resume's orphan diff, the stop hook's `backgroundTasks` view and the suspend's snapshot read it (the snapshot leaves the parked out, as before: a parked child waits on its own checkpoint row). The two readings it replaces went through `list()`, which caps at 50 rows before it filters by status: with more than 50 background tasks alive, a resume after an approval pause announced live tasks as "were running and are now stopped" (measured: 60 live at the pause and 5 more before the resume ⇒ 5 false orphans; 40 + 15 ⇒ 5; now 0), and the pause's snapshot held 50 of 60. The post-compaction listing keeps its 50-row cap where it is rendered and now says how many it left out: `(+N more background tasks, not listed here)`. `list()` keeps its cap (a display listing).
858
+ - **Closed and left.** KNOWN-LIMITS KL-469-c is closed. Left, each with a ticket: KL-2160-a — a host-lane parent whose device child has not finished preparing when the parent's turn ends: the next turn's write beyond the device runs with no person (unchanged since 7.55.0; #2278); KL-2160-b — a revived child that finishes between two leg ends before its notification is delivered, and a revival in another process (#2279); KL-2160-c — a workflow or background shell aborted between its launch and its registration (inferred; #2279); KL-2160-d — a delegation tool given a registry other than the default one (#2280).
859
+ - **Consumers.** *server*: the record's `data` keeps its three members, so the SQL session-entry twin needs nothing; no server code reads the retired sentence; behavior: more `cross_boundary` cards on `multi_tenant` device lanes after a turn that ends with background work in flight. The leg end reads exactly what the TaskOutput mount reads — the default registry under `{ owner, scope, sessionId }`: a host that wires its own registry into the delegation tool is not covered (KL-2160-d). *client-core* / *cli*: the card class (`cross_boundary`) and its rendering are unchanged, it appears more often; the catch-up sentence disappears; the post-compaction listing may end with the `+N more` line. *test*: J1 and J2 below; g2c#inject flips green in this version (expected-red since 7.55.0); a judgment cell pinned on the sentence "not been written" / "#2160" is re-pinned (the arm is retired).
860
+ - **Judgment cells (black box).** **J1 — g2c#inject is closed:** `multi_tenant`, a device environment; the parent leg launches a background agent and ends; the child reads beyond the device while the parent's leg-end record append is held in the store; any later append of that leg fails (inject) or lands (control). Criterion: in both arms the next leg's device `Bash` raises a `cross_boundary` ask (refused ⇒ not executed), and no notice carries "not been written" / "#2160". Positive control: posture `single_user` ⇒ zero cards in both arms. **J2 — a host-lane parent with a device child does not loosen:** the parent's environment declares no target, the background child's (minted per child session by the factory) does, and the child finishes preparing before the parent's turn ends; the next leg calls a write beyond the device (effect `write`). Criterion: that write raises a `cross_boundary` ask (as in 7.55.0 — this cell is the counterexample of "do not write the leg-end record"). Positive control: the child's environment declares no target either ⇒ zero cards. Read-only cells: a parked or a task-scoped background child counts as owed; a background shell on the target does not; with more than 50 live tasks a resume after a pause names no live task as stopped. Expected-red: KL-2160-a (the parent's turn ends before the child prepares) — the next leg's write runs, #2278.
861
+ - **Pins.** `test/backlog2159-cross-boundary-bit-on-run.test.ts` (J1 inject / control / a session-scoped child with a task id that is not the session id / single_user; J2 and its control; the lane: settle and suspend carry an owed bit into the record and not the tree, a shell on and off the target, a reading that throws, single_user, a host-lane tree reads nothing; the registry: pending / running / parked under the owner, session and root arms, a finished task and another owner's excluded, 60 live read in full, each read as another tree's poll; more than 50 live tasks across a pause and a resume; the listing's `+N more`), `test/design469-r3-launch-gate.test.ts` (a leg's end writes once: a same-tree lane's read landing during its wait is not chased, an owed delivery is in the record), `test/design469-r3-boundary-legs.test.ts` (the notice: no catch-up sentence in any phase, the governed prepare and settle sentences).
862
+
863
+ ### One reader of a rule's tool name, and one reading of a `*` in it: the persisted rule validator and the approval policy's lists read a name as the rule syntax spells it, a durable partition serving a behavior word outside the closed set is read as one that could not be read, and a `*` is a glob over the whole name on a list that restricts and names no scope on a list that grants — the lone `*` included, the entries beside it keeping their reading (#1879, #1911, #1715, #1902, #1807, #1918; ruled C-R722; behavior narrowings and widenings, each open to veto; hard-breaking: two root exports renamed, one exported type member renamed, the session rule grammar 4 → 5, `isCoveringRuleName`'s behavior required, a padded org snapshot row refusing every call; closed sets: `wildcard_all` stays, allow-side only, and the earlier-grammar notice's reader word gains `grammar_revised`; exports: −2 +2 renamed, no count change; members: +1 optional `RuleReject.respelling` on the exported `RuleReject` type) (@server @cli @client-core @sdk @settings-schema @test)
864
+
865
+ - **Before (v7.55.0).** A tool name in a rule or a list had ten readings (measured on the base tree through the real lanes): the persisted rule validator `parseRuleText` trimmed a name before its `(`, so `" Bash(rm:*)"` and `"Bash\t(rm:*)"` were stored, snapshotted and edited as `Bash(rm:*)` while every list refused that spelling; `createApprovalPolicy` read its three lists as written — `requireApproval: ["Read(*)"]`, `["Bash(ps:*)"]` or `["Re*d"]` let the tool run without asking, `deny: ["Read(*)"]` refused nothing, and its projection carried `Read(*)`, which a resume's read-back refused; a durable partition serving a row whose `behavior` was `"maybe"` had that row skipped in silence; a `*` inside a name (`Re*d`, `Read*`) was a literal name every list kept and nothing ever matched, a leading `*` (`*ead`) was refused by some readers and kept by others, a peer glob (`mcp__git*`) was refused, inert or silently kept by list, the lone `*` was refused on a name-set deny and in a rule, read as nothing on an entry deny list, refused on a run's denied list (`config.tool_face_control`: "a denied list has no wildcard — the entry would hide nothing"), and on an allow-entry list (a skill's `allowed-tools`, `allowedTools`, a session's `toolAllow`) it voided the whole list.
866
+ - **Now — the name head (#1879, #1911).** `parseRuleText` reads the name with the rule syntax's own test (`ruleSpellingOf`): a padded or invisible-charactered head is `invalid.grammar` ("… is not a rule spelling …") at every door that hands it raw text — the store's `addPersistedRule`, the org snapshot, the consent edit (`precheckEditedRuleText`), rule sync. `createApprovalPolicy` reads `requireApproval` / `deny` / `autoAllow` through the screen `createAllowDenyPolicy` reads its lists with (one function): `Read(*)` / `Read()` are `Read`, held once; the projection holds the bare names; an entry no tool name can have is refused at construction (`config.invalid_tool_name_set`, `issues[].list` naming the list).
867
+ - **Now — the durable partition (#1715).** A served rule or tombstone row whose `behavior` is no word of `deny` / `ask` / `allow` makes the durable read one that could not be read, the row named in `unreadable`: every call is asked of a person (class `rule_unreadable`, bypass-immune) and the run gets `permission.rule_store_unavailable` once — the answer the bundled file backend already gave such a bucket.
868
+ - **Now — the `*` (#1902, #1807, ruled C-R722; CC 2.1.296: the deny / ask lookups match a rule's tool name with `globMatching` on, the allow lookup does not, and the settings validator says "Deny and ask rules accept wildcards anywhere").** One reader answers which tools a spelling reaches on a list (`isCoveringRuleName` / `ruleNameCovers`): on a list that RESTRICTS — a rule policy's deny / ask, a name set's `deny`, `requireApproval`, a session's `toolDeny`, a skill's `disallowed-tools`, an agent's `denyTools`, a run's `excludeTools` — a `*` anywhere in a name is a glob over the whole name (`Re*d`, `mcp__git*`, `*__get_x`; `*` alone every tool); on a list that GRANTS — `allowedTools`, a skill's `allowed-tools`, a name set's `allow`, `autoAllow`, a session's `toolAllow`, an agent's `allowTools` — a `*` is read only in the tool segment after a literal `<ns>__<peer>__`, and any other `*` names no scope: refused at a constructor, read as nothing in a read list (`wildcard_all` for the lone `*`, `namespace_wildcard` for a peer glob, `grammar` otherwise), the entries beside it keeping their reading. The selector's allowed list keeps the lone `*` as "no allowlist" — one test, `namesEveryTool`, which the spec tightener, the definition's note (#1918) and the workflow agentType lane now ask too. In a rule (`createPermissionRulePolicy`) `X()` / `X(*)` are the bare `X` for a covering name too (`*(*)` is `*`, where it was `invalid.tool_name`; `a2a__peer(*)` is `a2a__peer`, where it was `unsupported.covering_paren`), as the lists read them; a covering name with a content stays refused, and an MCP name with any parenthesis — `mcp__srv(*)` included — stays refused in a rule (`unsupported.mcp_paren`, as in 7.55.0) while a list reads it as `mcp__srv`.
869
+ - **Behavior narrowing (open to veto) — padded rule names.** (1) **What changed:** `" Bash(rm:*)"`, `"Bash\t(rm:*)"`, `"Bash (rm:*)"` are refused by the store, a consent edit and rule sync, where they were read as `Bash(rm:*)` (an org snapshot row is read as unreadable — its own hard-breaking bullet below); the refusal names the spelling the text is a blank away from (`RuleReject.respelling`, "spelled as a rule it is \"Bash(rm:*)\""). (2) **Who is hurt:** a host that stored or submitted such a spelling — a person's typed edit with a leading blank included; the reference's editor trims what a person typed before it validates, so a surface that hands the bytes on unchanged meets the refusal. (3) **How to migrate:** spell the rule as written (`Bash(rm:*)`); a surface trims what a person typed before it submits the edit.
870
+ - **Behavior narrowing (open to veto) — the approval policy's lists.** (1) **What changed:** `createApprovalPolicy` throws `config.invalid_tool_name_set` for an entry it used to keep as written and never match (a content form, a padded name, a peer glob on an allowlist), and reads `Read(*)` as `Read` — so `requireApproval: ["Read(*)"]` now asks, where the tool ran unasked. (2) **Who is hurt:** a host that wrote such an entry. (3) **How to migrate:** name the tool (`Read`); route a content narrowing through `createPermissionRulePolicy`.
871
+ - **Behavior narrowing (open to veto) — an off-set behavior word.** (1) **What changed:** a custom durable backend that serves a row with a behavior outside the closed set gets every call asked of a person, where the row was skipped. (2) **Who is hurt:** a backend that stores a word of its own. (3) **How to migrate:** store `deny` / `ask` / `allow` only (the file backend already refused the bucket).
872
+ - **Behavior narrowing (open to veto) — a `*` on a restricting list.** (1) **What changed:** `Re*d`, `*ead`, `mcp__git*` and the lone `*` reach every tool their glob matches on every restricting list; the lone `*` was refused on a name-set deny and in a rule (7.52.0, #1889 / car-1886 E — "it names no tool"), read as nothing on an entry deny list and refused on a run's denied list. The refusal's reason — a deny that reads protective and denies nothing — no longer holds: the glob denies every tool, so a run whose denied list holds `*` — refused at prepare before — now starts with every tool hidden. A session row stored before this grammar keeps its writer's meaning (the stored-row bullet below): its `toolDeny: ["*"]` still names no tool, and the row is announced. A delegated leg whose own denied list holds `*` is refused before it starts (`protocol_denied`: it denies the tools it relies on). (2) **Who is hurt:** a host that wrote `*` or a partial glob on a deny list expecting nothing. (3) **How to migrate:** drop the entry, or name the tools.
873
+ - **Widening (open to veto) — a `*` on an allow-entry list (#1807, stated [11266]).** (1) **What changed:** an allow-entry list holding `*` grants its other entries (`["Read", "*"]` grants `Read`), where the whole list was voided; the `*` itself grants nothing and is announced. A skill's `allowed-tools` reads it the same way — wider than the reference's skill reader, which collapses such a list to `["*"]` and so grants nothing (read from its frontmatter parser; the scope of the mangled name is inferred). (2) **Who is hurt:** a host that relied on `*` to void a list. (3) **How to migrate:** remove the entries the list should not grant. A session row stamped in grammars 1–4 keeps its voided list (below).
874
+ - **Widening (open to veto) — the workflow agentType lane (#1918).** (1) **What changed:** a workflow step's `agentType` whose definition's `allowTools` holds the selector's wildcard (`["*"]`, `["Read", "*"]`, `["*(*)"]`) sets no allowlist on the step's policy, as the Agent lane's selector has always read it — the workflow lane read `*` as a name nothing carries and admitted only the named tools. (2) **Who is hurt:** a workflow whose agent definition named tools beside `*` expecting the workflow lane to bound them. (3) **How to migrate:** drop the `*` to bound the step to the named tools (on both lanes).
875
+ - **Behavior narrowing (open to veto) — allow-side constructors and mounts.** (1) **What changed:** a name-set `allow`, an `autoAllow` and a `requireApproval` under `denyByDefault` refuse a lone `*` and a glob outside a protocol name's tool segment (`config.invalid_tool_name_set`), where the entry was kept and admitted nothing; a run's or an agent's `allowTools` that holds a glob outside a protocol name's tool segment (`["Read", "R*"]`) refuses the task (`config.tool_face_control`), where the entry was kept beside `Read` and granted nothing; a caller tool whose name or alias holds a `*` is refused at mount (`config.tool_name_invalid`), as the reference's mount spelling holds none; a skill list's unquoted `*<name>` — the key's own value, a flow item or a `- ` entry — is a YAML alias the reader does not resolve and refuses the skill (`no_frontmatter`; YAML would resolve it to an anchored node, so reading it as the glob `*name` could lose a deny entry), while a `*` that names no anchor (`allowed-tools: *`, `*, Read`) is the wildcard text as before. (2) **Who is hurt:** a host that wrote such an entry or mounted such a name. (3) **How to migrate:** name the tools; rename the tool; quote a glob entry (`"*Edit"`).
876
+ - **Hard-breaking (open to veto) — two root exports renamed.** (1) **What changed:** `isNamespacedCoveringRuleName` → `isCoveringRuleName`, `namespacedRuleNameCovers` → `ruleNameCovers`: the predicates now answer for a glob over the whole name too, which "namespaced" no longer names. (2) **Who is hurt:** an out-of-engine name-keyed lane that self-checks with them (server `src/approval.ts`). (3) **How to migrate:** import the new names; pass the list's behavior to `isCoveringRuleName` (a deny / ask list reads a glob anywhere).
877
+ - **Hard-breaking (open to veto) — `isCoveringRuleName`'s `behavior` is required.** (1) **What changed:** `isCoveringRuleName(name, behavior)` takes the list's behavior as a required argument, and a value that is no behavior word throws a `TypeError` — it used to default to the granting reading, so a deny-list caller that left it out read `Re*d` / `mcp__git*` as exact names (a deny that refuses nothing), and `names.filter(isCoveringRuleName)` passed the array index there. (2) **Who is hurt:** a caller that omits it, or hands the predicate to `filter` / `map` directly (a type error at build, a `TypeError` at run time) — server's `src/approval.ts` did both when this was written and now passes its table's behavior (`NAME_TABLE_BEHAVIOR`, `:321` in its checkout of 10-23), so only the rename reaches it. (3) **How to migrate:** pass the behavior of the list the names come from: `names.filter((n) => isCoveringRuleName(n, "deny"))` (`"ask"` for an approval list, `"allow"` for a granting one).
878
+ - **Hard-breaking (open to veto) — an org snapshot row that is no rule spelling only by a blank refuses every call.** (1) **What changed:** an org row like `" Bash(rm:*)"` or `"Bash (rm:*)"` — read as `Bash(rm:*)` before, refusing `rm` only — is no rule spelling under the one spelling reader, so it is a row the layer cannot read and stands at its strictest reading: a deny refuses every call of every tool, its verdict saying `row 1 of the organization snapshot, " Bash(rm:*)", is not a rule spelling (invalid.grammar); republish it as "Bash(rm:*)"` and the degraded line naming the same spelling. Why: one reader of a rule's spelling, and a deny that cannot be read is fail-closed — reading the row trimmed at the reach would be a second reading of the same text. (2) **Who is hurt:** an organization whose published snapshot carries such a row: after the upgrade every agent under it is refused every call. (3) **How to migrate:** a deploy prerequisite for whoever serves an organization snapshot — republish it with canonical spellings before upgrading the engine (`parseRuleText` names the canonical spelling of each such row in `reject.respelling`). sema-server (its checkout at `3ff6dec7`, read not run, `src/boot/stage-04-stores.ts`) wires only the durable partition and no org snapshot source, so no first-party deployment meets this today.
879
+ - **Hard-breaking (open to veto) — `CompiledAllowEntry` kind `namespace` → `covering`.** (1) **What changed:** the entry reader's covering row is `{ kind: "covering", pattern }` (a protocol peer, a tool-segment glob, and on a deny list any glob). (2) **Who is hurt:** a reader switching on `"namespace"` (none found in server / cli / client-core / sdk). (3) **How to migrate:** read `"covering"`.
880
+ - **Hard-breaking (open to veto) — session rule grammar 4 → 5.** (1) **What changed:** `SESSION_RULES_GRAMMAR` is 5; a row stamped 5 is refused whole by an engine of grammar 4, which would read a `*` deny as nothing. A row stamped 0–4 keeps the meaning its grammar gave its entries (the stored-row bullet below). (2) **Who is hurt:** a mixed fleet whose older readers meet rows a newer writer stamped; a store twin that stamps its own number. (3) **How to migrate:** upgrade readers first; a store twin stamps `SESSION_RULES_GRAMMAR` as before.
881
+ - **Now — a session row an earlier grammar wrote keeps its writer's meaning (ruled C-R722, note 2).** One rule over the documented grammar revisions, read in one place (`earlierGrammarReads`, one table): an entry keeps the meaning its writing grammar gave it, and an entry that grammar could not read (no rule, or no rule form) takes this engine's reading; an entry this grammar reads as no rule reads as no rule (`earlier_grammar`, as before); a row stamped this engine's grammar consults nothing. So a row stamped 0–4 keeps a `toolDeny` `*` and a `*` in a protocol peer (`mcp__git*`) as entries naming no tool and `Re*d` as the exact name `Re*d` (in `toolAllow` too), and an allow list grammars 1–4 voided stays voided — what 7.55.0 read; a row stamped 0–3 keeps reading `Agent(<type>)` and the Bash pattern form as 7.55.0 did (its grammar could not read them); the readings the releases since gave a grammar-0 deny rule form and a grammar-0–2 `mcp__*` deny stand. `compileSessionRuleBucket` refuses a key that is no rule-entry bucket (`SessionPolicyError` `invalid_shape`) rather than read it as the allow bucket.
882
+ - **Widening (open to veto) — a stored `toolDeny` name starting with `*`.** (1) **What changed:** a row stamped 0–4 whose `toolDeny` holds `*ead` — no rule in grammar 4, so those rules could not be read and every call was refused — reads it as the pattern now (it refuses the tools it matches), announced. (2) **Who is hurt:** an operator who relied on such a row refusing every call. (3) **How to migrate:** write the rules afresh, naming the tools to refuse.
883
+ - **Behavior change (open to veto) — a stored row whose entries two grammars read differently is announced.** (1) **What changed:** `config.session_rules_earlier_grammar` is also sent, once per bucket, for a row stamped below this grammar whose entries its writer's grammar and this one read differently — a grammar-0 `toolDeny` rule form, a grammar-0–2 `mcp__*` deny, a grammar-0–3 `Agent(<type>)` or Bash pattern, a grammar-0–4 `*` in a name — naming each entry, both meanings and which one the rules enforce; the reading itself is unchanged. `detail.entries[].reader` gains a third word, `grammar_revised`. (2) **Who is hurt:** a consumer that switches on `reader` with two words. (3) **How to migrate:** render the message (it carries the facts), or add the word.
884
+ - **The published safety-merge corpus moves in lockstep.** `SAFETY_MERGE_CONFORMANCE_CORPUS`: `inert-deny-entry-removed-safe` no longer holds `mcp__git*` (it is a real deny now), and a new vector `glob-deny-removed-loosened` pins that removing `mcp__git*` / `Re*d` from a deny list loosens; a center merger that runs the corpus reads the new answers.
885
+ - **Unchanged.** The closed set `ALLOW_ENTRY_INERT_REASONS` (7 words; `wildcard_all` is the allow-side lone `*`), every rule's content grammar, the projection replay's matcher (it reads the frozen names through the same predicate), a run selector's `*` sentinel.
886
+ - **Consumers.** *server*: `src/approval.ts` imports the renamed predicates; the SQL session-rule twin stamps grammar 5 (upgrade readers first); a host-written policy projection holding a non-bare name is still refused at resume, and a list it builds with `createAllowDenyPolicy` meets the allow-side refusals; the `skill.disallowedTools entry "*" reads as nothing (wildcard_all)` request refusal no longer arises from this engine (a deny `*` is a row); M17 is unaffected (no closed-set change). *cli*: the rule-ladder render and a typed rule edit (trim before submit); the allow notice's sentence is reworded; `config.session_rules_earlier_grammar` now also arrives for a stored row whose entries two grammars read differently — its message is the sentence to render, and `entries[].reader` may be `grammar_revised`. *server*: a session-rule SQL twin serves the row's stamp as before — the engine reads a stamped row's meaning, so a twin that restamps rows on read would erase it. *client-core*: `voids_allow_list` arm retires at core ≥ 7.56.0 ([11266]); `wildcard_all` unchanged. *sdk*: K-95 needs no major (closed set unchanged). *settings-schema*: `SkillSpec.allowedTools` / `disallowedTools` carry entries; the lexicon is this engine's (C-R471). *test*: the black-box cells for `*` on both list kinds.
887
+ - **Pins.** `test/backlog1879-rule-name-one-reader.test.ts` (N the name head at the store, the edit and the approval lists, the real lane; B the off-set behavior word, effective() and the real lane, default and bypass; G one `*` reading over 11 patterns × 8 names × 7 restricting consumers, the granting side, the real lane, a covering name's `X()` / `X(*)`, #1918), all 13 red on the base tree; F the stored row's meaning per grammar (16 cells: kept, moved, voided, no rule, this grammar's own), the announcement's two meanings, the real lane (a grammar-4 `toolDeny: ["*"]` refuses nothing and is announced once, the same row in grammar 5 refuses unannounced, a grammar-2 Bash pattern deny refuses its own calls only), a key that is no entry bucket refused — seven mutants each red; the re-read pins of the earlier cars (`backlog1303*`, `1327*`, `1355b`, `1358`, `1372`, `1377`, `1385`, `1474`, `1484`, `1517-a`, `1623`, `1736`, `1790`, `1841`, `1845`, `1866*`, `1875`, `1876`, `1878`, `1886`, `1912`, `2150`, `7480`, `mcp-capability-and-rule-coverage`, `per-task-agents`, `permission-rule-consent`, `policy-name-audit`, `prepare-listings-transcript`).
888
+
889
+ ### A rewrite of a call's arguments goes back to every judge that judged earlier bytes, and a person's edit never runs in another form (a caller's tool named `AskUserQuestion` included — the #2300 resume-path section below): `combinePolicies` asks its earlier layers once more about a later layer's rewrite, the PreToolUse screening judges a policy's rewrite once, and a hook's or a policy's rewrite of an approved edit is put back to the person as it stands; a person's rules are read once per judgment of a call's bytes (#1022, #1312, #2224, #2225; security; six safety narrowings and one behavior change, all open to veto; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `AskRequest.editRewritten`, +1 optional ask-arm `PermissionResult.editRewritten`, +1 optional `CheckpointError.detail.editRewritten`) (@server @cli @client-core)
890
+
891
+ - **Before (v7.55.0, measured through the real Runner).** `combinePolicies(guard, rewriter)` where `guard` refuses `/protected/*` and `rewriter` moves `/safe/x` to `/protected/x`: the call ran at `/protected/x` — the fold cascaded a rewrite only into the layers after it, so the guard never saw the bytes that ran (the engine's own fold re-ran the layers before the last rewriter only inside its caller-slot region, through a separate wrapper and an emitter index; a deployment's own composed policy got nothing). A PreToolUse hook that refuses `/protected/*` judged the model's bytes and a policy then rewrote onto `/protected/x`: it ran. In the approved-edit round a person edited `/safe/x` to `/human` and the hook (or the policy) rewrote `/human` to `/hook` (`/policy`): `/hook` ran with no second card on an egress-marked tool or a declared `requiresUserInteraction` tool, because only a passthrough-seat tool met the edit round's `unstated` question; on the durable lanes the same rewrite was refused pre-claim (the policy) or dropped so the person's bytes ran (the resumed leg's hook re-screen). A deny row a person wrote into their persisted rules while deciding refused their edit through the session-rule store but not through the person's store (that store was read once per call), so one shape had two answers.
892
+ - **Now, one rule (`src/core/rewrite-rejudge.ts`).** A verdict covers the bytes it was given. (1) Rewritten bytes go back, once, to every judge that judged earlier bytes of the attempt: `combinePolicies` asks each layer before the latest rewriter about the bytes that run, and that answer is its verdict (a deny refuses; a different form handed back refuses — "a rewrite is judged once, never chased"); the latest rewriter is not asked again. The PreToolUse screening, which judged the bytes before the policy, judges the policy's rewrite once in the gate's fold and in the approved-edit round — its verdict on those bytes replaces its first. The documented order "a policy's rewrite is applied last over a hook's" is the last-rewriter case of the same rule and stands. (2) A person's bytes are the ceiling: a judge looking at a person's edit may refuse it, never replace it. Where the person can still be asked (the gate's edit round, a delegated child's ancestor re-check), the judge's form is put back to the same approver as the round's question, carrying `AskRequest.editRewritten.acceptableForm` (the bytes the card presents) — approving it as it stands runs exactly those bytes, a further edit is a new round (three at most), no mode releases it and no classifier answers it. Where nobody can be asked, the refusal names the form: pre-claim `resume.constraint_rejected` with `detail.editRewritten` (the row stays pending; a decide carrying that form runs it), post-claim the result's `details.editRewritten`, and the gate's refusal when no approver took the outlet. "The same bytes" is one reading for a rewrite: both values as the tool reads them, canonically serialized — a rewrite to the same bytes is no rewrite (the park's storage-fidelity check keeps its own plain-value comparison, `samePlainValue`, for a different question — whether a checkpoint backend's codec moved the filed value).
893
+ - **Rules counted.** Retired: the engine fold's final-form re-check wrapper and its per-call emitter index (`prepare-policy-chain.ts`: 1,538 → 1,459 lines, −79; the ratchet ceiling banked 1,588 → 1,460), the trailing copies of the integrity guards (their only reason was rewrite escape), the edit round's passthrough-only `unstated` re-ask (the outlet covers every tool), the edit round's "rewritten since a human looked" flag and its post-loop write-protection re-judge (the write-protection judge now judges a judge's form inside the round, before its card — rule 1 — and refuses a table hit there), the durable belts' private equality (`deepJsonEqual`) for a rewrite, and #1294's "the person's bytes beat the re-screen's" order. Added: the one rule's reader and outlet in one module. One residue, named: a same-instance arbitration adapter judges another layer's verdict rather than bytes, so when a layer between the two rewrote, it arbitrates the caller slot's verdict on the bytes that run after the fold's second look (no second evaluation of the shared instance) — the narrowing layers keep their trailing pass for their own reason (they read their rules at each judgment, and a layer before them may wait on a person).
894
+ - **Safety narrowing, the composed fold (open to veto).** (1) **What changed:** a layer of `combinePolicies` that judged bytes a later layer rewrote is asked again about the rewritten bytes; it refuses them or asks about them, and a layer that rewrites again on that look refuses the call; an `updatedInput` that reads as the bytes it was handed is no rewrite and asks nobody again; and a layer's rewrite that is a person's edit (an ancestor approver's, licensed by that person's yes) is not replaced by a later layer — that layer's rewrite refuses the call. (2) **Who is hurt:** a composition whose earlier layer refuses (or asks about) what a later layer rewrites into — that call was running unjudged; two layers that rewrite back and forth (refused now, before the claim on a durable decide); a stateful or prompting earlier layer is consulted twice on a rewritten call (`check` must already be idempotent). (3) **How to migrate:** order a sanitizer before the layers that must judge its output, or make the earlier layer accept the sanitized form.
895
+ - **Behavior change, verdicts follow the bytes that run (open to veto).** An approval factory (`createApprovalPolicy`) composed before a rewriting layer asks its person again about the rewritten bytes, and that yes covers them — no `unstated` card for them (before: one); its person saying no there refuses. A real-approval question an earlier layer raised about bytes a later layer rewrote away does not stand: that layer's question (or none) about the bytes that run does. A same-instance ancestor no longer arbitrates (and so no longer prompts about) the caller slot's verdict on bytes that do not run; a shared policy that asked about the raw arguments and allows the rewritten ones puts no card (before: two). A PreToolUse hook's `allow` speaks for the bytes it was given: after a policy rewrite, its second look's `allow` is the allow layer's hook member for the bytes that run (it clears what that member may clear — before, any policy `updatedInput`, an identical one included, silenced the member; a hook that allows only the model's bytes still leaves the question standing).
896
+ - **Safety narrowing, the screening's second look (open to veto).** (1) **What changed:** when the policy rewrites the call, the PreToolUse screening is raised once more over the rewritten bytes — a deny refuses, a different form refuses, an ask is the hook's question about those bytes, and its first verdict (an allow, an ask) no longer stands for them — its `additionalContext` is not replaced: when the second look answers (an allow, an ask, no opinion) both looks' context reaches the result; a second look that refuses ends the call with no context of its own. (2) **Who is hurt:** a hook that refuses or rewrites the policy's output; a hook counting dispatches sees two on a rewritten call (one on any other); a hook that answers only the first dispatch of a `tool_use_id` is silent on the second look, so its own mandatory question is dropped on a rewritten call (it is dispatched before the person has decided, and again on the rewrite); and a hook whose ask was about the model's bytes only meets no question about the rewritten ones — for that shape the second look widens, not narrows (the behavior-change bullet above says the same of an allow). (3) **How to migrate:** nothing for a hook that accepts the policy's forms; one that rewrites should accept its own output unchanged; a hook that must ask about whatever runs should answer on every dispatch, not once per `tool_use_id`.
897
+ - **Safety narrowing, the live edit round (open to veto).** (1) **What changed:** a hook's or the policy's rewrite of an approved edit no longer runs: the same approver is asked again with the rewritten form (`editRewritten`), for every tool — before, only a passthrough-seat tool met a second card, and an egress-marked or `requiresUserInteraction` tool ran the judge's bytes unseen. (2) **Who is hurt:** a deployment whose hook or policy normalizes every edit to a different spelling: each such edit takes one more card; an approver that answers every card `true` keeps working. (3) **How to migrate:** render `editRewritten` as "approve this form", or have the judge accept the person's spelling as it is.
898
+ - **Safety narrowing, the write protection in the edit round (open to veto).** (1) **What changed:** when the re-check of an approved edit raises an ask that carries a rewrite of the person's edit (the policy's, or a hook's), the write-protection judge judges that rewritten form before the round puts it to the person on a card (an approval factory composed in the policy asks its own approver while the policy re-judges, which comes earlier): a table hit (a path the protection table names, such as `.git/…`), or a target the judge cannot place, refuses the call fail-closed (`deniedBy: "write_protection"`) with no card for a write no approval covers; before, the form went to the person as a second card, and a plain yes cleared the protection and ran the write onto the protected path (a rewrite carried on an allow, with no card, was already refused there). The person's own edit stays outside this check. (2) **Who is hurt:** a deployment whose policy re-check asks about an approved edit it has rewritten onto a protected path — the call is refused where it took a card an approver could say yes to. (3) **How to migrate:** re-submit the edited action directly (the ordinary write gate judges it), or have the judge leave the person's spelling alone.
899
+ - **Safety narrowing, the resumed leg (open to veto).** (1) **What changed:** on a parked approval with a person's edit, a PreToolUse re-screen that rewrites the edit refuses the call (the approval is spent, the form on `details.editRewritten`); before, the hook's rewrite was dropped and the person's bytes ran. (2) **Who is hurt:** a deployment whose hook rewrites edited approvals on resume; an unedited approval's rewrite still runs (the reference's form). (3) **How to migrate:** re-issue the call; to keep the person in the loop, decide with the hook's form.
900
+ - **Safety narrowing, the person's rules (open to veto).** (1) **What changed:** the person's persisted rows are read once per judgment of a call's bytes, so an approved edit (another judgment) takes a new read: a deny row written while the person decided refuses their edit, as a session-rule deny already did. (2) **Who is hurt:** nobody intended; a store pays one more read per approved edit. (3) **How to migrate:** nothing.
901
+ - **Unchanged (the reference's form, `KL-2225-a`).** An approval without an edit is not judged again: a deny written while the person decided reaches the next call, not the one they approved — the same in both stores. The reference takes the permission decision's input after a schema check alone (cli296.js:249217 "tn=no.decision;Rt=no.input", cli296.js:249218 "Rt=W_t(e.name,tn.updatedInput,Rt)") and judges a hook's `updatedInput` with its rules before the prompt (cli296.js:249209 "let he=await yk(n,V,{...s,toolUseId:b},{hookUpdatedInput:e.updatedInput})"); its hooks never see a person's edit — re-screening it is ours, and so is the ceiling. The ceiling covers the bytes the engine records as a person's edit (its approval record); the answer of a host's own approval screen is not in that record, so a later layer can still rewrite it unasked (`KL-1022-a`).
902
+ - **Cost.** A call nobody rewrote pays nothing. On a rewritten call: the layers before the latest rewriter are evaluated twice; one extra PreToolUse dispatch when the policy rewrote; one more session-rule read when the caller slot rewrote; one more person-store read per approved edit.
903
+ - **Consumers.** *cli* / *client-core*: an approval request may carry `editRewritten: { acceptableForm }` — the card is the round's question about the form it presents; offer "approve this form". *server*: the durable refusal keeps its code `resume.constraint_rejected`; `detail.editRewritten.acceptableForm` is new and present only when the resume-edit policy rewrote the edit — a decide carrying that form as `updatedInput` runs it; a resumed leg's hook rewrite of an edit now refuses (post-claim, `details.editRewritten`).
904
+ - **Pins.** `test/backlog1022-rewrite-recheck.test.ts` (R1–R6 and R17 rule 1: the guard meets the rewrite, never chased, same bytes are no rewrite and keep a required question, the cost counts, the screening's second look and its dispatch count; R7–R9 and R16 the live outlet card, the policy's form judged by the screening first, the refusal's details, no mode release; R18–R19 an ancestor's approver: the outlet under `bypassPermissions`, a later ancestor layer does not replace the edit; R10–R12 the durable lanes and the reference's control; R13–R15 a deny written while the person decides, both stores, and the unedited approval). Re-pointed: `test/backlog1294-approved-call-hooks.test.ts` ③d (a re-screen rewrite of an edit refuses; the same bytes run), `test/backlog1065-mcp-default-ask.test.ts` ⑰a / ⑭c (the outlet card: `editRewritten`, the round's class), `test/inherited-gate-rewrite-ordering.test.ts` (the intermediate-rewrite cell, both readings), `test/backlog1020-approve-accepts-ask-outcome.test.ts` P8b and `test/coarse-command-name-policy.test.ts` (the crossing shapes are refused), `test/backlog1359-allow-layer-only-home.test.ts` D7 / D7c / G5 and `test/backlog595-settlement-attestation.test.ts` E3b / E3c (the second answer is the record), `test/hardening-permission-core.test.ts` I1 (the real-approval demand follows the bytes that run), `test/hooks.test.ts` / `test/defectscan-probe-final-args.test.ts` / `test/security-fixes.test.ts` R-3 (a judge that accepts the later form lets it stand; one that insists on its own refuses), `test/backlog1226-decision-receipt.test.ts` ⑬.2 (the copy discipline read off the outlet's presented bytes), `test/backlog1974b-keyless-and-unreadable-resume.test.ts` ⑥ (a back-and-forth composition is refused before the claim).
905
+
906
+ ### A checkpoint row's generation has one reading, `checkpointRevOf`, exported from the package root beside the optimistic-concurrency judge `checkpointOccMatches` (#2391; additive exports; behavior change: none; hard-breaking: none; closed sets: none; exports: +2 (2 runtime: `checkpointRevOf`, `checkpointOccMatches`; 0 types); members: none; signatures: none) (@server)
907
+
908
+ - **Before.** "An absent `rev` reads as 0" was written inline eight times in core (the OCC judge, the in-memory loss answer, the resolve and reopen bumps of both bundled stores, and the resume's expectation, twice) and again in server's approval-gate reads. `checkpointOccMatches` was not a root export, so a host that compares two reads of one row (did a resolve or reopen land between them?) wrote the generation rule itself.
909
+ - **Now.** `checkpointRevOf(cp)` is the one place that says it: `cp.rev ?? 0`. The OCC judge, the loss answer, both stores' bumps and the resume's expectation read the generation through it; `checkpointOccMatches(cp, expect?)` is unchanged in answer (no expectation opts out; otherwise the row's generation must equal `expect.rev`) and is exported with it. No behavior change on any row.
910
+ - **Who is hurt.** Nobody: two added runtime exports, no existing name or shape touched.
911
+ - **How to migrate.** *server*: delete `revisionOf` and read the generation through `checkpointRevOf`, and ask the OCC question with `checkpointOccMatches(again, { rev: checkpointRevOf(row) })`. The server lines that collapse (server `int-7135` at `3ddb462e`, line numbers move): `approval-gate-currency.ts:78-80` (`revisionOf` itself) and `:104` (`revisionOf(again) === revisionOf(row)`), and `approval-sweeps.ts:152` (`cp.rev ?? 0`) and `:202` (`(cp.rev ?? 0) !== observedRev`, which is `!checkpointOccMatches(cp, { rev: observedRev })`) — and a fifth spelling of the rule, the SQL checkpoint store's `put` bind `cp.rev ?? 0` (`src/plugins/checkpoint-store-sql.ts:1270`), which reads the generation through `checkpointRevOf` as well (its SQL half, `COALESCE(rev, 0)`, stays). Server deletes its `revisionOf` copy on arrival.
912
+ - **Pins.** `test/checkpoint-rev-reader.test.ts` (the root exports are the internal functions; the reader on an absent, zero and present `rev`; a host's two-read comparison; S1–S8 one cell per core site, each on a row minted without `rev`, which go red together when the reader's default is changed); `test/export-surface.snapshot.json` (+2, both `advanced`).
913
+ - **Unchanged.** `checkpointRowMatches` (the row half of the CAS predicate) is not exported; the SQL twin keeps its native `COALESCE(rev, 0)`.
914
+
915
+ ### A model's capability declarations are read in three states through one reading: `Model.reasoning` and `Model.input` are optional, what an undeclared capability means is said once, and the person is told — once per conversation, per model — when the images they supplied are not sent to the model (#1830 slice S1, #1926; ruled C-R762 and its note 1; user-visible; behavior change, open to veto; differs from the reference implementation, which never declares a model text-only and tells no one, open to veto; hard-breaking: five, open to veto; closed sets: `ENGINE_NOTICE_CODES` 101 → 102, +`model.capability_downgraded` (audience `user`, minted by the engine); exports: none at the package root (the reading lives in `src/brain/model-capability.ts`; its root export ships with slice S2); members: `Model.reasoning` and `Model.input` become optional) (@cli @server @web-admin @client-core @sdk @settings-schema @test)
916
+
917
+ - **The rule.** A model declares image input (`input` lists `"image"`, or a list without it) and reasoning (`reasoning: true | false`), or does not declare them (the key is absent). One predicate reads a declaration — `capabilityOf(model, cap)` answers `declared_on`, `declared_off` or `unknown` — and one table says what an undeclared capability means (`UNKNOWN_CAPABILITY_READS`: image input is used, reasoning is not), read by `capabilityHeld`. Every reader of the two bits reads through them: the image row of the three message assemblers (`brain/media-degrade.ts`), the thinking entry gate and the off / effort-tier faces (`brain/reasoning.ts`), the explicit-off arms and the `reasoning_content` field of the three lanes, the compaction summary's thinking forward and output floor, the Read tool's PDF profile and `selectModel`. Before this, an absent `input` meant "sees images" in the assemblers and the PDF profile and "does not" in `selectModel`, and the PDF profile read a `"document"` member the type does not allow.
918
+ - **What a request carries (unchanged except as listed below).** A declared-off capability is never asked for: images become the one-line placeholder the model already saw, no thinking keys go out. A declared-on capability is sent as before. An undeclared image capability is sent as before; an undeclared reasoning capability carries no thinking keys, as before. The rules that already shape a request stay (the anthropic budget lane's cap-wins skip, an effort tier the lane cannot spell, media stripped from an error tool result), and earlier reasoning in the transcript is replayed by each lane's own replay rule, not by this reading.
919
+ - **The person is told (user-visible, open to veto).** When a session's request replaces images with the placeholder because the model's entry declares no image input, the person gets `model.capability_downgraded` once per conversation per model (the conversation is the session, and what remembers it is the session's announced record: a child leg that starts from a record already holding the digest — a fork of a session whose notice has flushed — says nothing more, and one that starts without it, a child spawned before its parent's record flushed, can say it again for the same images): `detail: { model, capability: "image", status: "declared_off", sessionId, runId }`, and a message naming the model, why, and the two ways out (declare `input: ["text","image"]` if the model reads images, or switch models). The operator trace frame `vision.placeholder` is written from the same report, per request, as before. A reasoning capability a model declares off is never reported here — its face is the reasoning resolution's drop arm (`TaskResult.effectiveReasoning.dropped`). The account rides a leg-wide sink, so the end-of-task compaction reports to its own leg and a child leg reports to its own session, while `Runner.sideQuery` (sessionless; a tool's utility query) is shielded from it — an image a utility model does not take is that query's fact, not the conversation's; the once-per-conversation record is best effort (a first report after the leg's record is flushed, or a lost append, is said again by the next run).
920
+ - **Disclosed gap until slice S2.** An undeclared capability is "tried" only for images; an undeclared reasoning capability is not tried yet. And a provider that refuses a capability it was sent (an undeclared image on a text-only endpoint) fails the request loudly, as today: the retry without the capability, the per-endpoint memory of the refusal and the `downgraded` status arrive together with the reasoning flip in S2.
921
+ - **Hard-breaking (open to veto) — the two members are optional.** What changed: `Model.reasoning?: boolean` and `Model.input?: ("text" | "image")[]`; absent means "not declared". Who is hurt: TypeScript code that reads `model.reasoning` as a `boolean` or calls `model.input.includes(…)` without a guard stops compiling; @server's `mintCapabilityKeys` cast (`src/model-select.ts`) is no longer needed. How to migrate: handle `undefined` as "not declared"; writers change nothing.
922
+ - **Hard-breaking (open to veto) — a declaration of the wrong shape is refused.** What changed: a `reasoning` that is not a boolean, or an `input` that is not a list of strings, throws a `TypeError` naming the model and the key, instead of being read by truthiness (`reasoning: 1` was on; `input: "image"` was on). The task's own model is judged at the task's model door, beside its `extraBody`, before the session is acquired or an environment is minted, so the run fails with those words and holds nothing; any other model (a degrade target, the compaction model, a role's model) is judged where it is first read — a request's build fails with the words; a model a `classifier` role names for the auto-mode classifier is first read inside its lanes, whose fail-closed answers (`role_unresolvable` / `unavailable`) carry no words; a model the engine defaults the classifier seat to when no role names one (the tier below the main model, the `summarize` → `default` role chain) is first read where the default is announced, at arming, so a wrong shape there fails the run's preparation with the words, before any request. Who is hurt: untyped JavaScript callers that pass such values; and a catalog holding one entry of the wrong shape — a `select` reads, for each entry, the declarations its needs ask for, up to the first need the entry does not meet (`matches` in `select-model.ts`), so an entry whose `input` is the wrong shape makes every selection with a `vision` need throw its `TypeError` (and one whose `reasoning` is the wrong shape every selection with a `reasoning` need that reaches it; an entry that fails an earlier need is never read for a later one), and a role whose `select.needs` names such a need fails to resolve. How to migrate: pass `true` / `false` or omit; pass a list.
923
+ - **Hard-breaking (open to veto) — the `"document"` member is not read.** What changed: the Read tool's PDF profile grants native document blocks on the anthropic lane when image input is held, and nowhere else; an `input` list containing `"document"` grants nothing more. Who is hurt: only a caller of `pdfModelCapabilitiesOf` passing that member (the `Model` type never allowed it). How to migrate: none on the anthropic lane; elsewhere documents were never sent.
924
+ - **Hard-breaking (open to veto) — the notice catalog.** What changed: `ENGINE_NOTICE_CODES` gains `model.capability_downgraded` (audience `user`, minted by the engine; it always carries `sessionId`). Who is hurt: a consumer switching exhaustively over the catalog or holding a whitelist of user codes — no row of audience `user` was about media before this (`tool_result.media_refused` is the operator's), and this is the first. How to migrate: add the row; render the message by code.
925
+ - **Hard-breaking (open to veto) — selection reads the same table.** What changed: `selectModel` (and a `{ select: { needs: ["vision"] } }` role) matches an entry without `input` for a `vision` need, because an undeclared image capability is held. Who is hurt: a catalog that leaves `input` off a text-only entry and relied on selection skipping it. How to migrate: declare `input: ["text"]` on that entry.
926
+ - **Not changed here.** `contextWindow` stays a required number and is never read optimistically (the deployment's fallback is the server's). Reasoning replay across a model switch. The document placeholder telemetry is still traced under `vision.placeholder` (a candidate). The auto-mode classifier's sentence about `compat.thinkingFormat` (#1823) is slice S3.
927
+ - **Consumers.** @cli (L-1852): render `model.capability_downgraded` as one line by code — it is the engine's signal for an image the model did not get; the family table that wrote `MODEL_VISION=false` for `deepseek*` is the shell's own (L-1850). @server: S-1661 — drop the `mintCapabilityKeys` return cast; S-1624 — `images_omitted_no_vision_model` and `images_omitted_total` in `boot/resolve-spec.ts` retire (the engine mints the fact; there is no second detector); enter the code in the session-stream whitelist (dedupe unit: the session); S-1625 — the window fallback stays yours. @web-admin: the notice names the catalog row whose declaration withheld the images — a write-back hint for the row's vision bit. @client-core (CC-778): render by code; `caps.vision` `unknown` keeps "send and tell". @sdk: the two optional members pass through. @settings-schema: a catalog entry of the wrong shape now throws at the engine's model door and in `select` (above); the schema that types `Model.reasoning` / `Model.input` and a role's `select.needs` is the place such a value should be refused first.
928
+ - **Pins.** `test/backlog1830-model-capability-tristate.test.ts` (P1 the predicate's three states, the unknown table, six malformed shapes refused with model and key named; P2 the three real assemblers: image × on / off / unknown, thinking keys × on / off / unknown, explicit off × the three; P3 selection, the PDF profile, the effort-tier and off faces, the compaction summary; P4 a real Runner over the completions brain: one notice with its detail and way out, none more on the session's next run, one more for another model, none for undeclared / declared-on images, none for a reasoning drop, a malformed declaration refused before an environment is minted, a tool's side query to a text-only utility model told to no one; P5 the closed sets); `test/backlog1279-child-undrained-to-parent.test.ts` (catalog count 102).
929
+
930
+ ### One reasoning wire applier — `applyReasoningToRequest({ api, model, level, body })` writes the thinking keys a tier needs on each wire family, and the headers those keys need, from the one dialect table the three brains now build their requests from; a host that sends its own request to the same endpoints calls it instead of copying a dialect (#2163; additive, the engine's own requests are byte-identical except the two repairs named under "Two repairs" below; hard-breaking: none; closed sets: none; exports: +10 — `applyReasoningToRequest`, `mergeReasoningHeaders`, `mintEffortWireValue`, `lowestReasoningSeat`, `AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS` and the types `ApplyReasoningInput`, `AppliedReasoning`, `ReasoningBudgetKnobs`, `ReasoningRequestModel`, `ReasoningSeat`) (@server)
931
+
932
+ - **What changed.** The completions brain's `applyThinking`, the Messages brain's thinking / effort / beta arms and the Responses brain's `resolveWireEffort` were three private copies of "which keys does this model take for this tier". They are one function in `brain/reasoning-request.ts` now, and each brain calls it. The chat-completions dialects (`thinkingFormat`) are one table; the Messages family's budget form, adaptive form, `output_config.effort`, `context_management` and the three betas are one arm; the Responses family's `reasoning.effort` is one arm. Nothing about a request the engine sends changes, except the two repairs named under "Two repairs" below: 1 818 cells (wire family × model declaration × compat shape × requested tier × output-cap lane × header bag, including a `thinkingFormat` that is an array or an object and a cap an untyped config spelled as a numeric string), each driven through the real brain with a fetch stub, send the same body bytes and the same ordered headers before and after — 1 814 of them byte for byte, the other 4 (a user-declared `anthropic-beta` list on a model that asks for no thinking, see the first repair) differing only in the spelling of that header's NAME (`test/fixtures/reasoning-wire-matrix.golden.json`, captured on the tree before the change; those 4 rows re-captured).
933
+ - **What the exports answer.** `applyReasoningToRequest({ api, model, level, body, budget? })` → `{ body, headers, effort, wireFacts }`: a pure function — the input body is not mutated, `body` is a copy with the thinking keys written (Messages: `temperature` dropped while thinking is on, the cap raised when the budget form needs the room and the cap is not a hard bound), `headers` is the patch the keys need (`anthropic-beta`: the effort beta, the context-management beta, the interleaved-thinking beta — whichever the model entry declares; `{}` elsewhere), `effort` is the effort value the request carries, `wireFacts` the output-cap facts the Messages budget form weighed. `api` is the model entry's `api`; absence is meaningful — an undeclared or declared-off model, a `level` that is `undefined` or not a tier, a tier the wire cannot carry and a `thinkingFormat` that is not a recognised string write nothing. `mergeReasoningHeaders(headers, patch)` folds the patch into the request's own headers: the beta list is unioned with what the request already sends under that name, in any letter case and any number of spellings (existing entries first, as given, then each new entry not already there; entries are trimmed, and a spelling that holds no string is claimed and contributes no entry — a value that is not a string is dropped without a word, and a list left with no entry at all is still written, as an empty `anthropic-beta` header). The Messages brain folds its own header layers through the same function. `mintEffortWireValue(level, model, allowed)` is the clamp + `thinkingLevelMap` translation behind every effort value the engine writes (`declaredOffSpelling`, `thinkingOffSpelled` and `resolveEffort` were already exported). `lowestReasoningSeat(model)` is `off` where the wire spells off, else `low` — the tier an answer-only request takes; its output cap needs `AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS` more room on the `low` seat (`AUTO_MODE_CLASSIFIER_MAX_TOKENS` was already exported).
934
+ - **One rule, not two (C-R767; open to veto).** The classifier's reasoning seat (`classifierRungSeat`) now reads `lowestReasoningSeat` for its tier and adds only its own cap; it stays internal — the verdict is a model-capability fact, not a classifier fact, so the root carries the general name.
935
+ - **New refusal, external callers only.** `applyReasoningToRequest` on the Messages budget form throws a `TypeError` when the body has no `max_tokens` (`undefined` or `null`; the budget is sized from it); the brain always sets one, so no engine request meets this. A cap of any other kind that reads as a finite number — including a numeric string an untyped config produced — is read exactly as the brain always read it; one that does not is the refusal under "Two repairs".
936
+ - **Two repairs (both open to veto; the first moves 4 cells of the equivalence matrix in the spelling of a header name only, the second none).** **The beta list has one reading (widening).** What changed: the Messages brain read the `anthropic-beta` header through the plain header merge — one spelling, the last layer replacing the earlier ones whole — while `mergeReasoningHeaders` unions every spelling, so a list the model entry or the deployment declared was silently dropped when the call also sent one. The brain now rebuilds the list on every request through that same function — every spelling of the model entry's list, the deployment's (`headers` at construction) and the call's, in that order, each entry once and trimmed — and the thinking keys' own betas join it the same way; the list is always written under the lowercase name `anthropic-beta`. Who is hurt: a deployment that declared a list on more than one layer and counted on the later layer replacing the earlier — it now sends the union; and anything that matched the header NAME case-sensitively on a single-layer bag — a list declared as `Anthropic-Beta` or `ANTHROPIC-BETA` on the model entry or the call, on a request where no thinking key adds a beta, used to leave under that spelling and now leaves as `anthropic-beta` (HTTP header names are case-insensitive, and the value of a tidy list is the same bytes; an untidy value loses its blank and empty entries). How to migrate: nothing, if the lists were meant to add up and the name is matched case-insensitively; to send one layer's list alone, declare it on that layer only. **A budget that cannot be sized is refused, not sent as `null` (narrowing).** What changed: on the Messages budget form a `max_tokens` that does not read as a finite number (`NaN`, `Infinity`, a non-numeric string), or a share or fixed budget that makes the budget `NaN`, used to put `"max_tokens": null` / `"budget_tokens": null` on the wire (`JSON.stringify` writes a non-finite number as `null`); `applyReasoningToRequest` and the Messages brain now throw a `TypeError` naming `body.max_tokens`, `budget.share` and `budget.tokens` with the values as read, before any request is made. The same non-finite cap on a request that sizes no budget — thinking off, no tier, or a hard cap of `-Infinity` (which skips thinking) — is not read by this refusal and still reaches the wire as `"max_tokens": null`, as before. Who is hurt: only a caller or a configuration whose value was already unusable — its request carried a null cap or budget, and now fails at the build with its words; numeric strings from an untyped config (`"4096"`), a hard cap under the floor (thinking skipped) and an infinite share or budget the cap clamps are accepted as before. How to migrate: correct the value; a title generator or side query that passes a cap from its own configuration should check it is a finite number.
937
+ - **Not changed.** `reasoningRequestCarried`, `resolveReasoning`, `reasoningEffortLevelsOf` and `thinkingOffSpelled` read the same predicates as before; the reporting resolver and the wire still cannot drift because both read them. `Model.reasoning` stays read through `capabilityOf` / `capabilityHeld`: a wrong-shape declaration throws from the applier on every wire family.
938
+ - **Consumers.** @server (S-1904, the titler in `hookLlm`): delete the hand-copied dialect; build the body, call `applyReasoningToRequest({ api: model.api, model, level: lowestReasoningSeat(model), body })`, send `result.body`, and fold `result.headers` into the request headers with `mergeReasoningHeaders` — the Messages effort arm needs `anthropic-beta: effort-2025-11-24`, so merging the body alone is not enough, and assigning the patch over your headers would drop a beta the request already carries. Size the answer cap with the seat: on the `low` seat add `AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS` to it (a hard cap below the Messages budget form's floor skips thinking — `result.body.thinking` is absent then).
939
+ - **Pins.** `test/backlog2163-reasoning-applier.test.ts` (E1 the 1 818-cell equivalence against the golden; E2 the host's call writes the keys the brain's own request carries, for every cell; S1 the seat verdict and the classifier's seat reading it; A1 purity and a title generator's forms; A2 bad values; A3 the header union; A4 root identity and no dialect key left in the brains' code; F1 the beta list through the brain's three header layers and past a spelling that holds no string, the budget refusal on the host's call and on the brain's path, the role-defaulted line reading the seat and a defaulted seat read at arming); `test/backlog2163-seat-announce.test.ts` (the seat's verdict replaced by one that disagrees with the predicate: the seat and the role-defaulted line both follow it); `test/export-surface.snapshot.json` +10; two ratchets banked down for the comments and the compatibility words that left the brain bucket with the code (`comment-ratio-ratchet` `src/brain` 211 → 190, `legacy-residue-gate` `src/brain` 9 → 7), and the `thinkingBudget` exemption of `defectscan-truncation-invariant-surface` names the function's new file; `test/backlog1549-extra-body-shape.test.ts` reads the first argument of `applyExtraBody` by any name (the brains hand it the applier's returned body).
940
+
941
+ ### The model is told when a declared MCP server did not connect — on the leg's first request, by default, in its own frame `mcp_unavailable` that no longer waits on `toolsDelta` (#1551 slice 1; user-visible; behavior change, open to veto; differs from the reference implementation, whose frame needs tool search active, open to veto; hard-breaking: two, open to veto; closed sets: the `steering_injected` `source` union 19 → 20 (+`mcp_unavailable`), `AttachmentSource` 16 → 17; exports: none; members: +1 optional `TaskSpec.attachments.mcpUnavailable`) (@server @cli @client-core @dgx @test)
942
+
943
+ - **Before.** A declared server that failed to connect was told to the model only as the failed arm of the `tools_delta` frame: off unless `attachments.toolsDelta: true`, and carried only by a turn boundary — so a model that answered its first request in text (no tool turn, no boundary) never saw it, whatever the switch. The sentence also said the tools were unavailable "for this session", while the next run dials every declared server again.
944
+ - **Now.** One frame, `mcp_unavailable`: default ON (`attachments.mcpUnavailable: false` turns it off, for the request's engine-built child legs too; a value that is not a boolean is refused at the task's door — `config.invalid_argument`, naming the key and the kind it received, never the value — rather than read as either state), riding every leg's first request — a fresh leg's first user message beside the date / listing / output-style frames, a resumed leg's continuation beside its git / posture / output-style frames — once per session per (server, error) as before: the `announced_listing.mcpFailed` record advances on the receipt of the message that carried it, so a prompt a hook kept back, or a continuation never sent, records nothing and the next run tells it. No turn boundary carries it. Text: "…are unavailable for now:" + the entries + "Each is tried again when the next message arrives; whether it connects then depends on the error shown." + the reference's closing sentences. `tools_delta` keeps its added / readded / removed arms, and `Prepared.toolsDeltaRef` is minted only when something is deferred.
945
+ - **Hard-breaking (open to veto) — the notice's source.** What changed: the failed-server notice is a `steering_injected` event with `source: "mcp_unavailable"`, never `tools_delta`. Who is hurt: a reader or a test that finds the notice under `tools_delta`, or a closed switch over the source union. How to migrate: read `mcp_unavailable` (client-core's open label table falls to its default label until a row is added).
946
+ - **Hard-breaking (open to veto) — default on.** What changed: a host that never turned `toolsDelta` on now gets the frame when a server fails, on the first request. Who is hurt: a probe pinning "no frame without opt-in"; a host whose "all attachments off" switch lists only the earlier default-on keys. How to migrate: send `attachments.mcpUnavailable: false` once on the request — the switch rides the parent spec seat, so the request's engine-built child legs are switched off with it — and server's request intake (`src/spec-fields.ts`) has to pass that boolean through, and client-core's `SEMA_ATTACHMENTS=off` expansion has to learn the key, or a deployment's "off" does not cover this frame.
947
+ - **Behavior change (open to veto) — the attachments declaration rides the parent spec seat.** What changed: `ParentSpecSeatFields` / `ParentSpecSeat` carry `attachments` whole (an ordinary member of the seat's fill law, one more row of its table), so an engine-built child leg — the Agent tool's, the observer sidecar's, a workflow node's — is switched the way its parent request is; a child spec that authors `attachments` at all keeps exactly what it authored, never half to the parent. Who is hurt: a host that opted a lane in for the request (`todoReminder`, `changedFiles`, `toolsDelta`, `mcpInstructions`, `budgetUsd`, `totalTokensReminder`) and relied on its delegates not carrying it — they now carry the same frames — or that switched `agentListing`, `skillsListing`, `backgroundTasks` or `mcpUnavailable` off and relied on its delegates keeping them on. How to migrate: declare a lane on the request only if its delegates should carry it too; a workflow node or host-run child spec that authors its own `attachments` keeps it.
948
+ - **Behavior change — a switch nobody can read is refused.** What changed: `attachments.mcpUnavailable` that is not a boolean (`0`, `null`, `"off"`, an object, a list) refuses the task at the attachments door with `config.invalid_argument` (`detail.field` `TaskSpec.attachments.mcpUnavailable`), before any server is dialed or model request made, on a fresh run and a resume's re-supplied config alike; it used to be read as the default (on), which a deployment that meant "off" took for off. Who is hurt: no released spec — the key is new in this release; only a host sending it as a string or number. How to migrate: send `true` or `false`, or omit the key. The other attachment switches read as before.
949
+ - **Differs from the reference (open to veto).** The reference produces its deferred-tools frame only while tool search is active (cli296.js:250049); this engine tells the model whenever a declared server failed, deferred tools or not — the safe direction, since otherwise a missing tool reads as a missing capability.
950
+ - **Not changed here.** The operator line `mcp.server_unavailable`; the status record and the wiring manifest; which servers are dialed; a failed server's tools still leave the tool block (keeping them listed from the server's last listing is slice 2's opt-in). A host cannot drop one server's entry, and the station that disposes of server instructions (`prompt.section`) is not asked: an availability fact the engine authors is not the server's text.
951
+ - **Consumers.** @server: pass `attachments.mcpUnavailable` (boolean) through the request intake — a non-boolean is refused by the engine — and note that the request's attachments now reach its delegates (open to veto). @client-core: add `mcpUnavailable` to the default-on key tables so "off" stays all off; optionally a label for the source; a lane the request turns on now shows on its delegates' legs too (open to veto). @cli: render `mcp_unavailable` like the other model-facing system frames. @dgx (ha-brain): nothing to turn on — the frame arrives with the core that carries this release.
952
+ - **Pins.** `test/backlog1551-mcp-unavailable-lane.test.ts` (real Runner, a spawn-failing stdio server, a brain that answers its first request in text: L1 the first request carries the notice ahead of the objective, one echo, the session records it; L2 `false` turns it off; L3 with `toolsDelta` on, tools_delta carries none of it; L4 once per session, a fresh session told again; L5 the sentence; L6 no server ⇒ no frame; L7 a server that dies across an approval pause is told on the resumed continuation, answered in text; L8 the switch's reading, a value it cannot read refused by name; L9 a first prompt a `userPromptSubmit` hook kept back records nothing and the next run is told; L10 a parent that switched the notice off switches it off for the child it delegates to, with the default control; L11 five unreadable values refuse the task with no model request and no frame, the good spellings run; L12 a lane the parent opted into — `changedFiles` — reaches the child's legs, with a no-declaration control; L13 the seat's fill law for `attachments`: a child's own declaration wins whole, an unset one gets a copy); `test/backlog1950-resume-config-doors-preclaim.test.ts` gains the unreadable-switch row (a resume's re-supplied config is refused before the claim); re-pinned to the new source: `rb309-tools-delta-mcp-arms-e2e`, `backlog560-announce-once-per-session` (its two stop / halt pins now probe the boundary-only dropped-tools once-arm), `turn-attachments-g1-tools-delta`, `backlog818-intact-lane` (the new frame's bound), `prepare-tool-disclosure-mount(-transcript)`, and the registry / source-union tables (`prompt-assembly-event-registry` — the fifth default-on kind, argued there —, `event-registry-completeness`, `backlog1255-output-style`).
953
+
954
+ ### The allow layer's `hook` member has one clear and one record whichever event speaks it: a `tool.check` `allow` that clears an ask is recorded as the hook member's clear (`clearedBy`), as a `classic.PreToolUse` `allow` already was, and the member's disclosure is one record naming every registration that spoke (#1406; behavior change, open to veto; wire: `tool_end.gate.disposition.clearedBy` one more presence case, `AskRequest.allowRuleShadowed` one `hook` record where there were several)
955
+
956
+ - **Before.** Two events speak "this call needs no asking" for an extension: a `classic.PreToolUse` `allow`, read as the allow layer's `hook` member at the head of the layer, and a `tool.check` `allow`, read behind the read-only arm over the verdict the layer left standing. Both cleared through the same predicate, but only the first wrote the allowed disposition's `clearedBy: { member: "hook", entries: [<registration>] }` — the gate record of a call a `tool.check` extension cleared read like a call nobody was asked about, so `tool.call` took an extension's `{ result }` in place of the body there and refused it on a call a PreToolUse extension had cleared. A mandated ask both events answered `allow` over carried two `member: "hook"` disclosure records (more with inherited installations), where the layer's rule is one record per member.
957
+ - **Now.** One mint for the hook member's clear (`extension "<name>" allowed this call`), its `clearedBy` written from both places; one record per member in `allowRuleShadowed` — its entries are every speaking in reading order (two registrations whose shown names read alike stay two entries; a registration that speaks in two installations is listed twice), five shown and the rest counted. Positions are unchanged: a PreToolUse `allow` is still the allow layer's first member — the rule store is still read once before it, for its deny and ask rows, but a call it clears is not put down to a stored allow row (that row's sink is not told) and the read-only reader does not run — and `tool.check` is still raised over the verdict the layer left standing, as the reference engine reads its two events at two entry points. Which calls run and which are asked about is unchanged; neither clear touches the classifier's denial streak.
958
+ - **Who is hurt.** A reader that took an absent `clearedBy` as "nobody was asked" on a call a `tool.check` extension cleared (it now reads `member: "hook"`); a `tool.call` `{ result }` on such a call is refused now — the tool's body does not run either way, the model reads the refusal instead of the extension's output (`tool.call` is new in this release: no released host relied on the old reading); a card or a view that counted `hook` records (one now, every speaking in `entries`).
959
+ - **Consumers.** @server (the trace projection of `clearedBy`; pass-through) · @cli (the card shows one hook row) · @client-core (one record per member in the view).
960
+ - **Pins.** `test/backlog1406-extension-clear-one-reading.test.ts` (X1–X6 and X9–X15: the clearance record from both events, one hook record beside the other members' — inherited installations and the approved-edit round included, two look-alike names kept apart, the count past five exact — a real Runner's `tool.call` substitute refused after either clear, and the two positions — a PreToolUse clear ahead of the read-only arm and a stored allow row with the store read once and a stored deny row still refusing, a stored row ahead of `tool.check`); `test/mods-tool-check.test.ts` G3 re-pinned to the one record.
961
+
962
+ ### A persisted-rule lane's answer the gate cannot read is the lane's `unreadable` word, never "no rule" — `normalizePersistedRuleHit` answers `{ unreadable: true, reason }` (the reason names the host's lane `persistedRules.adjudicate` and the failed field) for every malformed answer, and `{}` only for the lane's own no-hit words (#2302; security fix and behavior narrowing, open to veto; hard-breaking: none — return type unchanged; closed sets: none; exports: none) (@server @cli)
963
+
964
+ - **Before.** `normalizePersistedRuleHit` (the one screen every consumer of a persisted-rule lane reads through: the live gate's lane station, the synthetic Read / write judge, the resumed-call belt) answered `{}` — "no rule speaks" — for every answer it could not read: a `behavior` outside the closed set (`Deny`, `42`, absent), `rules` absent / not an array / empty, a member that is not a record or whose `rule` is not a string, a `null` / string / number answer, an object that is none of a hit, an unreadable report or a coverage table (`{}`, the retired `{ rule }`). Its own `unreadable` arm was therefore unreachable from a malformed answer, and a host whose lane misspelled a deny's behavior had that deny silently fail: the default mode handed the call to the classifier and `bypassPermissions` ran it. The org lane read the same garbage as `unavailable` (fail closed) — two answers, in two places, to "the lane gave an answer nobody can read".
965
+ - **Now.** One reading. `{}` is only the lane's own no-hit words: `undefined` (the store was read and no rule speaks) and a coverage answer (`{ segmentCoverage }`, whose malformed table is advisory and reads `{}`). Every other answer the lane gives that the screen cannot read is `{ unreadable: true, reason }` — the call form: the gate puts a person on the call (a real-approval ask of class `rule_unreadable`, origin `rule_store_unavailable`, the evidence `unavailable`), no mode releases it (`bypassPermissions` keeps it for a person — the same row the org lane's `unavailable` reads), a run with no approval face refuses the call, the synthetic judge answers `unanswerable` (`call_unreadable`) and the resumed-call belt spends the approval. The ask's reason names the seat and the first field that failed — `` `behavior` is not one of deny / ask / allow ``, `` `rules[1].rule` is not a string ``, `` `rules` is empty ``, `` the answer is `null` `` — from a fixed vocabulary and a row index; a lane's own values never ride it. A readable deny, ask or allow keeps its reading; the lane's own `{ unreadable: true[, reason] }` is carried as it was. No new code, class, origin, notice or form: the arm that was unreachable is now reached, and the helper that builds it is the only new line of reading.
966
+ - **What reads unreadable that read "no rule" (open to veto).** `null` (the contract's no-hit word is `undefined`; a lane bridged over JSON that answers `null` for a miss is now asked about), an empty `rules` (the `PersistedRuleHit` note said "not a hit"; a lane that builds `{ behavior, rules: matches }` and hands over an empty `matches` for a miss is now asked about), a bare string, the retired `{ rule, dots? }` object and a `{ rules }` hit without its behavior (the retired shapes the notes already said the screen does not accept), and any carrier that is not a plain own-data record. The engine's own lane never answers any of these (its answers are `undefined`, a hit with at least one rule, the unreadable report and the coverage answer), so a deployment on the bundled rule store sees no change.
967
+ - **Who is hurt.** A host that drives `runToolGate({ persistedRules })` directly, or calls `normalizePersistedRuleHit`, with a lane that answers one of the shapes above: its calls on the lane's tools now ask a person (default and bypass) or are refused (`dontAsk`, or no approval face) instead of running as "no rule" — loudly, naming the field. The Runner mints its lanes from `permissionRuleStore`, so a Runner-hosted deployment cannot hit this.
968
+ - **How to migrate.** A lane with no rule for the call answers `undefined`; a hit answers `{ behavior: "deny" | "ask" | "allow", rules: [{ rule, dots? }, …] }` with at least one member; a lane that cannot read its store answers `{ unreadable: true }`. *server*, *cli*: `ToolGateInput.persistedRules` is not wired in either checkout as read here (inferred from a text search of `src`, not run: your `persistedRules` wire key is the approval-response echo of the engine's own lane, a different seat) — confirm by search; nothing to change if so.
969
+ - **A hit the screen cannot inspect is not a coverage answer.** An answer whose `behavior` / `rules` (or `unreadable`) are accessors — never invoked — beside a `segmentCoverage` table read as the coverage no-hit; it is now unreadable (the coverage reading applies only to an answer that bears none of `rules`, `behavior`, `unreadable`), and a `rules` array whose `length` is not one positive integer, read once, is unreadable.
970
+ - **Pins.** `test/backlog2302-lane-answer-unreadable.test.ts` (S1–S19 on the normalizer, on the resumed-call belt's composition, on the synthetic Read judge and on a real Runner under default, bypass, `dontAsk` and no approval face with the lane's answer injected where a host's lane would sit; the org lane's garbage and the persisted lane's garbage read the same class and disposition; well-formed deny / allow / ask and the no-hit words keep their readings; mutations M1–M8, per-fixture table in the receipt), and the `design252-ask-rule-evidence` cells re-pinned to the new reading (`unavailable`, not `no_match`).
971
+
972
+ ### The sentence a user reads after a fail-closed refusal nobody could be asked about offers the approver and no longer teaches `bypassPermissions`; the release rides the `delegation.ask_unresolvable` notice as `detail.remedies`, a closed-set member a host renders or leaves (#2422; user-visible wording change, open to veto; additive member; hard-breaking: none; closed sets: `ApprovalRemedyKind` new, `approver` / `permission_mode`; exports: `APPROVAL_REMEDY_KINDS`, `ApprovalRemedy`, `ApprovalRemedyKind`) (@server @cli @client-core)
973
+
974
+ - **Before.** An ask that reached its final deny with nobody to answer it (no approver wired, a wired one that answered `unavailable`, or a run that declared nobody on it can be asked) ended its tool text, and the notice's `detail.remedy` and `message` ("To let it run: …"), with one sentence naming the way out. On two arms that sentence said "run with `permissionMode: "bypassPermissions"` to release this ask (the release is disclosed on the call), or" and then the approver clause: for a tool that states no permission of its own (the clause stood between the allow entry and the approver), and for every ask no other arm names — a Bash call under `default` that needs approval is one. A user read that beside a client's own way out ("upgrade"): two ways out on one screen, one of which teaches opening every permission.
975
+ - **Now.** On those two arms the sentence is the approver clause alone, in the clause of the seat's own state (`wire an approver (`onAsk`) to put it to a person`; `make a person reachable through the approver that is wired (it answered `unavailable` for this ask)`; the declared-nobody form). The arms that name `acceptEdits`, `additionalDirectories`, an allow entry, leaving plan mode or an immunity no mode lifts keep their sentences. The release is data: `delegation.ask_unresolvable` `detail.remedies` — a non-empty list over the closed set `APPROVAL_REMEDY_KINDS` — carries `{ kind: "approver" }` on every arm that carries a remedy and, on exactly the two arms above, `{ kind: "permission_mode", value: "bypassPermissions", disclosedOn: "gate.disposition.released" }`; `disclosedOn` is where that release is disclosed (the allowed disposition's `released` member on the call's `tool_end.gate`), the note the sentence used to carry in parentheses. `remedies` rides exactly when `detail.remedy` is the headless remedy's sentence (a binding's own sentence and `mandate_unreconstructible` carry none); a notice minted without it is byte-identical to one minted before. The notice mint throws a `TypeError` for a `remedies` that is empty, outside the closed set, of another shape, or has no remedy sentence beside it. One decision gives both: `headlessApprovalRemedy` returns `{ sentence, remedies }`, and the gate's exit hands the notice the pair it read.
976
+ - **Who is hurt (wording change, open to veto).** A reader that matched "run with `permissionMode: "bypassPermissions"` to release this ask" or "the release is disclosed on the call" in a refusal's tool text or a notice's message (a probe, a test, a prompt that quotes it): those words are gone from both. As read in the neighbor checkouts (inferred from a text search of `src` and tests, not run): *server* matches only the approver clause (`test/s654-unstated-statements.test.ts`, unchanged by this) and names the old words in a `run-local.ts` comment alone; *cli* renders the engine's `message` whole (`upstreamBridge.ts`, the 400-character budget; its fallback line reads `detail.remedy`); *client-core* reads `detail.remedy` as an opaque string and has no `remedies` member yet — none matches the removed words. An embedder that learned the release from the sentence reads it from `remedies`, or from `docs/INTEGRATION-CORE.md`.
977
+ - **How to migrate.** Nothing breaks: the member is additive and an older host ignores it. *server*: pass `detail.remedies` through on `engine_notice` and render nothing. *cli*, *client-core*: render by `kind` or leave it unrendered — `approver` is what the sentence already says; `permission_mode` is an offer a host makes only where its operator allows the mode, with the disclosure `disclosedOn` names beside it; a `kind` outside `APPROVAL_REMEDY_KINDS` belongs to a newer engine and is ignored.
978
+ - **What the member does not list.** The sentences of the arms above that stay name `acceptEdits`, `additionalDirectories`, an allow entry or leaving plan mode; those are narrower than the release the sentence stopped teaching and are not in `remedies`, which today lists the approver and that one release.
979
+ - **Pins.** `test/backlog2422-refusal-remedies.test.ts` (a real Runner: a headless Bash call under `default` and under a wired approver that answers `unavailable` — the tool text has no `bypassPermissions`, the notice carries the approver and the release; the refusals that never offered the release carry the approver alone; a matrix over every mode, class, seat and covered-write form for the sentence and for exactly where the release is a member; the notice mint; mutations M1–M6, per-fixture table in the receipt), and the `backlog1137` / `backlog1314` sentences re-pinned.
980
+
981
+ ### An engine write to a memory store file changes only the name it writes: a file that also has another name (a hard link) gets a new file under this name instead of a refusal, and the staging name a restore stopped between its `link` and its `unlink` left beside a seat is removed by the next write that meets it (#1105, design/1105; ruled C-R774; behavior widening — a refusal becomes a performed write, open to veto; fix, user-visible: a copy-out mount over such a seat failed on every session and now mounts; hard-breaking: none; closed sets: none; exports: none; members: none; wire: none — the uncoded `<path> has N names (a hard link) … not written` text is gone) (@server @dgx)
982
+
983
+ - **What changed.** `writeFileNoFollow` — every engine write at a store path that follows an earlier read: the containment and read-only-layer restores, the copy-out projection and its write-back, the seat repair, the id strips, the memory index writes — opens the path no-follow for writing and counts its names. One name: rewritten in place, as before (its inode and mode stay). More names: first, every staging name an engine minted beside the seat that is the same file is removed (the interrupted restore's last step; it loses nothing the write does not overwrite); a file that still has another name is not written in place — this name gets a new file, written the way the store commits a file (`atomicWriteFileSync`: a sibling created exclusively and no-follow, 0o600, renamed over this name), and the other names keep their file, its bytes and its mode. This is the answer the store's other three writers already gave (the File backend's commit and its inbound-gate restore rename a new file into place; a host's Write tool stages and renames) and the reference implementation's (its file writer renames a new file over the target; an in-place fallback refuses a hard link only for a caller that asks).
984
+ - **Who is hurt.** A deployment that hard-links a store file somewhere else and relied on the engine's restores or projections reaching the other name (the commits never did); the name the engine gives a new file is 0o600 and owned by the engine's process. A consumer or test that matched the old refusal text (none found in server, cli or dgx).
985
+ - **How to migrate.** Nothing to read: keep two copies in sync with a copy or a symlink outside the store, not a hard link (a symlink at a store path is still refused at the open). A directory the new file cannot be created in fails the write with its errno (`EACCES` / `EPERM` with the existing permission remedy inside the store, `EROFS`, `ENOSPC`, `EEXIST`) — never a fallback to the in-place write.
986
+ - **Not changed.** A symlink at the name is refused at the open (`ELOOP`); a file whose own bits refuse the write is refused at the open (`EACCES`) — the new file is only ever made for a file this process could have written in place; a single-name file keeps its inode and mode (the reasons an earlier attempt to route every such write through the rename, which replaced symlinks, bypassed write protection and changed every inode, was not taken).
987
+ - **The engine's own leftover.** A staging name in the engine's one staging grammar for its two owners — an instruction hold (`hold-<ms>-<12 hex>.staging-<ms>-<pid>-<seq>`) and a crash-residue capture (`<ms>-residue-<id>.md.staging-<ms>-<pid>-<seq>`, and that containment's `<ms>-residue-<id>.md.staging` of 7.55.0 and earlier), any `.claim-<pid>-<seq>` suffixes — beside the seat, that is the same file (device and inode). A name that only looks like a staging name (`backup.staging-1-2-3`) is not the engine's and stays. The crash-residue containment now mints its staging name through the same minter as the instruction hold. The hold reconciliation retires such a generation into custody like any other but no longer announces "could not be restored onto the plane" while the seat still holds those bytes.
988
+ - **Fix: the committed copy an UPDATE-form hold puts back lands only in the seat the hold vacated.** When an instruction-form file's bytes were replaced between the harvest's read and the hold's move, the hold puts the newer bytes back on the seat (or a third writer re-created the seat first); the UPDATE form then wrote the committed copy over them. It now writes no-replace (an exclusive create): a writer who holds the seat keeps it, the next harvest judges those bytes, and `report.restored` names only a restore that landed.
989
+ - **Atomic writes.** `atomicWriteFileSync` creates its temporary sibling exclusively and no-follow: a name already standing there is an `EEXIST` refusal, never a file it truncates or writes through (every caller: the control plane's sidecars, the File backend's journal replay and inbound restore, and now the detached write).
990
+ - **Known limits.** A path replaced by another file between the open and the rename is replaced again — the window every rename-based store write has (KL-1105-a). A leftover staging name no write meets stays beside its seat as a non-entry file (KL-1105-b). KL-1094-g is closed.
991
+ - **Pins.** `test/backlog1105-hardlink-second-name.test.ts` (the engine's own leftover removed and the write in place; the old residue spelling; a minted-grammar file that is a different file left alone; a planted link to a file outside — new file here, the outside file's bytes and mode untouched; a host's hard-link copy kept; a symlink still refused; write protection not bypassed; a directory that refuses the new file fails with its errno and writes nothing through; the exclusive temporary sibling; a copy-out mount over an interrupted restore and over a `cp -al` snapshot; the hold reconciliation's disclosure and its control; a host backup spelled like a staging name kept; the written path never taken for a leftover; the minter and the UPDATE-form no-replace restore in the source; end to end on a File store, a writer who takes the seat an UPDATE-form hold vacated keeps it, and its control); `test/a005-memory-engine-nofollow.test.ts` site table: the UPDATE-form restore is the exclusive create; `test/backlog1094-memory-target-realpath.test.ts` ⑳a / ⑳b / ⑳c re-pinned from the refusal to the new file.
992
+
993
+ ### `Runner.rewindFiles({ sessionId, to, dryRun?, signal? })` rewinds a session's files outside any run — the code-only rewind with no model call and no session entry — and is the same history operation as `rewindFilesTo`; every `refused[]` row of a restore names its class, and the file-history `conflict` code means one law (#1130 / #2177, design/1130; behavior widening — a new verb; hard-breaking for a file-history backend outside this repo: two store-contract shapes; closed sets: +2 refusal codes from the new verb only, `rewind.session_busy` / `rewind.files_env_unsupported`, and a new closed set `FILE_HISTORY_REFUSAL_CLASSES` (3); exports: +`annulProofFailure`, +`FILE_HISTORY_REFUSAL_CLASSES`, +types `RewindFilesRequest` / `RewindFilesResult` / `FileHistoryRefusalClass` / `FileHistoryMintProof`) (@server @client-core @cli)
994
+
995
+ - **What changed — the verb.** `runner.rewindFiles({ sessionId, to })` converges the session's agent-edited files to the boundary of turn `to` (the entry id of the turn's FIRST user entry — the id space `TaskSpec.rewindFilesTo` addresses; an entry with no boundary of its own refuses, exactly as there) and answers `{ dryRun: false, restore }`, the store's ledger as it answered (`complete`, or `partial` with every refused and failed file). `dryRun: true` answers `{ dryRun: true, preview }` from the store's optional `diffStats` and writes nothing to the working tree; `preview: null` means no preview for this request (the backend has no `diffStats`, or answered none) and is distinct from an empty answer. It reuses what a `rewindFilesTo` run uses — the deployment door (`onTrackFailure` / `fileHistoryStore` / `fileHistoryCapture`), the env and initial root (a factory-minted env is minted for the rewind and destroyed after it), the history coordinates, the fork carry, and one restore reader — so `fileHistoryCapture: "off"` still restores, and there is no `TaskSpec.fileHistory` outside a run. There is no `acceptPartialRestore`: a run refuses a partial restore because it would go on working on a mixed tree; the rewind ends, and its caller reads the ledger.
996
+ - **What changed — admission and refusals.** In order: a bad request (`rewind.invalid_spec`: a request that is not a plain data object, judged before any member is read — a Proxy of any kind, even a transparent or revoked one, and an object whose prototype is neither `Object.prototype` nor `null`, an object built in another realm (a `vm` context) included, since it carries that realm's `Object.prototype`; `sessionId` / `to` not non-empty strings, `dryRun` not a boolean, `signal` not an AbortSignal, an unknown key, a member that cannot be read — a getter member is refused, never called). The request is taken as data once, so a Proxy that tells the caller `dryRun: true` can never become a real rewind, and no trap of the request runs; a host that wraps its request in a Proxy, or builds it in another realm, passes a plain copy (`{ sessionId, to, dryRun }` written out in its own realm); the deployment door's `config.*` codes and `rewind.store_unconfigured`; **`rewind.session_busy`** — a leg of THIS Runner is running or queued on the session (otherwise the rewind holds the session's lock until it ends, so a leg that arrives meanwhile waits for it); `resume.session_not_found`; **`rewind.files_env_unsupported`** — no filesystem-capable env (a run with a restore request notes the same fact as `files_env_unsupported` and runs on); `rewind_snapshot.unresolvable` (a preview's sentence says "no usable boundary": its probe cannot tell a boundary never made from one that cannot be read); `rewind.restore_failed` with `detail`. All are thrown `Error`s carrying `code`.
997
+ - **What changed — the read state.** The paths a restore moved go to the Runner's session read-state seats, and the session's next leg checks each against the disk after its seeding rungs (the agreement check a run's own restore always had), settling the batch only once its map is adopted — so after `rewindFiles`, the next turn's Edit on a restored file is refused as unread instead of "modified since read, either by the user or by a linter". The run's own restore uses the same channel, which also covers a partial restore the run refused (its written files used to keep their stale read records).
998
+ - **What changed — the ledger and the proof read.** `FileHistoryRestoreResult.refused[]` rows carry a required `class`: `link_safety` (a symlink or other non-regular object at the path, a parent chain that moved, a guarded write's precondition — the reference's `skippedLinks`), `base_unavailable`, `key_unresolvable`; damage to the store's own records is a `failed` row in every backend. `worldStillMatchesMint` answers `{ verdict: "unchanged" | "kept" | "unjudged" }`: a proof read that could not re-read the path judged nothing, so `annulTrack` answers `track_failed` (`aborted` when the signal fired) there, no longer `conflict`; `annulProofFailure` is the one mapping. `FileHistoryError.code`'s `conflict` is documented as the one history-preservation law it is (the empty-target law of `adoptScope` / `importHistory`, and `annulTrack` of a record that is no longer just the caller's mint).
999
+ - **Who is hurt.** A file-history backend outside this repo (server's `SqlFileHistoryStore`): its `restore` must put a `class` on every refused row — copy `resolved.class` / `applied.class` from the shared helpers it already calls and write `"base_unavailable"` on its own DV-14 arm; a refusal for a corrupt mode record is store damage and belongs in `failed`. Its `annulTrack` reads `proof.verdict` instead of `proof.unchanged`, and an unread proof is no longer `conflict`. A host that counted `conflict` from a retraction as "the law said no" for an unreadable path now sees `track_failed` / `aborted` there. Nothing changes for a run, its codes or its answers.
1000
+ - **How to migrate.** Backend: `ledger.refused.push({ path, reason, class })` at each refused arm; `const proof = await worldStillMatchesMint(...); if (proof !== undefined && proof.verdict !== "unchanged") return annulProofFailure(proof, absPath);`. Host (server route / sdk verb): call `runner.rewindFiles` and pass its codes through; the reference's `canRewind` ⇔ the call did not throw, `filesChanged` / `insertions` / `deletions` = `preview`, `skippedLinks` = `restore.refused.filter((r) => r.class === "link_safety").length`. A deployment with several Runners over one session store routes the rewind to the Runner that runs the session (the busy check sees only its own lock).
1001
+ - **Known limits.** The busy check sees this Runner's session lock: a same-tree delegated child recording into the session's history scope under its own session id — a background child that outlives its parent's leg — does not make the rewind refuse (KL-1130-a). A parked session's rewind addresses a fresh workspace and a snapshot-backed resume can put the parked snapshot back over it — as a `rewindFilesTo` run on a parked session does today (KL-1130-b). The read-state handoff lives in this Runner's process and covers the next leg that adopts the session's read state; a later replay of an older source (a conversation-only `resumeAt` branch, a checkpoint cut before the rewind) can bring a pre-rewind read record back, as after a run's own restore (KL-1130-c).
1002
+ - **Pins.** `test/backlog1130-rewind-files-verb.test.ts` (restore and preview with no brain call and no entry; preview null both ways; no boundary for both; the fork carry; the deployment door, capture off, a bad onTrackFailure; missing session, stub env; twelve bad requests including a hostile `dryRun` getter; a request that is a Proxy (one whose `get` tells the caller `dryRun: true`, one whose `ownKeys` / descriptor traps add it, a transparent one, a revoked one — no trap runs, nothing is rewound), an object that inherits `dryRun`, and an object of another realm refused, with the frozen, non-enumerable-`dryRun`, `dryRun: undefined` and null-prototype plain forms still admitted; factory env minted and destroyed once per call on both paths; busy refusal and the lock held during the rewind; the out-of-run read-state handoff and the refused-partial run's handoff; the channel's settle; the partial ledger handed back; the classes; the proof read's three verdicts; the code doc's one law); the file-history kit: link-safety and base-unavailable classes, and the unread / aborted proof read (`track_failed` / `aborted`, no permanence, the record kept).
1003
+
1004
+ ### A peer session's directory row says where it runs — `PeerSessionRecord.where` is `this_machine` or `hosted`, the members that name a local process (`pid`, `procStartMs`, `sockPath`) ride a `this_machine` row only, and ListAgents and the ambiguity error say where from one table; a cross-session notice's typed notice is read off the frame's provenance record, where it already rides (#971 S1, #1090; behavior narrowing of the row contract, open to veto; hard-breaking: `PeerSessionRecord.where` is required; closed sets: a new one, `PEER_SESSION_WHERE_VALUES` = `this_machine` / `hosted`; exports: +2 runtime (`PEER_SESSION_WHERE_VALUES`, `PEER_SESSION_WHERE_LABELS`) and the type `PeerSessionWhere`) (@server @client-core @cli)
1005
+
1006
+ - **Before.** A directory row had no locality member. Whether `pid` / `sockPath` described a process on the reading machine was assumed (the socket lane dials `sockPath`; the liveness probe signals `pid`), and a server's session rows left them out by convention only. ListAgents rendered every peer as `peer session`, with nothing saying where it runs. Separately, a host that wanted a delivery receipt's state or an idle notice's `finishedAt` read the box row, because the type comment said the frame's provenance carried `peerMeta` on the message lane only.
1007
+ - **Now.** `PeerSessionRecord.where` is required and closed. `this_machine` = a process on the reading session's machine, stated only by a directory that reads that machine (`createCcPeerDirectory` stamps it on every row). `hosted` = a session a hosting service runs. `readPeerSessionRecord` refuses a row without `where` (`field_invalid: where`) and a non-`this_machine` row that carries `pid`, `procStartMs` or `sockPath` (`field_invalid` naming the member). Every terminal now reads a row through that reader. The engine reads a host's directory through one function, `listPeerDirectory`, at the three places it lists (ListAgents, SendMessage's resolution and its post-append re-read, the drain's own-row lookup). A row the reader refuses faults the whole listing; it is never dropped, since refs are minted and names resolved over the full set. Each place's existing fault arm then speaks: ListAgents says `peer-session directory unreadable: …` and names the row and the reason, SendMessage refuses with nothing sent, and the drain falls back to its own label. So a socket is dialed and a pid probed for a local process and nothing else: the exported `isPeerSessionProcessAlive` takes `where` too and does not signal a pid that is not this machine's. `peerDirectoryContract` gains a fifth reading: every listed row is one the reader accepts. ListAgents carries `details.rows[i].where` on peer rows and renders the kind column as `peer session on this machine` / `peer session hosted by the service` (`PEER_SESSION_WHERE_LABELS`; its description reads the same table). The ambiguity error's candidates say the same. ListAgents' peer state table drops its own-key guard: a row's `tempo` reaches it only through the reader. What a send promises still follows the lane, not `where`: receipts, idle notices and durable parking hold for every store-lane peer wherever it runs. The notice frame: no new member. Its typed notice has ridden `_sema_provenance.peerMeta.notice` since 7.4.0, the box record's `MailboxPeerNoticeMeta` copied whole. The type comments now say so (four carriers, two box-record provenance kinds), and `crossSessionNotice` stays `{ kind, text }`, the render discriminator and the line.
1008
+ - **Hard break (open to veto). What changed:** `PeerSessionRecord.where` is a required member. `readPeerSessionRecord`, and so `createInMemoryPeerDirectory().upsert`, refuses a row without it, or a `hosted` row with `pid` / `procStartMs` / `sockPath`. The engine reads every listed row through it, and one row it refuses faults that listing. **Who is hurt:** every host that builds `PeerSessionRecord` objects. A server directory projection fails `tsc` at upgrade, and an `upsert` caller whose rows lack `where` is refused. A host whose directory hands back a row the reader refuses, for any member, used to have its other rows used as-is; now that host's peer lane says "directory unreadable" and sends nothing until the row is fixed. **How to migrate:** add `where` to each row your directory hands back: `"hosted"` for sessions your service runs, `"this_machine"` only if your directory reads the reader's own machine. Keep the three process members off `hosted` rows, and run `peerDirectoryContract` against your directory.
1009
+ - **Consumers.** @server: the peer-directory projection's two row literals add `where: "hosted"` (its rows already carry no pid, procStartMs or sockPath). For S-709, `semaProvenanceOrAbsent` projects kind `cross_session_notice` with `peerMeta.notice`: the typed bits are already on the frame, so no new core member is needed and none is coming. @client-core: the cross-session notice card reads `_sema_provenance.peerMeta.notice` and does not wait for a carrier member; peer rows in ListAgents' `details.rows` carry `where`. @cli: the closed set and its two labels are what the cross-machine half will be written against; nothing to do in this version.
1010
+ - **Not in this slice.** A session on another machine listed through a relay (a `remote` value, which needs the reader's identity on `PeerDirectoryAccess`), a per-source incomplete-listing seat, and a cross-machine approval setting are later slices of the same design.
1011
+ - **Pins.** `test/backlog971-session-where.test.ts`: W1 the reader's closed set and the three machine-bound members on each value; W2 the in-memory write path; W3 the reference-registry reader stamps `this_machine`; W4 the real ListAgents tool's rows, kind column and description; W5 the ambiguity error; W6 a host that lists a refused row (the listing faults whole on ListAgents and SendMessage, nothing appended); W7 the kit's five readings and two broken projections that fail only the reader reading; W8 the liveness probe never signals a hosted row's pid. `backlog940-peer-row-state`'s hostile-tempo cell now reads the fault. `test/backlog1090-notice-typed-seat.test.ts`: T1 a real run's `task_notification` events carry each record's typed notice at `_sema_provenance.peerMeta.notice`, equal to the box record, with the carrier `{ kind, text }` only; T2 the drain alone, every typed member, both kinds. Re-pinned for the new kind column: `backlog1024-list-agents-fresh`, `design385-slice2b-agents` (whose A3 liveness rows are now `this_machine`). Fixtures gained `where` in six more files, and `backlog969-peer-directory-contract` now counts five readings.
1012
+
1013
+ ### A run's result names the committed messages it was read from — `TaskResult.resultEntryIds`, in conversation order, the ids `message_committed` carries — so a consumer replaces those messages' text rows with `result` instead of inferring which message on screen the answer was (#1966; additive; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `TaskResult.resultEntryIds`) (@client-core @server @test)
1014
+
1015
+ - **Before.** `done.result.result` was a plain string with no provenance. It is read from the run's final assistant message merged with the trailing chain of cut-and-continued messages (a `length` stop or a promoted mid-stream partial, each followed by a continue nudge), and a committed message can differ from what streamed (engine notes placed when the turn finalizes, a degenerate cut trimmed, an opt-in text tool-call repair). A consumer comparing `result` with "the message on screen now" misreads the chain shape by construction: on screen is the chain's last message, `result` is the whole chain — a normal continued answer was reported as diverging from what was shown.
1016
+ - **Now.** `resultEntryIds` lists the entry ids of the committed assistant messages the engine selected as the answer's source: one for an ordinary answer, every member of the continuation chain when an answer was continued. The chain walk that merges the text names its members, so the merge and the names cannot disagree. A member may contribute no text (a tool-calling message a park or a stop ended on, a thinking-only cut head), so a non-empty list may sit beside an empty `result`. `[]` = read from no committed message (refused before any turn, a run with no assistant message, and every result the engine writes in its own words: a workflow call's refusal or failure row, its `park_not_pending` settlement, the journal's oversize tombstone, a cascade that dispatched no rung, a repair loop's unprotected or exhausted-before-dispatch result). Absent = the provenance is unknown: a record an older engine wrote, a result rebuilt from a checkpoint row alone, or an answer a leg read from a message committed before it began (whose append that leg never saw — no Runner shape reaching it was found; it is stated as unknown rather than as `[]`). Every other result a Runner assembles carries it, and so does the stream's settle backstop. The harness binds an answer's sources to the answer object it returns, so a prompt that rejects after its answer was committed, or one that overlaps another on the same harness, leaves no name for a later reader to pick up.
1017
+ - **Consumer rule.** At `done`, replace the text rows of the named messages (bound through `message_committed`, in stream order) with `result` — no comparison, no suffix arithmetic. A consumer that cannot bind an id (it lost that `message_committed`) reads the named entries from the session instead of guessing. *client-core*: where the field is present, the six-cell reconciliation in the adapter's result arm (render whole / nothing / trim-equal / append suffix / result-is-a-prefix / `result_text_diverged`) and its "current message, else the previous one" left value reduce to this rule; your existing path stays for an absent field. *server*: the key reaches your per-key result table (`observability/run-terminal.ts`) — it is an entry id the engine mints (not model text, not a secret); your ledger does not keep `message_committed`, so a consumer reading a replayed stream cannot bind ids and takes the read-the-entries branch. *cli*: `-p` prints `result`, unchanged.
1018
+ - **Not yet byte-equal.** `result` alone drops inline `<think>…</think>` blocks and is trimmed; the named messages keep both. Moving those normalizations to where the message is produced is a change of its own.
1019
+ - **Not closed here.** A sub-agent's forwarded pane carries no `message_committed`, so its rows cannot be bound. The `text_end.content` contract sentence said the segment text is byte-equal to its deltas; it now says what it is — the block as the brain closed it, which the committed block can still differ from.
1020
+ - **Pins.** `test/backlog1966-result-entry-ids.test.ts` (P-A the continuation chain over the real OpenAI-protocol brain fed SSE fixtures — both members named in order, each a real assistant entry of the session tree; P-B one answer; P-C tool then answer, only the final message; P-D refused before any turn, `[]`; P-F park then resume, each leg its own messages, the parked tool-calling message named beside an empty `result`; P-I a thinking-only cut head named; P-K the sources ride the answer object — an absent answer, a look-alike and a later prompt's empty ending name nothing while the earlier answer keeps its own; P-H the think-tag gap as it stands), and one `[]` cell each in `backlog652-park-truth-from-store`, `backlog672-journal-oversize-refusal`, `cascade`, `repair-loop` (two), `rb201-workflow-spawn-review` (two), `backlog383-workflow-budget-gate-timing` (two) and `workflow-resilience`; mutations M1–M19, per-fixture table in the receipt.
1021
+
1022
+ ### A skill carries its own hooks: `SkillSpec.mods` registrations are admitted with the leg that loads the skill and take part in that leg's raises only while the skill is active there — from its activation to the run's end, across a park and its resume — and never in what the leg hands its children (#1627 slice S1; design/1627 r2; capability word `mods.skill_seat`; behavior change, open to veto; differs from the reference implementation on two points, open to veto (below); hard-breaking: one — the parked row gains a member and a requirement word, a store-format forward change, open to veto; closed sets: `EngineCapability` 2 → 3 (+`mods.skill_seat`), `RequirementWord` 32 → 33 (+`skill_scopes`), `CheckpointErrorReason` +`skill_scopes_damaged` (all registered in `docs/CLOSED-SETS.md`); exports: none by name (`ENGINE_CAPABILITIES` gains a word); members: +1 optional `SkillSpec.mods`, +1 optional `CheckpointState.skillScopes`) (@server @client-core @cli @sdk @test)
1023
+
1024
+ - **What changed.** A skill may carry `mods` — `EngineModRegistration`s in the `TaskSpec.mods` form. They are admitted at the
1025
+ prepare of every leg that loads the skill (it passes the load gate), on the task seat after `TaskSpec.mods`, through the
1026
+ same screen. Their handlers take part only while the skill is active on that leg: from the `Skill` tool's successful
1027
+ activation (or prepare, for a skill in `userInvokedSkills`) to the end of the run; a refused activation opens nothing. A
1028
+ skill registration hangs `classic.*` events only (`config.mods_invalid`, naming the skill and the event). They are the
1029
+ leg's own: absent from the standing chain the leg hands its children (its screening constraint, its installation
1030
+ identity), and absent from "an effect-aware gate covers every call" (the ungated-write warning). A park records the active
1031
+ hook-carrying skills (`state.skillScopes`) and the resumed leg reopens them; a section that is not a list of names refuses
1032
+ the resume pre-claim (`checkpoint.invalid_outcome`, `reason: "skill_scopes_damaged"`). Before, a `mods` member on a skill
1033
+ was dropped by the skills seat's copy, without a word.
1034
+ - **Hard-breaking (store format; open to veto).** What changed: a park of a run whose hook-carrying skill was active writes
1035
+ `state.skillScopes` and the requirement word `skill_scopes`. Who is hurt: a worker older than 7.56.0 that meets such a row
1036
+ refuses it `version_newer` (rows of runs without such a skill are unchanged). How to migrate: finish those rows on a 7.56.0
1037
+ worker; roll workers forward before routing skill hooks to them.
1038
+ - **Differs from the reference (open to veto).** (1) The reference registers a skill's hooks under the session, so a skill a
1039
+ subagent activates governs the main thread; here a skill's hooks govern the leg that activated it. (2) The reference keeps a
1040
+ skill's hooks for the session; here they last for the run, as its `allowed-tools` grants do.
1041
+ - **Consumers.** @server: `SkillSpec` gains a key (the `Record<keyof SkillSpec, …>` field table reds by design) — the wire
1042
+ keeps `hooks` (frontmatter shape) and compiles it into `mods`; one registration object is admitted on every leg that loads
1043
+ the skill, so a `once` entry is keyed by the leg (`e.identity`), and nothing of it is carried across a park; the engine's
1044
+ word reds `ENGINE_CAPABILITY_FACTS` (the fact: this process compiles skill hooks on this lane). @client-core / @cli:
1045
+ project skills' frontmatter hooks into the request when the server's key is true; a skill the engine activates is the
1046
+ engine's to run. @sdk: `skills[].hooks` on the request type. @test: black-box cells in the pins below.
1047
+ - **Not in this slice.** `AgentDefinition.mods` (the agent seat, `mods.agent_seat`) — design/1627 §3.5, next slice.
1048
+ - **Pins.** `test/backlog1627-skill-seat.test.ts` (① a guard fires only after the activation; ② a person's invocation opens it
1049
+ at prepare; ③ an observer watches only after it; ④ a refused activation opens nothing; ⑤ a child is neither screened by nor
1050
+ dispatches the parent's skill hooks, and a parent task-seat handler judges the child's call once; ⑥ the ungated-write
1051
+ warning still fires; ⑦ the seat's refusals; ⑧ park → resume reopens, a plain skill is not recorded, a damaged section is
1052
+ refused; ⑨ the word).
1053
+
1
1054
  ## 7.55.0 — 2026-10-21
2
1055
 
3
1056
 
1057
+
4
1058
  > Rulings open to veto in this release that the sections above do not name by number: C-R676, C-R678, C-R682, C-R683, C-R684, C-R690, C-R712 (three notes), C-R713 — all in `sema-comms/audits/CLAY-RULINGS.md`; the ship post's veto table lists every ruling C-R674 → C-R717.
5
1059
  ### One reading of a workflow journal row read back from a store — `readWorkflowJournalEntry` — and the bundled journals refuse a row it finds corrupt, and the file journal a ledger line that is not a record (#1468 T3; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: +1 function, +1 constant, +1 type, `WorkflowJournalIncompatibleError` re-homed with its name and code unchanged; members: +1 static `WorkflowJournalIncompatibleError.ledgerLine`) (@server @client-core)
6
1060