@sema-agent/core 7.52.0 → 7.54.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 (357) hide show
  1. package/CHANGELOG.md +470 -0
  2. package/LICENSE +3 -1
  3. package/README.md +3 -1
  4. package/dist/agents/cross-session-envelope.d.ts +8 -0
  5. package/dist/agents/cross-session-envelope.js +6 -0
  6. package/dist/agents/cross-session-judge.d.ts +21 -1
  7. package/dist/agents/cross-session-judge.js +8 -0
  8. package/dist/agents/cross-session-ref.d.ts +5 -0
  9. package/dist/agents/cross-session-ref.js +1 -0
  10. package/dist/agents/org-delivery-gate.d.ts +122 -0
  11. package/dist/agents/org-delivery-gate.js +69 -0
  12. package/dist/agents/peer-held-queue.d.ts +9 -4
  13. package/dist/agents/peer-held-queue.js +6 -5
  14. package/dist/agents/peer-session-drain.d.ts +35 -6
  15. package/dist/agents/peer-session-drain.js +271 -182
  16. package/dist/agents/read-inbox-tool.d.ts +7 -0
  17. package/dist/agents/read-inbox-tool.js +5 -1
  18. package/dist/agents/send-message-tool.js +3 -4
  19. package/dist/agents/subagent.d.ts +6 -5
  20. package/dist/agents/subagent.js +117 -83
  21. package/dist/agents/tool-filter.d.ts +25 -4
  22. package/dist/agents/tool-filter.js +13 -3
  23. package/dist/brain/anthropic.d.ts +6 -1
  24. package/dist/brain/anthropic.js +4 -4
  25. package/dist/brain/model-presets.js +2 -1
  26. package/dist/brain/route-adjudicator.d.ts +6 -0
  27. package/dist/brain/routing.d.ts +24 -1
  28. package/dist/brain/routing.js +54 -19
  29. package/dist/brain/timeout.js +5 -4
  30. package/dist/core/a2a.d.ts +5 -2
  31. package/dist/core/a2a.js +33 -14
  32. package/dist/core/allow-entry.d.ts +3 -1
  33. package/dist/core/allow-entry.js +3 -2
  34. package/dist/core/approval-object.d.ts +87 -0
  35. package/dist/core/approval-object.js +85 -0
  36. package/dist/core/arg-summary.d.ts +3 -2
  37. package/dist/core/arg-summary.js +17 -11
  38. package/dist/core/ask-origin.d.ts +43 -27
  39. package/dist/core/ask-origin.js +20 -11
  40. package/dist/core/ask-question.d.ts +0 -1
  41. package/dist/core/ask-question.js +4 -67
  42. package/dist/core/ask-unresolvable-notice.d.ts +3 -2
  43. package/dist/core/auto-compaction.d.ts +106 -50
  44. package/dist/core/auto-compaction.js +69 -53
  45. package/dist/core/auto-mode-prompt.d.ts +4 -2
  46. package/dist/core/auto-mode-prompt.js +4 -3
  47. package/dist/core/canonical-json.d.ts +69 -0
  48. package/dist/core/canonical-json.js +130 -8
  49. package/dist/core/checkpoint-requirements.d.ts +13 -7
  50. package/dist/core/checkpoint-requirements.js +4 -1
  51. package/dist/core/checkpoint-store.d.ts +60 -36
  52. package/dist/core/checkpoint-store.js +7 -6
  53. package/dist/core/child-clamp-seats.d.ts +45 -6
  54. package/dist/core/child-clamp-seats.js +56 -4
  55. package/dist/core/compliance.js +3 -1
  56. package/dist/core/consolidate-scope.d.ts +1 -1
  57. package/dist/core/cross-boundary-taint.d.ts +213 -0
  58. package/dist/core/cross-boundary-taint.js +196 -0
  59. package/dist/core/decision-receipt.d.ts +44 -1
  60. package/dist/core/decision-receipt.js +16 -0
  61. package/dist/core/effective-path-target.d.ts +28 -6
  62. package/dist/core/effective-path-target.js +28 -2
  63. package/dist/core/engine-notice.d.ts +47 -4
  64. package/dist/core/engine-notice.js +17 -1
  65. package/dist/core/engine-storage-backend.d.ts +73 -0
  66. package/dist/core/engine-storage-backend.js +1 -0
  67. package/dist/core/file-history-store.d.ts +6 -2
  68. package/dist/core/file-history-store.js +1 -0
  69. package/dist/core/gate-fold.js +13 -4
  70. package/dist/core/gate-lanes.js +41 -34
  71. package/dist/core/gate-outcome.d.ts +18 -9
  72. package/dist/core/governance-codes.d.ts +9 -2
  73. package/dist/core/governance-codes.js +9 -0
  74. package/dist/core/hands-band-screen.d.ts +7 -0
  75. package/dist/core/hands-band-screen.js +32 -0
  76. package/dist/core/hooks.d.ts +21 -19
  77. package/dist/core/hooks.js +6 -5
  78. package/dist/core/locked-config.d.ts +110 -31
  79. package/dist/core/locked-config.js +155 -42
  80. package/dist/core/lsp.d.ts +1 -1
  81. package/dist/core/mcp-probe.js +1 -1
  82. package/dist/core/mcp-server-spec.d.ts +152 -17
  83. package/dist/core/mcp-server-spec.js +223 -40
  84. package/dist/core/mcp.d.ts +21 -14
  85. package/dist/core/mcp.js +59 -54
  86. package/dist/core/memory-engine/engine.d.ts +14 -19
  87. package/dist/core/memory-engine/engine.js +9 -6
  88. package/dist/core/memory-engine/export-bundle.d.ts +1 -1
  89. package/dist/core/memory-engine/export-bundle.js +1 -1
  90. package/dist/core/memory-engine/file-backend.d.ts +2 -6
  91. package/dist/core/memory-engine/file-backend.js +1 -12
  92. package/dist/core/memory.d.ts +3 -3
  93. package/dist/core/mode-policy.d.ts +16 -26
  94. package/dist/core/mode-policy.js +5 -18
  95. package/dist/core/mods/kernel/dispatch.js +13 -15
  96. package/dist/core/mods/kernel/events.d.ts +9 -6
  97. package/dist/core/mods/kernel/events.js +2 -2
  98. package/dist/core/mods/kernel/receive.d.ts +41 -11
  99. package/dist/core/mods/kernel/receive.js +124 -54
  100. package/dist/core/mods/kernel/site-rules.d.ts +80 -15
  101. package/dist/core/mods/kernel/site-rules.js +232 -40
  102. package/dist/core/mods/stations.d.ts +0 -6
  103. package/dist/core/mods/stations.js +0 -7
  104. package/dist/core/mounted-mcp.d.ts +57 -0
  105. package/dist/core/mounted-mcp.js +71 -2
  106. package/dist/core/own-property-receipt.d.ts +13 -0
  107. package/dist/core/own-property-receipt.js +3 -1
  108. package/dist/core/parent-spec-seat.d.ts +12 -15
  109. package/dist/core/parent-spec-seat.js +14 -6
  110. package/dist/core/pause-registry.d.ts +35 -1
  111. package/dist/core/pause-registry.js +10 -0
  112. package/dist/core/permission-rule-consent.d.ts +18 -5
  113. package/dist/core/permission-rule-consent.js +13 -8
  114. package/dist/core/permission-rule-model.d.ts +87 -15
  115. package/dist/core/permission-rule-model.js +87 -8
  116. package/dist/core/permission-rule-org.d.ts +200 -33
  117. package/dist/core/permission-rule-org.js +217 -91
  118. package/dist/core/permission-rule-session.js +2 -2
  119. package/dist/core/permission-rule-store.d.ts +8 -0
  120. package/dist/core/permission-rule-store.js +1 -0
  121. package/dist/core/permission-rule-sync.js +3 -2
  122. package/dist/core/permission-rules.d.ts +7 -1
  123. package/dist/core/permission-rules.js +37 -8
  124. package/dist/core/read-only-shell.d.ts +1 -1
  125. package/dist/core/read-only-shell.js +30 -9
  126. package/dist/core/remote-env.d.ts +41 -1
  127. package/dist/core/remote-env.js +18 -0
  128. package/dist/core/resume-startup-cause.d.ts +18 -0
  129. package/dist/core/resume-startup-cause.js +12 -0
  130. package/dist/core/retention.js +7 -2
  131. package/dist/core/retired-keys.d.ts +8 -2
  132. package/dist/core/retired-keys.js +12 -1
  133. package/dist/core/roles.js +60 -10
  134. package/dist/core/rule-lane-reads.js +1 -1
  135. package/dist/core/runner/approval-feedback.d.ts +46 -0
  136. package/dist/core/runner/approval-feedback.js +41 -0
  137. package/dist/core/runner/compaction-call-options.d.ts +3 -3
  138. package/dist/core/runner/compaction-call-options.js +2 -2
  139. package/dist/core/runner/contracts.d.ts +34 -41
  140. package/dist/core/runner/cross-boundary-lane.d.ts +87 -0
  141. package/dist/core/runner/cross-boundary-lane.js +190 -0
  142. package/dist/core/runner/decide-continuation.d.ts +4 -4
  143. package/dist/core/runner/decide-continuation.js +3 -1
  144. package/dist/core/runner/gate-exit.d.ts +32 -12
  145. package/dist/core/runner/gate-exit.js +77 -34
  146. package/dist/core/runner/inherited-ask-grants.js +2 -2
  147. package/dist/core/runner/mcp-redial.d.ts +52 -10
  148. package/dist/core/runner/mcp-redial.js +38 -9
  149. package/dist/core/runner/permission-rule-lanes.d.ts +5 -2
  150. package/dist/core/runner/permission-rule-lanes.js +22 -10
  151. package/dist/core/runner/person-path-rows.d.ts +5 -3
  152. package/dist/core/runner/person-path-rows.js +2 -2
  153. package/dist/core/runner/prepare-acquire-reconcile.d.ts +3 -4
  154. package/dist/core/runner/prepare-acquire-reconcile.js +3 -3
  155. package/dist/core/runner/prepare-ask-lane.js +3 -3
  156. package/dist/core/runner/prepare-caps-and-workflow.d.ts +6 -5
  157. package/dist/core/runner/prepare-caps-and-workflow.js +3 -3
  158. package/dist/core/runner/prepare-config-doors.d.ts +58 -15
  159. package/dist/core/runner/prepare-config-doors.js +167 -146
  160. package/dist/core/runner/prepare-context-lane.js +6 -3
  161. package/dist/core/runner/prepare-defer-classify.js +4 -7
  162. package/dist/core/runner/prepare-delegation-surface.d.ts +5 -4
  163. package/dist/core/runner/prepare-delegation-surface.js +8 -7
  164. package/dist/core/runner/prepare-execution-env.d.ts +1 -2
  165. package/dist/core/runner/prepare-execution-env.js +2 -1
  166. package/dist/core/runner/prepare-gate-stations.d.ts +2 -0
  167. package/dist/core/runner/prepare-gate-stations.js +4 -3
  168. package/dist/core/runner/prepare-hands-readface.d.ts +3 -3
  169. package/dist/core/runner/prepare-hands-readface.js +3 -2
  170. package/dist/core/runner/prepare-inherited-gate.d.ts +2 -0
  171. package/dist/core/runner/prepare-inherited-gate.js +3 -2
  172. package/dist/core/runner/prepare-listings.d.ts +1 -1
  173. package/dist/core/runner/prepare-listings.js +4 -2
  174. package/dist/core/runner/prepare-memory-engine-session.d.ts +0 -6
  175. package/dist/core/runner/prepare-memory-engine-session.js +19 -6
  176. package/dist/core/runner/prepare-memory.d.ts +3 -2
  177. package/dist/core/runner/prepare-memory.js +6 -2
  178. package/dist/core/runner/prepare-offload-wrappers.d.ts +4 -4
  179. package/dist/core/runner/prepare-offload-wrappers.js +2 -1
  180. package/dist/core/runner/prepare-park-ask.d.ts +1 -1
  181. package/dist/core/runner/prepare-park-ask.js +9 -3
  182. package/dist/core/runner/prepare-policy-chain.d.ts +1 -1
  183. package/dist/core/runner/prepare-policy-chain.js +14 -19
  184. package/dist/core/runner/prepare-project-context.d.ts +1 -1
  185. package/dist/core/runner/prepare-project-context.js +6 -4
  186. package/dist/core/runner/prepare-prompt-assembly.d.ts +1 -1
  187. package/dist/core/runner/prepare-prompt-assembly.js +20 -16
  188. package/dist/core/runner/prepare-prompt-inputs.d.ts +12 -6
  189. package/dist/core/runner/prepare-prompt-inputs.js +6 -3
  190. package/dist/core/runner/prepare-protocol-tools.d.ts +7 -5
  191. package/dist/core/runner/prepare-protocol-tools.js +19 -12
  192. package/dist/core/runner/prepare-question-face.d.ts +4 -2
  193. package/dist/core/runner/prepare-question-face.js +4 -3
  194. package/dist/core/runner/prepare-safety-scan.d.ts +7 -2
  195. package/dist/core/runner/prepare-safety-scan.js +34 -27
  196. package/dist/core/runner/prepare-suspend-saga.d.ts +2 -2
  197. package/dist/core/runner/prepare-suspend-saga.js +6 -2
  198. package/dist/core/runner/prepare-task.js +22 -19
  199. package/dist/core/runner/prepare-turn-wiring.d.ts +10 -5
  200. package/dist/core/runner/prepare-turn-wiring.js +14 -49
  201. package/dist/core/runner/prepare-wiring-manifest.d.ts +10 -5
  202. package/dist/core/runner/prepare-wiring-manifest.js +9 -6
  203. package/dist/core/runner/prepare-workspace-restore.js +2 -2
  204. package/dist/core/runner/resume-admission.d.ts +4 -1
  205. package/dist/core/runner/resume-admission.js +16 -14
  206. package/dist/core/runner/resume-apply.js +4 -4
  207. package/dist/core/runner/resume-internals-and-config.d.ts +3 -3
  208. package/dist/core/runner/resume-internals-and-config.js +3 -8
  209. package/dist/core/runner/resume-policy-outcome.d.ts +2 -2
  210. package/dist/core/runner/resume-policy-outcome.js +18 -15
  211. package/dist/core/runner/resume-preflight.js +2 -2
  212. package/dist/core/runner/resume-review-outcome.js +3 -3
  213. package/dist/core/runner/retired-runner-deps.d.ts +5 -6
  214. package/dist/core/runner/retired-runner-deps.js +1 -4
  215. package/dist/core/runner/run-leg.js +97 -43
  216. package/dist/core/runner/run-recovery-lanes.js +5 -3
  217. package/dist/core/runner/run-settle-and-teardown.js +2 -0
  218. package/dist/core/runner/run-terminal-adoption.js +21 -9
  219. package/dist/core/runner/run-turn-boundary.js +9 -3
  220. package/dist/core/runner/runtask.js +1 -1
  221. package/dist/core/runner/session-file-state-replay.d.ts +5 -2
  222. package/dist/core/runner/session-file-state-replay.js +3 -2
  223. package/dist/core/runner/skills-seat.d.ts +15 -0
  224. package/dist/core/runner/skills-seat.js +57 -0
  225. package/dist/core/runner/stream-steer-verb.js +2 -2
  226. package/dist/core/runner/sub-agent-deny-lists.d.ts +13 -11
  227. package/dist/core/runner/sub-agent-deny-lists.js +7 -5
  228. package/dist/core/runner/synthetic-tools.d.ts +5 -2
  229. package/dist/core/runner/synthetic-tools.js +2 -12
  230. package/dist/core/runner/tool-face-overlay.d.ts +24 -6
  231. package/dist/core/runner/tool-face-overlay.js +24 -15
  232. package/dist/core/runner-deps.d.ts +37 -22
  233. package/dist/core/session-policy-store.d.ts +19 -0
  234. package/dist/core/session-policy-store.js +22 -2
  235. package/dist/core/session-store.d.ts +35 -16
  236. package/dist/core/session-store.js +20 -12
  237. package/dist/core/{shell-lexer.d.ts → shell-run-candidates.d.ts} +36 -12
  238. package/dist/core/{shell-lexer.js → shell-run-candidates.js} +145 -225
  239. package/dist/core/shell-wrapper-table.d.ts +5 -3
  240. package/dist/core/shell-wrapper-table.js +1 -1
  241. package/dist/core/store-contracts/checkpoint-store-contract.js +2 -2
  242. package/dist/core/store-contracts/mailbox-store-contract.js +13 -0
  243. package/dist/core/store-contracts/session-store-contract.d.ts +49 -0
  244. package/dist/core/store-contracts/session-store-contract.js +639 -0
  245. package/dist/core/store-contracts/session-store-incarnation-contract.d.ts +12 -0
  246. package/dist/core/store-contracts/session-store-incarnation-contract.js +5 -2
  247. package/dist/core/task-event.d.ts +44 -6
  248. package/dist/core/task-notification.js +2 -2
  249. package/dist/core/task-registry-agent.js +1 -0
  250. package/dist/core/task-registry-shared.d.ts +5 -0
  251. package/dist/core/task-registry.d.ts +7 -0
  252. package/dist/core/task-registry.js +17 -0
  253. package/dist/core/task-result.d.ts +1 -1
  254. package/dist/core/task-spec.d.ts +7 -7
  255. package/dist/core/tighten-task-spec.d.ts +6 -4
  256. package/dist/core/tighten-task-spec.js +2 -2
  257. package/dist/core/tool-catalog-entries.d.ts +1 -1
  258. package/dist/core/tool-catalog-entries.js +52 -52
  259. package/dist/core/tool-face-reader.d.ts +49 -0
  260. package/dist/core/tool-face-reader.js +94 -0
  261. package/dist/core/tool-face.d.ts +17 -8
  262. package/dist/core/tool-face.js +1 -0
  263. package/dist/core/tool-policy.d.ts +67 -23
  264. package/dist/core/tool-policy.js +72 -60
  265. package/dist/core/tool-roster.d.ts +46 -28
  266. package/dist/core/tool-roster.js +91 -14
  267. package/dist/core/tool-spec-members.js +2 -2
  268. package/dist/core/tool-spec.d.ts +20 -4
  269. package/dist/core/tools.js +2 -0
  270. package/dist/core/trace.d.ts +24 -2
  271. package/dist/core/types.d.ts +3 -3
  272. package/dist/core/types.js +1 -1
  273. package/dist/core/untrusted-text.d.ts +23 -0
  274. package/dist/core/untrusted-text.js +10 -10
  275. package/dist/core/user-attachment.d.ts +56 -2
  276. package/dist/core/user-attachment.js +139 -56
  277. package/dist/core/wiring-manifest.d.ts +4 -2
  278. package/dist/engine/compaction/compaction.d.ts +2 -2
  279. package/dist/engine/compaction/utils.d.ts +2 -2
  280. package/dist/engine/harness/agent-harness.js +2 -1
  281. package/dist/engine/harness/types.d.ts +10 -4
  282. package/dist/engine/llm/types.d.ts +7 -2
  283. package/dist/engine/llm/types.js +2 -1
  284. package/dist/engine/loop/agent-loop.d.ts +12 -1
  285. package/dist/engine/loop/agent-loop.js +17 -5
  286. package/dist/engine/loop/types.d.ts +7 -1
  287. package/dist/engine/session/repo-utils.d.ts +5 -0
  288. package/dist/engine/session/repo-utils.js +9 -0
  289. package/dist/engine/session/storage-base.js +2 -2
  290. package/dist/index.d.ts +37 -17
  291. package/dist/index.js +31 -14
  292. package/dist/internal/harness.d.ts +1 -0
  293. package/dist/internal/harness.js +1 -0
  294. package/dist/internal/llm.d.ts +1 -1
  295. package/dist/internal/llm.js +1 -1
  296. package/dist/orchestration/goal.d.ts +24 -1
  297. package/dist/orchestration/goal.js +3 -2
  298. package/dist/orchestration/run-workflow-tool.d.ts +3 -0
  299. package/dist/orchestration/run-workflow-tool.js +1 -0
  300. package/dist/orchestration/workflow-governance.d.ts +1 -1
  301. package/dist/orchestration/workflow.d.ts +3 -0
  302. package/dist/orchestration/workflow.js +2 -1
  303. package/dist/prompt-assembly/artifact-store.d.ts +44 -9
  304. package/dist/prompt-assembly/artifact-store.js +38 -12
  305. package/dist/prompt-assembly/artifact.d.ts +12 -11
  306. package/dist/prompt-assembly/artifact.js +52 -21
  307. package/dist/prompt-assembly/assemble.d.ts +2 -0
  308. package/dist/prompt-assembly/assemble.js +2 -1
  309. package/dist/prompt-assembly/catalog/index.js +2 -1
  310. package/dist/prompt-assembly/catalog/memory.d.ts +13 -0
  311. package/dist/prompt-assembly/catalog/memory.js +6 -0
  312. package/dist/prompt-assembly/catalog/schema.d.ts +19 -2
  313. package/dist/prompt-assembly/catalog/schema.js +3 -0
  314. package/dist/prompt-assembly/composer.d.ts +2 -0
  315. package/dist/prompt-assembly/composer.js +1 -1
  316. package/dist/prompt-assembly/overlay.d.ts +21 -11
  317. package/dist/prompt-assembly/overlay.js +57 -19
  318. package/dist/prompt-assembly/select.d.ts +21 -6
  319. package/dist/prompt-assembly/select.js +41 -4
  320. package/dist/prompts/default.d.ts +1 -12
  321. package/dist/prompts/default.js +0 -3
  322. package/dist/shell/lex.d.ts +87 -8
  323. package/dist/shell/lex.js +283 -49
  324. package/dist/stores/file/index.d.ts +8 -6
  325. package/dist/stores/file/permission-rule-store.d.ts +1 -1
  326. package/dist/stores/file/permission-rule-store.js +2 -1
  327. package/dist/tools/artifact/artifact-tool.js +14 -3
  328. package/dist/tools/fs/bash-program-position.d.ts +3 -15
  329. package/dist/tools/fs/bash-program-position.js +3 -10
  330. package/dist/tools/fs/bash-readonly-classifier.js +2 -0
  331. package/dist/tools/fs/fs-bash.d.ts +3 -3
  332. package/dist/tools/fs/fs-bash.js +72 -101
  333. package/dist/tools/fs/fs-read.d.ts +12 -0
  334. package/dist/tools/fs/fs-read.js +10 -9
  335. package/dist/tools/fs/fs-shared.d.ts +27 -31
  336. package/dist/tools/fs/fs-shared.js +12 -15
  337. package/dist/tools/fs/fs-write.js +1 -1
  338. package/dist/tools/fs/index.js +2 -2
  339. package/dist/tools/fs/read-deny-home.d.ts +1 -1
  340. package/dist/tools/fs/read-deny-home.js +5 -4
  341. package/dist/tools/fs/read-deny.d.ts +16 -3
  342. package/dist/tools/fs/read-deny.js +12 -9
  343. package/dist/tools/fs/safety.d.ts +7 -6
  344. package/dist/tools/fs/shell-read-bindings.d.ts +5 -3
  345. package/dist/tools/fs/shell-read-bindings.js +15 -16
  346. package/dist/tools/fs/shell-read-device.js +1 -1
  347. package/dist/tools/fs/shell-read-plan.js +36 -47
  348. package/dist/tools/fs/shell-read-word.d.ts +1 -6
  349. package/dist/tools/fs/shell-read-word.js +2 -20
  350. package/dist/tools/fs/shell-reentry-text.js +1 -1
  351. package/dist/tools/web.d.ts +2 -2
  352. package/package.json +4 -1
  353. package/test/export-surface.snapshot.json +5651 -5457
  354. package/wire-changes/core@7.53.0.json +1968 -0
  355. package/wire-changes/core@7.54.0.json +2870 -0
  356. package/dist/core/shell-scan.d.ts +0 -60
  357. package/dist/core/shell-scan.js +0 -183
package/CHANGELOG.md CHANGED
@@ -1,3 +1,473 @@
1
+ ## 7.54.0 — 2026-10-19
2
+
3
+ ### A table of the faces kept alive for compatibility, a gate that keeps it honest, and one ruler per word (#1336, #2100; release tooling and src comments only; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
4
+
5
+ - **What changed.** (1) `docs/RETIREMENT-LINES.json` lists every face that lives on only for compatibility, one row per face: what it is (`target`, `kind`), where it is still declared (`presence`: a file and a declaration-shaped pattern, read after comments are stripped), the one current reading, the last version allowed to ship it (`retireBy`) and the ticket. 30 rows today: the TaskOutput tool (export, env factory, two catalog rows, five retired-name hints), the old `MemoryStore` family (`MemoryStore`, `consolidateScope`, `runMemoryConsolidation`, the memory ledger v1 bare-map reader), the memory-root anchor backfill arm, the v1–v9 checkpoint number ladder, and fifteen smaller members and exports (`rewindFiles`, `ImportPreview.translated`, `ARTIFACT_CARD`, the `details.matched` list, the `finalVerifyInjected` boolean, `MEMORY_GUIDANCE`, `CODE_SYSTEM_PROMPT`, `memoryConsolidationMicroUsd`, the never-emitted `post-clear` word in two unions, the ignored `mark` parameter of `stripGitStatusUnits`, the journal cap re-export, `CheckpointStore.listByScope?`, `createWebFetchTool`, the bare `string | null` return of `loadProjectMemory`). The 65 names of the teacher / team / stores-cc / scenarios / built-in-workflow families that the table's design listed are all gone from `src/` (v7.49.0) and have no row. (2) `gate:retirement-lines` (static tier of `gate:all`) judges the table: a row whose face is gone is red (stale: the commit that deletes the face deletes its row); a face still there although a release at or past its line has shipped is red in every mode (broken: re-window with a reason, or delete); a face whose line is the version being prepared is a warning, and red under `gate:retirement-lines:rc` (rc tier). The version being prepared is `--version X.Y.Z`, else the first heading of `CHANGELOG.md` when it is a release heading — never `package.json`, which on a release candidate still holds the previous version; `--rc` with neither COULD NOT JUDGE: exit 2 with the refusal marker, so `gate:all` prints it on its COULD NOT JUDGE line (it names `--version X.Y.Z` and a numeric `## X.Y.Z` heading as the two ways out); exit 1 is kept for a judged finding such as a due row that is present. (3) The same gate scans `src/` comments for a closed vocabulary of survival claims (`back-compat`, `backward-compat(ible)`, `for compatibility`, `one minor`, `compat shim`, `compat seat` / `arm` / `window`): each must name its row (`RL-<id>`) in the same comment or sit in an exemption of the table, keyed by file and a verbatim fragment of the comment (never a line number), of class `false-positive` (with the reason) or `cc-declared` (with the parity-register entry, checked wherever the register is visible). A comment citing a row that does not exist is red; an exemption that matches no comment is red. 45 claims today: 20 governed by a row, 25 exempted. (4) One ruler per word: the count ratchet in `test/legacy-residue-gate.test.ts` keeps `legacy`, `pre-rename` and the Chinese word for compatibility, and no longer counts `backward-compat`; its regex now comes from the gate script (`RATCHET_NARRATIVE_RE`), and the gate re-proves on every run that no survival claim is visible to it. Baselines re-frozen to the measurement without the word (of the 11 lines that left, 10 are governed by the scan — a row or an exemption — and the eleventh, `prepare-memory.ts:295`, was reworded: it called a one-object type a union): `src/agents` 16→15, `src/core` 105→100, `src/core/runner` 59→53 (merged tree; 55 before the #1391 note removals), `src/engine/harness` 5→4. `legacy arm` is not in the scan's vocabulary because it contains `legacy`, which stays with the ratchet.
6
+ - **Comments made true (no behavior).** `LspServerManager.sessionFor`'s `env` and `WebSearchConfig.search`'s third parameter are optional by contract, not for an older implementation, and now say so; the memory-root backfill arm's comment no longer states a deletion version of its own (the row does); `prepareMemory` no longer calls the memory spec a union. Twenty comments gained an `RL-<id>`.
7
+ - **Who is hurt.** Nobody at runtime: `src/` changes are comments. A change that adds a survival-claim sentence without a row or an exemption, or deletes a listed face without deleting its row, now fails `gate:all`; a release candidate fails `gate:retirement-lines:rc` while a face is still listed at or before the version being cut.
8
+ - **Not in this release.** The deletions the rows promise (`#2100` carries the memory-root arm, the member rows, `listByScope?`, `createWebFetchTool`, the bare `loadProjectMemory` return and the `rewindFiles` merge into the retired-key refusal; `#1608`, `#1654`, `#1333` carry theirs). The full enumeration of the old `MemoryStore` family beyond the three exported names above belongs to `#1654`'s design.
9
+ - **Pins.** `test/retirement-lines-gate.test.ts` (the shipped table green on the shipped tree; an earliest-line probe red under `--rc`; due, stale, comment-only presence, broken, half-deleted family, unreadable file, bad values; survival claims governed, dangling, exempted, stale; the word division and its before-the-division negative control; the CLI reads the CHANGELOG heading, never `package.json`); `test/gate-all-roster.test.ts` (both gates seated); `test/legacy-residue-gate.test.ts` (re-frozen).
10
+
11
+ ### (#469 r3 residuals, #2061) A resume leg tells the execution-env factory which workspace it is rebuilding (`ExecutionEnvFactoryContext.resumeWorkspace`), and a file op a target refused before commit has its own word (`FileErrorCode` `target_unavailable`) (#469; additive; behavior change, open to veto: a file write refused with `target_unavailable` is read as provably unwritten by the Write / Edit / NotebookEdit lanes; behavior narrowing: none at runtime, the contract text for a target-bound factory is tightened; hard-breaking: none; closed sets: `FileErrorCode` +`target_unavailable` (a type-only union; types: `FileErrorCode` +1); exports: none; members: +1 optional `ExecutionEnvFactoryContext.resumeWorkspace`) (@server @client-core)
12
+
13
+ - **Before.** A device-bound factory learned of a resume only by `sessionId` / `placementRootSessionId`, so it looked the placement's CURRENT binding up and connected whatever device that named; the engine compared the minted env's handle against the checkpointed one afterwards and refused a different `provider` or `deviceId` (`resume.env_failed`) — after the wrong device was already connected. The file face had no never-started word: an adapter whose target refused a file op before commit had to land it on `transport_lost` ("the connection dropped mid-session"), which a retry whitelist reads as an op that may have run.
14
+ - **Now.** (1) On a durable resume leg the factory's ctx carries `resumeWorkspace`: a frozen copy holding exactly the members a handle declares (`WORKSPACE_HANDLE_MEMBERS`, one list keyed by the interface: `sandboxId`, `provider`, `mountPath`, `snapshotId`, `sessionToken`, `restoreMode`, `deviceId`) that the checkpoint row carries as strings. Every member is a string, so the freeze is deep; nothing else on the stored object is copied (a decoration an adapter put on its own handle), and there is no structured-clone step: a handle a custom store hands back as a Proxy no longer fails the resume with a code-less `DataCloneError`. The key is absent, not an `undefined` value, on a fresh run and on a resume whose row recorded no handle. A factory bound to a target MUST mint on `resumeWorkspace.deviceId` and THROW when it cannot; it must not connect another device or re-read the binding. **The minted env's `workspaceHandle()` MUST report the device its connection actually reached and is never copied from `resumeWorkspace`.** The engine's comparison (`provider` equal; `deviceId` equal whenever the row carries one) is unchanged and is the receipt check, and it trusts that report: a factory that connects elsewhere and reports so is refused with `resume.env_failed`, the approved action does not run, and the row is reopened when the store can; a factory that echoes the fence into its env's handle while connecting elsewhere defeats its own protection and passes (the contract's stated limit, pinned). A factory's own typed refusal surfaces as the failed terminal's code unchanged (e.g. `device.rebind_mismatch`), the row reopened the same way. (2) `FileErrorCode` gains `target_unavailable`: the op was refused before commit (offline / busy / not bound), never started, no effect landed, a retry is safe once the target is back — the exec face's word of the same name, on the file face. The Read chain and the Bash dead-cwd probe treat it as every non-death code: the adapter's sentence rides unchanged and the probe reads it as unknown, never as a gone directory. (3) **Behavior change (open to veto).** The write lane's list of codes whose contract says nothing was written (`NOTHING_WAS_WRITTEN`, `fs-write.ts`: `already_exists`, `precondition_failed`) gains `target_unavailable`, so a file write the target refused before commit retracts its first-touch history record on the code's own guarantee and counts no edited notice. Before, a code outside the list took the evidence arm, which re-reads the path through the same unreachable target: the read failed, the record was kept, and a rewind then deleted a file someone else had since created at that path. Who is affected: an adapter that returns `FileError("target_unavailable")` from a write (none did before: the word was not a `FileErrorCode` member and the device adapter mapped it to `transport_lost`); its records and `editedFiles` entries for a refused write are no longer kept.
15
+ - **Behavior narrowing: none at runtime.** The fence moves no refusal earlier than it was: it is read after the claim, so the only pre-claim defence stays `RunnerDeps.resumePreflight`. A window remains between a passing preflight and the factory's mint in which the device can be re-bound; a conforming factory refuses there, the refusal lands in the post-claim compensation (the row reopens on a store with `reopen`, stays consumed on one without) and its code is the factory's own, not necessarily `resume.env_failed`. The engine writes no file-face prose per code, so `target_unavailable` and `transport_lost` read alike in the Read / Bash answers; the difference is the code a retry decision reads.
16
+ - **Who breaks.** (a) Type only: code that exhausts `FileErrorCode` alone — a `Record<FileErrorCode, …>` or a `switch` ending in a `never` check — stops compiling until the new word has a row (a table over `FileErrorCode ∪ ExecutionErrorCode` does not move: the exec face already carried the word; `sema-server` `device-ws-protocol.ts` is such a table). Runtime values are unchanged for every existing adapter. (b) A factory or test that deep-equals or enumerates the ctx on a RESUME leg sees one more key. (c) A factory that logs its ctx wholesale writes the checkpointed handle on a resume leg, `sessionToken` included.
17
+ - **How to migrate.** *server*: the device factory reads `ctx.resumeWorkspace?.deviceId`, mints on that device, and throws a typed `device.rebind_*` refusal when the bound device differs or is unreachable; `DEVICE_OUTCOME_TO_FILE_ERROR.target_unavailable` (`remote-env-device.ts`) and the transient rows of `DEVICE_REJECT_TO_FILE_ERROR` move from `transport_lost` to `target_unavailable`; redact `sessionToken` where ctx is logged. *client-core*: add `target_unavailable` to the mirrored `FileErrorCode`. *cli*: nothing.
18
+ - **Pins.** `test/design469-r3-fence.test.ts` (F1–F7: the fence's members, a Proxy row, the echoing factory's stated limit); `test/design469-r3-file-target-unavailable.test.ts` (T0–T3); `test/failopen-governance-core.test.ts` (the exhaustive table gains the row); `test/hands-track-edit.test.ts` (#469: a `target_unavailable` create keeps no record, reads no proof, counts no edit, and a rewind spares a later file; control `transport_lost`).
19
+
20
+ ### `readToolFaces(declared)` is the one reader of a `toolFaces` declaration and `readDenyRowAnchor(pattern)` the one judge of where a read-deny row is anchored, both exported from the package root (#1973, #2042; additive exports; hard-breaking: none; closed sets: none; exports: +2 functions; members: none) (@server @cli @client-core @settings-schema)
21
+
22
+ - **Before.** The engine judged a `toolFaces` declaration (`McpServerSpec.toolFaces`) only at the mount, against the tool's materialized schema (`toolFaceProblem`), and that judge assumes a well-typed face; the structure of a declaration a host assembles from JSON — the members, the kind of each, the five closed words (`family`, `pathTarget.access` / `base` / `absent`, `renderHints.approvalCard`) — was read by each host for itself (server `mcp-tool-faces.ts`, beside a mirror of the engine's tables). The engine's own door did not ask any reader of it either: it judged `toolAxes` through `readToolAxes` and left `toolFaces` to the mount, so the engine read the declaration a second way, at the listing, off the caller's raw object — a mistyped member (`pathTarge`) or a safety axis written as a face member (`effect`) was dropped in silence and the tool mounted without the protection the operator declared; a flag of the wrong kind was stamped as written; `aliases: "file"` threw `values.find is not a function` out of prepare (#2079); an empty tool name was announced inert (`config.tool_face_undeclared`). Where a read-deny row is anchored was answered by two predicates read together (`isRootedReadDenyPattern` and `readDenyPatternRun(p).homeAnchored`), written out at each of four readers, neither exported (and the auto-mode classifier's deny lines asked the home word alone, so a rooted row `/etc/shadow` was stated to the model as `Read(//**/etc/shadow)`, matched anywhere): a host judging a served or hot-issued row could only look at its first character.
23
+ - **Now.** (1) `readToolFaces(declared)` answers `{ ok: true, faces? } | { ok: false, problem }`, the face-side sibling of `readToolAxes`, and the two share their receipt step and their refusal sentences (`receivePerToolDeclaration`). The declaration is received as plain data by the engine's one receipt (a Proxy, a map, a class instance, a record of another realm, a getter that throws, a cycle, a nesting past 1 000 levels, a tool named `__proto__` are refusals that say where they sit — the same words `readToolAxes` uses), each face is typed whole (the members the types have, each of its kind; a typo is a refusal, never a dropped member; `pathTarget.skillScopeEligible: false` is the absence its type means), then judged by the mount's own judge with the checks that need the schema left out — the five closed words, the bounds, the cross-member rules. The checks that read the schema (a `param` that is a top-level property, the keys an approval card needs, the names a summary or a rule face lists) stay with the listing. One face that does not fit refuses the whole declaration; an empty record declares nothing. The answer is a plain copy that keeps the order the members were written in. The engine's door asks it exactly as it asks `readToolAxes` (`judgeEntryToolFaces`, beside `judgeEntryToolAxes`): for every MCP server of a task (a declaration that does not fit refuses the whole prepare, `config.tool_face_invalid` — see the #2093 section below — subject `MCP server "<name>"`, before any server is dialed) and per server at the library entrance (`materializeMcpTools` and the no-run probe: that server's dial fails, `invalid_config`, its siblings unaffected); the entry then carries the reader's frozen copy, and the mount (`applyMcpToolFaces`) consumes that copy, never the caller's object. The card-namespace rule (a tool's `resultCards` live under its own `<cardId>/…`) is now the face judge's own rule, for an MCP server's face (`mcp:<server>/<tool>/…`, asked at the listing, where the minted tool is) as for a caller's (`toolFaceProblem` takes the tool's `cardId`): one function judges both. The refusal sentences are the mount judge's own words and may quote only the structure of the declaration — a tool or member name, the word a closed table refuses, a key over its bound (its first 40 characters), a count and its bound — never display text or a list that fits (the `ToolFacesRead` doc). (2) `readDenyRowAnchor(pattern)` answers `"root"` / `"home"` / `"none"`; it states the rooted rule once (a leading `/` that is no home row), the canonical spelling asks it, and so do the compiler (placement and linked placements), the toolkit's "has anything to place" test, the mount's row-head reading and the classifier's deny lines (a rooted row is stated at the root, `Read(//etc/shadow)`, not as matched anywhere) — `isRootedReadDenyPattern` (a module function, never a root export) is retired. The two write tables that refuse the home word because they place no row (`write-protect.ts`, `sensitive-path-policy.ts`) ask whether a name opens with the home word itself, `readDenyPatternRun`, and stay. (3) `readToolFaces` is declared in `src/core/tool-face-reader.ts`, beside the declaration vocabulary rather than in the runner subtree where the mount lives; the public surface tiers a symbol by its declaring file, so it is `advanced` like `readToolAxes` (it was `internal` in the file where the mount is). A spelling and its canonical form give one answer: `//etc` and `/etc` are `"root"`, `/~/x` is `~/x`, `"home"`.
24
+ - **Not folded in.** `effect` / `egress` / `irreversibility` are `toolAxes`' members, read once, by `readToolAxes`; a face has no such member and `readToolFaces` refuses one as an unknown member. The ticket's "five vocabularies" are the face's five closed tables, not those axes.
25
+ - **Behavior narrowing (open to veto).** The row-anchor judge moves nothing (every row the engine mints compiles, places and mints the same text: `test/backlog2042-read-deny-row-anchor.test.ts` A2); the face reading does. (1) **What changed:** the engine refuses at its door what it used to take in silence, announce at the listing, or choke on — for a task's `TaskSpec.mcp` (the whole prepare, `config.tool_face_invalid`) and for `materializeMcpTools` / the no-run probe (that server's dial, `invalid_config`): (a) a mistyped member, a safety axis written as a face member, a flag that is not a boolean (base: the tool mounted without the declared member, or with the flag stamped as written); (b) a member of the wrong kind — `aliases: "file"`, a string `summaryParams`, a null face (base: a TypeError out of prepare, #2079); (c) an empty tool name (base: `config.tool_face_undeclared` announced, every tool mounted); (d) every fault the mount's judge refused per tool at the listing — a word outside a closed table, a write path target on `base: "root"` or `absent: "base"`, an inert `patternParam`, a member over its bound, a reserved approval card (base: that tool is not mounted and `config.tool_face_invalid` is announced, the server's other tools mount; head: the whole prepare is refused). At the listing, an MCP face whose `resultCards` name another namespace (`builtin:Edit/…`, another server's, a bare name) is refused as a caller's is — the tool is not mounted, `config.tool_face_invalid` (base: stamped as written, and its cards joined the leg's projector allow set). A written `skillScopeEligible: false` is the absence its type means, and an empty `toolFaces: {}` leaves no key on the entry. A valid declaration mounts exactly as before, byte for byte in the face columns (full, members in another order, partly empty, none: `.gate-logs/car-1973-2042/cx/door-*.txt` in the receipt). The auto-mode classifier states a rooted read-deny row at the root (`Read(//etc/shadow)`; before, `Read(//**/etc/shadow)`, wider than the row). (2) **Who is hurt:** a deployment whose `toolFaces` carries any of the above — it was half-declared before and mounted without (part of) its protection, or lost tools at the listing; its tasks are refused until the declaration is fixed, and nothing mounts without the protection it declared. A host that keeps its own intake reader meets the same refusals one step later, at the engine's door. A classifier prompt that listed a rooted row gains a narrower line: it names the declared location only, as a home-anchored row's always did — where the row's head is a link the compiler also denies the resolved location, which the line does not state (the old `Read(//**/…)` line reached it only when the link target happened to end in the same segments, and over-stated the row everywhere else; the engine enforces the compiled row either way). (3) **What compensates:** the sentence names the server, the tool and the member (and the word a closed table refuses), in the words the mount and the host reader use; a host that asks `readToolFaces` at its own door refuses before a task is built.
26
+ - **Who is hurt (otherwise).** Nobody else. `isRootedReadDenyPattern` was reachable from no published subpath. The type of the answer is not a second export (`ReturnType<typeof readToolFaces>`).
27
+ - **How to migrate.** *server*: `readMcpToolFaces` (`mcp-tool-faces.ts`) becomes `readToolFaces` (same answer shape, `faces`; the sentence is the engine's), and the file and the five table imports only it used are deleted; S-1743 asks `readDenyRowAnchor(row)` for a served or hot-issued row instead of a first-character test. *settings-schema* (arrival version **7.54.0**): import `readToolFaces` instead of mirroring five tables; no drift gate to carry. *cli* / *client-core*: import where a `toolFaces` declaration or a deny row is judged. *Any host that mounts MCP servers*: a `toolFaces` declaration that mounted with a member dropped (or threw) is fixed at its source; the refusal sentence names the member.
28
+ - **Pins.** `test/backlog1973-read-tool-faces.test.ts` (T1–T4, S: the one reader, one bad word per closed table in the mount's sentence, the receipt's words shared with `readToolAxes`, the host-side reader's shapes judged the same, no vocabulary of its own); `test/backlog1973-fix1-faces-door.test.ts` (D the door and the task lane, L the library entrance, M the mount and the namespace rule, A the classifier's statement of an anchor, T the tier); `test/backlog2042-read-deny-row-anchor.test.ts` (A1–A3, G: the three forms and their spellings, every reader byte-identical to the retired pair, the pair read nowhere else); `test/export-surface.snapshot.json` (+2, `readToolFaces` tiered `advanced`).
29
+
30
+ ### An MCP server's `toolFaces` declaration that does not fit is refused under `config.tool_face_invalid`, the code a caller tool's bad face already has, at the preparation door and at `materializeMcpTools` (#2093; behavior narrowing, open to veto; hard-breaking: none for a well-formed deployment, see Who is hurt; closed sets: `CHECKPOINT_CONFIG_REFUSAL_CODES` +1 `config.tool_face_invalid` (40 → 41); exports: none; members: none) (@server @client-core @cli @settings-schema @test)
31
+
32
+ - **Before (v7.53.0).** A vocabulary problem in an MCP server's `toolFaces` — a word outside a closed table, a reserved approval card (`question` / `plan`) — reached the mount, which announced it once per tool after the listing (`config.tool_face_invalid`) and did not mount that tool; the server's other tools mounted. A caller tool declaring the same face was refused at its own door, at prepare, under the same code.
33
+ - **Behavior narrowing (open to veto).** The entry is refused whole, before any dial: the preparation door refuses the whole prepare (`runTask` returns a failed terminal, `terminal.code` `config.tool_face_invalid`, the reader's sentence in `terminal.message`; nothing is announced) and `materializeMcpTools` fails that server's dial (status `failed` / `invalid_config`, the warning's `cause.code` `config.tool_face_invalid`, its siblings unaffected) — the same all-or-nothing rule a bad `toolAxes` already had. This also gives the face refusals the #1973 section above describes (a mistyped member, a member of the wrong kind, an empty tool name) their code: `config.tool_face_invalid`, not the generic `config.invalid_argument`, so one bad face has one code at the caller door and at the MCP door, in the one judge's sentence. What stays `config.invalid_argument`: the entry's own plain-data receipt (a Proxy, a cycle, a member named `__proto__`, a list element that is not a string) and the `toolAxes` leg. A face the reader accepts but the tool's schema cannot carry (a `pathTarget.param` that is not a top-level property, the keys an approval card needs) is unchanged: judged at each listing, that tool not mounted and `config.tool_face_invalid` announced once.
34
+ - **On a resume.** The word is a prepare code the resume's pre-claim screen can now produce, so it joins the set the screen wraps: an MCP entry whose `toolFaces` the face reader refuses rejects the resume as `checkpoint.config_refused` (`detail.code` `config.tool_face_invalid`, `detail.field` `"taskConfig"`) with the row `pending` and byte-identical on any store, as a bad `toolAxes` does. A CALLER tool (`TaskSpec.tools`) declaring the same bad face is not on the screen: its refusal is the roster safety scan, prepare's own stretch after the config-door phase, so on a resume it is a `failed` terminal after the claim (`KL-1950-a`; the pin is `backlog1950` D) — it was never a raw error, only later.
35
+ - **Closed sets.** `CHECKPOINT_CONFIG_REFUSAL_CODES` / `CheckpointConfigRefusalCode` 40 → 41: +`config.tool_face_invalid` (each word of the set has a recipe the screen produces: `test/backlog1950-resume-config-doors-preclaim.test.ts` I).
36
+ - **Who is hurt.** (a) A deployment that points one bad card at one tool of a server: the whole server does not mount (before: that tool alone), and a task naming it is refused at prepare until the declaration is fixed. The code does not change from v7.53.0 (`config.tool_face_invalid`), only where it is met and what it takes with it. A host that branched on `config.invalid_argument` for the #1973 refusals above is not hurt: they never shipped under that code. (b) A downstream table that is exhaustive over `CheckpointConfigRefusalCode` / `CHECKPOINT_CONFIG_REFUSAL_CODES` (a `satisfies Record<CheckpointConfigRefusalCode, …>` status or disposition table) stops compiling, or misses the word at run time, until it has a row for it (@server @client-core).
37
+ - **How to migrate.** Fix the declaration the refusal names (the server, the tool, the member), or ask `readToolFaces(declared)` at the host's own door and refuse before a task is built. *server* / *client-core*: add one row to a table over `CheckpointConfigRefusalCode` for `config.tool_face_invalid` (`detail.field` `"taskConfig"`: correct the config and resume the same token, as for the other protocol declarations). *settings-schema* / *cli*: a branch on the declaration code now sees `config.tool_face_invalid` for an MCP face too.
38
+ - **Pins.** `test/backlog2093-toolfaces-door-code.test.ts` (D the door and the shared judge, W the two doors one code, R the task lane, L the library entrance, S the schema-dependent half unchanged); `test/backlog1414-approval-card.test.ts` (A10 protocol arm: the run fails at prepare, nothing asked); `test/backlog1973-fix1-faces-door.test.ts` (D1–D3 code); `test/backlog1950-resume-config-doors-preclaim.test.ts` (A / B the resume refuses the face pre-claim, G the nested seat sweep, I the recipe for the new word, D the caller door's post-claim `failed`).
39
+
40
+ ### What the approving person writes in the card's "and tell the agent what to do next" input reaches the model: `ResumeOutcome` (`policy_ask`) `allow` gains `feedback?: string` (#1581; additive; behavior change, open to veto: the words are delivered to the model as the user's own, unfenced and unbounded, like the reference's accepted-prompt feedback; behavior narrowing: a WRITTEN `feedback` on a `deny` or on a decide whose `hostDecision.decidedBy` is not `"person"` is refused pre-CAS; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `ResumeOutcome.feedback` on the `policy_ask` arm, +1 optional `ResolvedOutcome.feedback`, +2 optional `detail.kind` / `detail.carry` on `task.user_steer_undrained`, +1 optional `OwedUserInputAccount.continuation.approval`, +1 optional `approvalFeedback` in `undrainedUserInputNotices`' counts) (@server @client-core @cli)
41
+
42
+ - **Before.** The approval card's Yes row had an input ("and tell Sema what to do next") whose text had no seat on the decide body: the shell collected it and dropped it, so the model never read what the person wrote. The `deny` arm already carried the person's words as `reason`; the `allow` arm had nothing.
43
+ - **Now.** (1) The `allow` arm of the `policy_ask` decide takes `feedback?: string`. It is present only when the person wrote something; a host whose card has no input omits the key; **blank (absent, `""`, whitespace-only) is "wrote nothing" and delivers nothing** — the continuation is byte-for-byte what a plain allow produced. (2) The resumed run's continuation user message (the first message after the batch's tool results) carries the words once, verbatim after the outer whitespace is dropped, as a user-lane range outside every `engineSegments` range — no fence, no wrapper sentence, no length bound; a derived view (auto-mode window, compaction serializer) reads them as user speech. A user row directly after the approved call's own result is not used: a batch's siblings settle after it and a call's results must precede any later message. (3) The winner on the row records the words exactly as supplied (`resolvedOutcome.feedback`, additive: a row that never recorded them leaves the dimension unbound) and the `env_failed` replay must repeat them. (4) **Core has no `allow_session` word** — `decision` is exactly `"allow"` | `"deny"`; "allow for this session" is a host's session-rule write beside a plain `allow`, which carries `feedback` like any other allow. (5) Anchored on the reference (2.1.296): the accepted feedback is trimmed with empty meaning none (`acceptFeedback` (cli296.js:274533)), and it rides as one plain text block beside the tool result with no prefix and no bound (`acceptFeedback` (cli296.js:249220)).
44
+ - **Behavior narrowing (open to veto).** A WRITTEN `feedback` is refused pre-CAS (`checkpoint.invalid_outcome`, the row stays pending and redeemable) when the decision is `deny` (that arm's words are `reason`), when `hostDecision.decidedBy` is not `"person"` (a machine's text is never delivered as the user's), when it is not a string, or when it carries a `<system-reminder>` tag, opening or closing (the one rule a deny's `reason` is held to — the sentinel is the engine's own, so the reference's raw delivery of such text is a stated divergence, `KL-1581-c`). A host that sent a stray `feedback` key on such a decide before this version had it silently ignored.
45
+ - **Who is hurt.** (a) A host that already puts a WRITTEN `feedback` (a non-blank string) on a deny or on a policy-made decide (ignored until now) is refused at the verb — a blank one on a deny is accepted as before; and a host that sends `feedback: null` (or any other non-string) on any decide of an approval row — a person's allow included, where it was accepted before — is now refused (`checkpoint.invalid_outcome`, the row stays pending): omit the key instead. (b) A deployment whose `userPromptSubmit` screen is a prompt FILTER does not see these words (`KL-1581-b`): filter at the approval channel, as for `reason`. (c) A consumer that assumed the resume continuation holds only engine frames and fenced data now meets a user-lane range there when the person wrote one. (d) Mixed fleets: a worker on an older core ignores `feedback` silently — pin the core version that serves the decide verb. (e) If the call's result row or the message carrying the words fails to append, or the leg ends before that message commits (a spent token / $ ceiling), the words are announced — one more row of `task.user_steer_undrained` with `detail.kind: "approval_feedback"` (`kind` is absent on every other row; no second code) — unless the checkpoint row came back; they are not retained across the failure, and a returned row does not carry them either: on a row reopened for `env_failed` the replay must repeat the recorded words (blank and trimmed forms read alike), on one reopened for another reason the next decide is fresh and, without `feedback` on it, the first words are gone with no announcement — the host may re-supply them (`KL-1581-a`). (f) If a sibling of the approved call gates again before the prompt lands, the words ride the new row as its last parked steer and reach the model under that carrier's framing on the next resume — unless they exceed that carrier's bounds (16,000 characters; a queue already holding three steers), in which case they are not carried and `task.user_steer_undrained` (`cause: "leg_end"`, `detail.carry` = the store's refusal code `steering.invalid_content` or `steering.queue_full`, and a sentence that says the pause's carrier refused them) counts them; the same account counts them when the leg ends before its prompt lands (a spent token / $ ceiling), after the decide was applied (`KL-1581-a`). (g) The refusal sentences of a deny / reject `reason`, of `editedPlan` and of a trusted live steer (same rule — they already refused the opening tag too) are now produced once (`sentinelTagRefusal`, beside `sanitizeUntrustedText`) and say "… must not contain a <system-reminder> tag (opening or closing)" where they named only the close tag: dispatch on `code`, never on the sentence. The in-process approver face (`AskOutcome`) has no `feedback` seat in this version.
46
+ - **How to migrate.** *server*: pass the card's input through on the allow decide — `feedback` beside `decision: "allow"` when `hostDecision.decidedBy` is `"person"` and the person wrote something (omit the key otherwise; never on a deny, where the words are `reason`). *client-core*: the `decide` input gains `feedback?: string` for the allow arm and projects it onto the decision body. *cli*: the card's existing input feeds the allow decision (`buildAllowDecision(updatedInput, permissionUpdates, feedback)`), on the plain Yes row and on the rows that also write a session rule alike.
47
+ - **Pins.** `test/backlog1581-approval-feedback.test.ts` (F1 delivery: role / position / once / verbatim and the stored user-lane range; F2 blank; F3 composition and the absent `allow_session` word; F4 deny unchanged, written feedback refused on a deny and on a policy decide; F5 the winner, the replay, the owed carry on a re-park; F6 bad values; F7 event order and the committed user entry).
48
+
49
+ ### The protocol tools a teammate leg shares with its spawner (`SendMessage`, `CronCreate` / `CronDelete` / `CronList`, the task-list family) are held exactly when the layers above the leg admit them — one table replaces the `protocolUnderCeiling` flag (#2070; behavior narrowing and behavior change, ruled: clay C-R646, 2026-10-19; hard-breaking: type only — `ToolCatalogEntry` loses `protocolUnderCeiling`; a host compiling against that member stops compiling, runtime unchanged; closed sets: none; exports: none; members: −1 optional `ToolCatalogEntry.protocolUnderCeiling`) (@cli @server @client-core)
50
+
51
+ - **Before.** On a named child (a teammate leg) four of the eight team tools — `SendMessage` and the scheduler trio — were admitted whatever the spawner's lists said; only the task-list family (`TaskCreate` / `TaskGet` / `TaskList` / `TaskUpdate`) was bounded by the spawner, through a flag on those four catalog rows. So a spawner with `allowTools: ["Read", "Agent"]` handed its teammate `SendMessage` and `CronCreate` / `CronDelete` / `CronList` anyway, and a spawner that denied `SendMessage` (or any of the four) got its named child REFUSED before it ran (`config.tool_face_control`, `detail.reason: "protocol_denied"`, "an inherited list … denies it"). The cli's `--tools` hid the four with a deny-complement second line for exactly this reason.
52
+ - **Now.** One table, `SPAWNER_SURFACE_LEG_KINDS` (the leg kinds that share their spawner's tool surface; today `teammate`), read by the selector's protocol arm: a protocol row some kind of which is in the table is decided by the layers ABOVE the leg — the leg's own lists still do not decide it (`allowTools` need not name it, a definition's own allowlist never takes it). So a spawner's deny, or an allowlist (its own, or one of a run between the spawner and the leg) that does not name a team tool, takes that tool from the named child: its roster does not carry it and a call of it comes back `tool.not_found`. An inherited deny of such a tool is a bound, no longer a contradiction: the child starts without the tool. The flag is gone (`protocolUnderCeiling` on four catalog rows, its field in the filter's row table, its contract sentence). A row no kind of which is in the table is the leg's own channel — `ReportBlocked` (delegated), `StructuredOutput` (structured), `DeclareDone` (goal), `ObserverReport` (observer): admitted past every layer exactly as before, and a deny that reaches one, the run's own or an inherited one, is still refused before the run in the same sentence. A row naming several kinds is shared when any of them is in the table. Anchored on the reference (2.1.296): an in-process teammate's available tools ARE its leader's root tool surface (`cli296.js:278271`, `{tools: J} = y.rootToolSurface`), the task-list family is requested for it only when that surface holds `TaskUpdate` (`cli296.js:278288`), and its own `tools:` / `disallowedTools` cannot remove `SendMessage` or the task-list family (`cli296.js:278274`).
53
+ - **Behavior narrowing (ruled: clay C-R646, 2026-10-19).** A named child under a spawner whose allowlist does not name `SendMessage` / `CronCreate` / `CronDelete` / `CronList` no longer holds them (the task-list family already followed the allowlist). The leg's own deny of a team tool is refused before the run as before, byte for byte; the table's other kinds, an unnamed (delegated) child, and the whole-face switch (`excludeAllTools`) are unchanged.
54
+ - **Behavior change (ruled: clay C-R646, 2026-10-19).** A spawner that denies a team tool no longer gets its named child refused: the child starts without the tool (a team whose lead denied `SendMessage` has teammates that cannot message — the configurer's choice, now carried out as written). The reference silently drops a teammate's OWN deny of these tools from its definition (`cli296.js:278274`, the filter on `p.disallowedTools`); core keeps refusing it loudly (the existing divergence, unchanged).
55
+ - **Who is hurt.** (a) A deployment that relied on "the team tools are always there" under a limited spawner: `SendMessage` and the scheduler trio vanish from the named child unless the spawner's allowlist names them (`--tools Read,Agent` shape). (b) A host or test that pinned the refusal of an inherited deny of a team tool (`protocol_denied` naming "an inherited list") for these rows: that sentence now arises only for the other kinds' rows. (c) A parked named child resumed after the upgrade is judged by the ceiling its row recorded, so it too meets the new roster (not pinned). (d) Type only: a literal typed `ToolCatalogEntry` that sets `protocolUnderCeiling` stops compiling (the catalog is core's own static table; no host is known to author rows).
56
+ - **How to migrate.** Name the team tools the teammates should keep in the spawner's allowlist (`allowTools: ["Read", "Agent", "SendMessage", "TaskList"]`), or deny the ones they must not have; there is nothing to set on the leg. *cli*: retire the deny-complement second line of `--tools` (the `tools-flag-complement-deny-teammate-protocol` transitional arm: its registry entry in `scripts/uplift-retirement-registry.json`, the comment block at `src/main.tsx:1329`, and the complement `src/utils/permissions/permissionSetup.ts` computes for it) — the request's top-level `allowTools` already bounds the teammate. *server*: no change (the tool table projection reads the roster). *client-core*: one doc sentence — a named child's team tools follow the spawner's allowlist and denylist.
57
+ - **Divergences preview.** Entry 280 (`protocol-column-leg-tools-outside-lists`) narrows to the leg's own lists; the layers above now decide the team rows as the reference's root-surface inheritance does. What stays a divergence: a teammate's own deny of a team tool is refused (reference: silent drop), and the scheduler trio is kept past a definition's own allowlist here although the reference forces only `SendMessage` and the task-list family into a teammate's own `tools:` (`cli296.js:278274`; the trio is in the reference's teammate allowance set, `cli296.js:245703`, applied at `cli296.js:254651`).
58
+ - **Pins.** `test/backlog2070-protocol-rows-under-ceiling.test.ts` (T1–T2 the table and the multi-kind rule; U per catalog row P1–P4, P6, three-layer nesting, the covering-spelling matcher, P7, the other kinds' rows unchanged; R a real Runner, roster AND call, own deny sentence byte-same; S the flag gone and no leg-kind literal in the filter); `test/backlog2070-multi-kind-protocol-row.test.ts` (a synthetic catalog row naming two kinds, asked through the admission and through the contradiction judge, per asking leg); `test/backlog1623-tool-selector.test.ts` ②b ① and the row-under-the-ceiling cell re-anchored.
59
+
60
+ ### Five judges the engine already asks are now package-root exports, so a host stops writing a second copy: `sameOrgPolicyContent`, `receiveDeclaration` + `DECLARATION_PATH_RULES`, `mcpStartupTimeoutMs`, `isHostRole` (#2060, #2076, #2084, #2085; additive exports; hard-breaking: none; closed sets: none; exports: +4 functions, +1 constant, +3 types; members: none) (@server @client-core)
61
+
62
+ - **Before.** Each of these was a function or table the engine's own door, partition or backend already asks, held inside its module. A host that needed the same question answered at its own door had to re-write it: the org partition's "same revision, same content" comparison (a persistence backend of the host's own), the plain-data reading of an MCP server's `transport.env` / `transport.headers` or an A2A peer's `headers`, the startup bound's range for `startupTimeoutMs`, and the membership of `TaskSpec.promptRole`. `TaskSpec.promptRole` also spelled the four role words a second time, as an inline union beside the exported `HOST_ROLES`.
63
+ - **Now.** (1) `sameOrgPolicyContent(a, b)` (#2060) is the one comparator the partition's equal-revision guard and the file backend's install of org state both ask: row for row the same policy members (`rule`, `behavior`), never observation metadata (`fetchedAtMs`, `signature`) and never a member no judgment reads. (2) `receiveDeclaration(declared, rules)` and `DECLARATION_PATH_RULES` (#2076; types `DeclarationRead`, `DeclarationFault`, `DeclarationPathRule`) are the engine's receipt of a caller's declaration and the per-surface path rules the preparation door asks it with (`receiveDeclaration(entry, DECLARATION_PATH_RULES[at])`, `at` a `ToolAxesSurface`): `ok: true` carries the engine's frozen plain-data copy (the caller's object is never handed on), `ok: false` the path and the kind of fault, never a value. `rules` is a required argument and must be a list: a surface's row, or `[]` for a declaration that fixes no path; an absent value (what an `at` outside the two surfaces indexes to), `null`, a string or an inherited member of the table is refused with `config.invalid_argument`, the code `readToolAxes` gives an unknown `at`, rather than read as "no path rule". The table is now frozen down to each row and its path (it was frozen only at the table and the lists, which a package-root export would have left rewritable by any consumer, changing what every door judges; nothing in the engine writes to it). A declaration that is not a record (`null`, a string, a list) is answered as it is, ok, for the caller that expected a record to word — the door's own refusal of those is its next step, not this function's. (3) `mcpStartupTimeoutMs(server?)` (#2084) is the one reader of an MCP server's startup bound: the server's own `startupTimeoutMs`, else env `MCP_TIMEOUT`, else `undefined` (the SDK's default); a declared value that is not a whole number of milliseconds in range is refused with `config.mcp_startup_timeout_invalid`, naming the server (`(unnamed)` for an entry with no usable `name`, which the sentence used to read for its text and fail on with a bare `TypeError`). The `brain.call` trace row's `turn` note now says what the implementation does: `turn` is present on the main loop's own provider call and absent on every call fired beside the loop (the compaction summarizer, the classifier gate, a mod's call), whose rows also carry `latencyMs: 0`. (4) `isHostRole(value)` (#2085) is the membership test of `HOST_ROLES` (`"general"`, `"code"`, `"coordinator"`, `"cowork"`; a root export since 7.52.0): `resolveHost` (the task door for `TaskSpec.promptRole`) and the overlay grammar's `when.host.role` check now call it instead of each writing the membership, and `TaskSpec.promptRole` is typed `HostRole`, the one statement of the set. The ticket named the set `PROMPT_ROLES`; it already exists as `HOST_ROLES`, so there is no second name for it. A host that carried a copy of an engine role's text (the code role's prompt, sent as `systemPrompt`) retires it by selecting the role — `TaskSpec.promptRole: "code"` renders the engine's own code variant — and must also stop sending `systemPrompt` beside it: `systemPrompt` replaces the selected role base, so given together the host's text wins over the engine's.
64
+ - **Behavior narrowing: none.** Every export is the value the engine's own module holds (pinned by identity), the two readers of the role set ask the same predicate they wrote inline before, and `promptRole`'s accepted values are unchanged. The changes to existing values: the path-rules table's rows are frozen (nothing writes to them), and `receiveDeclaration` loses its `rules = []` default (it was not exported; the engine's two callers that fix no path now pass one empty table).
65
+ - **Who is hurt.** Nobody: additive exports plus a type spelled through its alias (the same four members; `test/backlog2060-exports-batch.test.ts` E-2085.c checks it at compile time).
66
+ - **How to migrate.** *server*: the MCP request door's `transport.env` / `transport.headers` and the A2A `headers` reading (`plain-string-record.ts`) can ask `receiveDeclaration(entry, DECLARATION_PATH_RULES[at])` and word a refusal from `fault.path` / `fault.problem` (measured against `plain-string-record.ts` over eighteen `transport.env` values, the two answer alike on sixteen; they differ on a member holding `undefined`, which the engine reads as absent and `plain-string-record.ts` refuses, and on an own `__proto__` member holding a string, which the engine refuses and `plain-string-record.ts` strips from its copy); the `mcpServers[i].startupTimeoutMs` intake asks `mcpStartupTimeoutMs({ name, startupTimeoutMs })` and answers a `config.mcp_startup_timeout_invalid` throw as a 400 naming the member; a persistence backend of its own for the org state asks `sameOrgPolicyContent` for "same revision, same content". *any host*: read the role set from `HOST_ROLES` / `isHostRole`, not from a copy.
67
+ - **Pins.** `test/backlog2060-exports-batch.test.ts` (E-2060.a–b, E-2076.a–h, E-2084.a–c, E-2085.a–e: each root export is identical to the engine's own value, plus one behaviour per judge; E-2076.e compares the root receipt with the preparation door's own refusal over twelve declarations on both surfaces); `test/export-surface.snapshot.json` (+8 names).
68
+
69
+ ### Small fixes: a checkpointed workspace handle is delivered as one copy; the org partition's refused snapshot copy has its own type; a pre-claim parked-resume refusal names its code, field and reason; a session branch is read in linear time (#2058, #2066, #2083, #2092; behavior change — open to veto; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `details.detail` on the parked-resume startup receipt) (@server)
70
+
71
+ - **#2058 (behavior change, open to veto; type-level: two `Readonly`).** On a durable resume the env factory was handed a frozen copy of the checkpointed workspace handle (`ExecutionEnvFactoryContext.resumeWorkspace`), the adapter's `resumeVM` was handed the stored row's OWN object as `VmLifecycleOptions.priorHandle`, and the deployment's `resumePreflight` a structured clone of it (`ResumePreflightInfo.workspaceHandle`; a handle a custom store returns as a Proxy made that clone throw before the preflight ran). `frozenHandleCopy(handle)` now answers one copy per live handle object, and all three deliveries are that copy: the preflight's facts, the factory's `resumeWorkspace` and the restore's `priorHandle` are the same frozen object holding the declared handle members (`WORKSPACE_HANDLE_MEMBERS`) that the row carries as strings. `priorHandle` and `ResumePreflightInfo.workspaceHandle` are typed `Readonly<WorkspaceHandle>`. The engine's own comparison of the row against the restored handle is unchanged and still reads the row's own object.
72
+ - **Who is hurt (#2058).** An adapter that writes to `priorHandle` (the write now throws in a module, where it used to edit the stored row's object for the next attempt of the restore), one that reads a member the interface does not declare off `priorHandle` (a decoration a store kept on the row; it is no longer delivered), and a preflight hook that mutates `info.workspaceHandle` or reads such a member. Nothing else: a conforming adapter reads `mountPath` / `snapshotId` / `provider` / `deviceId`, which are all delivered unchanged. *server*: no migration; a deployment adapter that rebinds the restore on `priorHandle.mountPath` is unaffected. Pins: `test/backlog2058-prior-handle-same-copy.test.ts` (P1 the three deliveries are one object, frozen and equal to the row; P2 a write to `priorHandle` throws and the row is untouched, a decoration is not delivered; P3 / P3b a Proxy handle resumes through a wired preflight and the preflight's copy is the factory's and the restore's; P4 the copy function; P5 readonly at type level); `test/design469-r3-fence.test.ts` F1 re-anchored (its comment and assertions had stated the restore received "the engine's own handle", the one fact this fix changes).
73
+
74
+ - **#2066 (type, no behavior change).** The org partition's copy of a snapshot whose rules list the list judgment refused (not a list, a Proxy, past 256 places) had no type of its own: it was held as an `OrgRuleSnapshot` whose `rules` was a frozen refusal stand-in, behind an `as unknown as` that the type-hygiene ratchet counted (`src/core` 43 → 44 in 7.53.0). The held copy is now `HeldOrgSnapshot = OrgRuleSnapshot | RefusedOrgSnapshot`: the refused arm carries the snapshot's envelope and the words it was refused in (`refused`) and no `rules`; `validateOrgSnapshot` is the one way to an installable snapshot (`{ admitted }` or `{ refused }`), and install and serve read the admitted arm, so a refused copy cannot be read as a table of rows (a zero-row policy). The stand-in object and its WeakMap are gone, and the `src/core` hygiene stock is 43 again. The partition's verdicts are unchanged: 23 refused and control inputs, fetched and persisted, resolve to the same status, revision, rules served, installs and disclosure sentences as before (recorded from the tree this change started from). The new types and `orgSnapshotCopyOf` / `validateOrgSnapshot` are module exports for the pins; the package root does not carry them. Pin: `test/backlog2066-org-snapshot-copy-union.test.ts` (U1 shapes, U2 verdicts, U3 `@ts-expect-error` negatives, G the 23-scenario table, H the retired assertion); `test/type-hygiene-gate.test.ts` baseline `src/core` 44 → 43.
75
+ - **#2083 (additive; a new `details` member on an existing code; closed sets: none — no count changes) (@server S-1708).** The parked-resume receipt of a background sub-agent (`details.error: "parked_resume.startup_failed"`, `details.cause`) named only the cause class when the resume was refused before the claim: the refusal's own `CheckpointError.detail` (`code`, `field`, `reason` — which input and which arm refused) was dropped on the way, so a consumer gating the claim could read the sentence and nothing else. The receipt gains `details.detail = { code?, field?, reason? }`: each a string, the words of the thrown error's own families (`checkpoint.config_refused`'s prepare code `detail.code` and its input `detail.field`; the arm of one code, `detail.reason`), read from the failure's coded account by the one picker `resumeRefusalDetailOf` (`src/core/resume-startup-cause.ts`). **In-presence:** only when the resume was refused by a thrown failure whose coded `detail` states at least one of the three as a string; a member not stated is absent, the whole `detail` is absent when none is, and a returned failed result (a leg that ran past the claim) carries none. The sentence, `cause`, `error` and `task_id` are unchanged; no word is added to any closed set. *server*: `parked-decide.ts`'s 422 body can pass `detail.code` / `detail.field` / `detail.reason` through as `blockedBy` / `field` / `reason`, the keys the top-level and synchronous legs already carry. Pin: `test/backlog2083-park-claim-refusal-code.test.ts` (D1–D2 the members on a config refusal, the arm of one code, a lone member; D3 sentence / cause / error byte-same; D4 absence — no detail, other members only, non-strings, a throwing getter; D5 the picker, own members only).
76
+ - **#2092 (performance, no behavior change).** `getPathToRoot` (the one root-bound path read behind `getBranch`, `buildContext` and every engine read of a local-backend session) built its answer with an `unshift` per entry, so the cost of reading a branch was quadratic in its length (20,000 entries ≈ 0.5 s per read, measured by the server). It now collects leaf → root and reverses once. The returned path, its order and every stopping rule are unchanged: the floor stops the walk, an entry with no parent stops it, a parent missing above the floor is `invalid_session`, an unknown leaf is `not_found`. Pin: `test/backlog2092-path-to-root-linear.test.ts` (B1 the order in three shapes, byte-for-byte; B2 a 20k chain walked with zero front-insertions — a count, not a wall-clock budget, which the hard gate forbids).
77
+
78
+ ### The store vocabularies a backend checks rows against are package-root values, each spelled once in the engine: `PERSISTED_RULE_MATCHES`, `RULE_OFFER_BATCH_MEMBER_KINDS`, `UNCOVERED_SEGMENT_REASONS`, `EDITED_RULE_BREADTH_WARNING_CODES`, `RULE_APPROVAL_RECORD_SCHEMA`, `FILE_HISTORY_ERROR_CODES` (#1468 stage one, K1 — the 7.55.0 theme; additive exports; hard-breaking: none; closed sets: none changed — six existing vocabularies gain their value, members and order unchanged; exports: +6 constants; members: none) (@server)
79
+
80
+ - **Before.** The engine published the TYPES of these six lists only. The server kept a runtime copy of each — five in `src/permission-rule-vocab.ts`, one in `src/file-history-disclosure.ts` — bound to the engine's type by a compile-time equality pin, and read the copies in its stores and doors (`z.enum(...)`, the approval-record stale gate, a text code reader). Inside the engine, the consent record's read-back spelled the three reason words out again in two clauses (the record's damage check and the card projection) and the two warning codes in one, and the record stamp `4` was written four times (three mints, one stale gate).
81
+ - **Now.** Each list is a frozen tuple (the record stamp a constant) in the module that owns it, the public type is derived from it — the unions are literally the same — and the package root exports that value. Engine constant → export → the server copy it replaces (sema-server origin/main `5df75153`): `PersistedRuleMatch` (`src/core/permission-rule-model.ts`) → `PERSISTED_RULE_MATCHES` → `src/permission-rule-vocab.ts:45`; `RuleOfferBatchMember["kind"]` → `RULE_OFFER_BATCH_MEMBER_KINDS` (the union keeps its literal arms, two compile fences bind them to the tuple both ways) → `:79`; `UncoveredSegmentDetail["reason"]` → `UNCOVERED_SEGMENT_REASONS` (`UNCOVERED_SEGMENT_REASON_BASELINE` is keyed by it) → `:84`; `EditedRuleBreadthWarning["code"]` → `EDITED_RULE_BREADTH_WARNING_CODES` → `:89`; `RuleApprovalRecord["schema"]` (`src/core/permission-rule-consent.ts`) → `RULE_APPROVAL_RECORD_SCHEMA` (`4`) → `:99`; `FileHistoryError["code"]` (`src/core/file-history-store.ts`) → `FILE_HISTORY_ERROR_CODES` → `src/file-history-disclosure.ts:193`. The consent read-back clauses and the three mints read the values instead of spelling them. The session-rule bucket names are not exported: the server retired its copy of them itself and reads the engine's session-rules judges (`readSessionRulesShape`, `judgeSessionRulesWrite`), so no copy is left for a value to replace.
82
+ - **Behavior narrowing: none.** The same members in the same order as the server's copies; the read-back clauses accept and refuse exactly the values the spelled-out comparisons did — the record's two clauses compare word by word in the tuple's order and stop at the first match, so even a value a host's approval-record store hands back behind an accessor is read as often, and in the same order, as before; the card projection keeps its one read of each value.
83
+ - **Who is hurt.** Nobody: additive exports and types re-spelled through their own derivation.
84
+ - **How to migrate.** *server* (stage two): import the six names from the package root and delete the six copies with their pins (`permission-rule-vocab.ts:45` / `:79` / `:84` / `:89` / `:99`; `file-history-disclosure.ts:193` and its two difference fences); `z.enum(PERSISTED_RULE_MATCHES)` and the other readers take the frozen tuples as they took the local ones. `RULE_OFFER_BATCH_MEMBER_KINDS` has no reader in the server today (only its pin).
85
+ - **Pins.** `test/backlog1468-k1-vocabulary-twins.test.ts` (per vocabulary: ① the root export is the module's own constant, ② frozen, ③ the same set as the server copy, ④ at compile time the derived union equals the union the type spelled before; ⑤ the collapsed read-back clauses accept every member and refuse thirteen foreign values, a live store's accessor rows get the verdict the spelled-out comparisons gave, the stale gate reads only the stamp as live, and the card, import and starter mints write it); `test/export-surface.snapshot.json` (+6 names).
86
+
87
+ ### Five more store vocabularies are package-root values, each spelled once in the engine: `RULE_APPROVAL_KINDS`, `RULE_APPROVAL_STATES` (+ type `RuleApprovalState`), `IMPORTED_SETTINGS_LAYERS`, `RULE_ADD_ORIGINS`, `RULE_OWNER_KINDS` (#2097, #1468 stage one — the 7.55.0 theme; additive exports; hard-breaking: none; behavior narrowing: none; closed sets: none changed — five existing vocabularies gain their value, members and order unchanged; exports: +5 constants, +1 type; members: none) (@server)
88
+
89
+ - **Before.** The engine published only the TYPES of these lists (the approval record's `state` was an inline union with no name). The server's stores and doors spelled each list out again inline — `z.enum([...])` or per-arm `z.literal(...)` — bound to the engine's type one way at most (a list that is a subset of the engine's still compiles where it is assigned into the engine's type), so a word the engine adds would be refused there with no compile error to say so. One inline copy is held both ways: the local-import door's add `origin` (`src/http/routes/rules.ts:126`), through that door's row-shape equality pin (`:164`). Inside the engine, the sync wire's add reader and the session overlay's add check each spelled the three provenance words out as three comparisons.
90
+ - **Now.** Each list is a frozen tuple in the module that owns it and the public type is derived from it — the unions are literally the same; `RuleOwner` keeps its literal arms (its `principal` arm carries the name), and two compile fences bind the arms to `RULE_OWNER_KINDS` both ways. The two add checks read `RULE_ADD_ORIGINS`. Engine constant → the server's inline copy it replaces (sema-server origin/main `5df75153`): `RULE_APPROVAL_KINDS` → `src/plugins/permission-rule-store-file.ts:114`, `src/plugins/permission-rule-store-sql.ts:1138`; `RULE_APPROVAL_STATES` → `permission-rule-store-file.ts:115`, `permission-rule-store-sql.ts:1139`; `IMPORTED_SETTINGS_LAYERS` → `src/http/routes/rules.ts:80`; `RULE_ADD_ORIGINS` → `rules.ts:126`, `permission-rule-store-sql.ts:157`; `RULE_OWNER_KINDS` → the owner arms at `permission-rule-store-file.ts:109` / `:190`. The durable scope gets no list: its rule is a judgment (two members, and a project root must be a complete absolute path), and the existing package-root judge `isValidDurableScope` is what `permission-rule-store-sql.ts:146-149` (`DurableRuleScopeSchema`) restates.
91
+ - **Behavior narrowing: none.** The same members in the same order as each type spelled them; the two collapsed add checks compare word by word in the tuple's order and stop at the first match, so they accept and refuse exactly the values the spelled-out comparisons did.
92
+ - **Who is hurt.** Nobody: additive exports and types re-spelled through their own derivation.
93
+ - **How to migrate.** *server* (stage two): import the names from the package root and read them where the inline lists stand (`z.enum(RULE_APPROVAL_KINDS)` and the others take the frozen tuples as they take literal arrays); the scope shape asks `isValidDurableScope`.
94
+ - **Pins.** `test/backlog2097-rule-vocab-twins.test.ts` (per vocabulary: frozen, the root export is the module's own constant, the words in order are the type's words as it spelled them; at compile time each derived union equals the union it spelled before; the sync wire's reader and the session overlay's door judge 24 values — the three members and 21 foreign ones — exactly as the spelled-out comparison does); `test/export-surface.snapshot.json` (+6 names); `docs/CLOSED-SETS.md` (+5 rows).
95
+
96
+ ### Release-process docs and gates: the live ruling vocabulary and serial lane, the pre-tag wire-manifest check, the 7.53.0 criteria erratum (#2067, #2068, #2072; release tooling, tests and docs only; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
97
+
98
+ - **#2067 (live gate; no `src/` change).** The `#842` live cell (`test/live-write-protect-ask.test.ts` ①) read the classifier's ruling through a pattern naming a word the verdict type does not have (`approve`), so a model answering "no" — `allow`, a legitimate ruling — read as "no ruling". The vocabulary is now a table keyed on `AutoModeVerdict["kind"]` (`allow` and `block` are rulings, `unavailable` and `parse_error` are not), pinned against the real `parseAutoModeResponse` in the same file; that block runs without the live environment. `scripts/run-live-gate.mjs` gains a serial lane: the files named in `SERIAL_LIVE_FILES` (that one) run alone in their own vitest invocation after the shared batch, each batch is judged on its own targets, and every batch runs even when an earlier one is red (`test/backlog2067-live-serial-lane.test.ts`, a fake `npx` on `PATH`). Not established: that the gate's concurrency caused the 7.53.0 RC2 red — the lane removes it as a variable rather than claiming it. The question wording is unchanged; a stable `spelling = allow` / others = `block` disagreement would be #842's own disclosure and is not this ticket.
99
+ - **#2068 (release gate; no `src/` change).** The wire manifest the tag carries is computed from `src/`, the export snapshot and the version's criteria file; a fix merged after it was generated left the package's copy describing an older parent (7.53.0: computed at `3c775d132`, five input files moved afterwards, and only the post lint — after the tag — said STALE). `npm run release:preflight` (`scripts/release-preflight.mjs`) runs that same staleness judgment before the tag: it reads the manifest from the RC tree rather than the working tree, takes the version from the RC tree's `package.json` (the manifest must carry the same one), reads a two-stage manifest's `v<version>` as pointing at the RC, checks the base is the previous tag and that no input moved since it was computed, and exits 1 naming each moved file. The judgment is one function now (`manifestRangeProblems` in `scripts/ship-post-lint.mjs`, which calls `staleManifestProblems`; the post lint's WIRE leg uses it unchanged). `docs/RELEASE-TIERS.md` adds it to the R checklist as item 6: regenerate the manifest as the last step before the tag, once more after every car merged since (`test/backlog2068-release-preflight.test.ts`, real tags as fixtures: 7.53.0 reds, 7.52.0 and 7.51.2 pass). It is a one-off pre-tag check outside the `gate:*` family, so it is not in the `gate-all` roster.
100
+ - **#2072 (docs only; `docs/` is not in the package; no `src/` change).** The 7.53.0 criteria document's `v7.52.0:` front states for G10.d, G10.e, G19.a and G19.b (and the F15 / F23 `before` texts that repeated them) do not reproduce on the real v7.52.0 tag (they described an intermediate state of the 7.53.0 stage); each is rewritten to what the tag does, measured, and marked `erratum #2072` in the text (the criteria schema carries no separate erratum member, so the mark rides the field it corrects): the throwing-`behavior` row made the lane throw on `Bash` calls and the partition refuse the snapshot; class instances, `Date` / bigint / typed-array rows and another realm's plain object were read as published; a list-shaped row was held in memory with its members and persisted as `[]`; the start block already taught `s` before any tool was allowed and the disconnect line already went out in (b). G1's setup spells `createRoutingBrain(routes, { fallback })`; G15.e's 8 000-level claim is limited to the screening seat (the engine's one remaining `structuredClone` of a screening's answer is #2048); G15.f is marked owner-pin-only (#2071). The 7.53.0 section's #1042 line names `TaskSpec.clientContext.timeZone`. `docs/INTEGRATION-CORE.md` 12.13 gains the `contentHash` reading rule: `salted:` values are keyed with a per-process random salt (`randomBytes(16)`, never persisted) and compare only within one engine process; `sha256:` values compare anywhere; a host dispatches on the prefix (`test/backlog2072-docs-errata.test.ts` ties the `contentHash` statement, the G1 call form, the erratum marks and the zone path to the code or text they concern; the measured v7.52.0 readings are history and are not pinned).
101
+
102
+ ### `EngineStorageBackend` is the engine's storage seats as one contract — ten seat stores, `dispose()`, and an optional backend-level `retention` executor — exported from the package root, and `FileStorageBackend` implements it (7.55.0 theme version, #1468 stage one K3; additive export; behavior change: none; hard-breaking: none; closed sets: none; exports: +1 type; members: none on any existing type) (@server)
103
+
104
+ - **Before.** What "a storage backend" is existed twice and was a contract nowhere: core's `FileStorageBackend` class (at the base, `src/stores/file/index.ts:179-394`: the ten seat stores below and the file-only `root` / `memoryStore` / `consolidationLock` at `:179-225`, `dispose()` at `:355`), and the core-seat half of the server's `StoreBackend` interface (server `src/plugins/store-backend.ts:194-417`: `session()`, `checkpoint?()`, `toolResult?()`, `sessionPolicy()`, `workflowJournal()`, `fileHistory()`, `fileUsageWindowStore?()`, `retention?()`, `close()`, interleaved with the server's own product stores). A second backend had no type to implement. The class's doc example still passed `memoryStore` to `new Runner`, a seat removed earlier.
105
+ - **Now.** `EngineStorageBackend` (`src/core/engine-storage-backend.ts`) has ten members, each named for its `RunnerDeps` seat and typed as that seat's store: `sessionStore`, `checkpointStore`, `toolResultStore`, `sessionPolicyStore`, `workflowJournalStore`, `usageWindowStore`, `backgroundAgentStore`, `mailboxStore`, `rosterStore`, `fileHistoryStore` (the store only: the seat's `"disabled"` word is the deployment's). It also has `dispose()` and `readonly retention?: ManagedRetentionCapability`. The type is the existing contract in `src/core/retention.ts:64-79`, not a new one. It is the backend-level executor, because the capability's session deletion re-checks references held by several stores inside one transaction. It is an object, not a store's `"managed"` / `"none"` declaration word. `FileStorageBackend` gains `implements EngineStorageBackend` and nothing else: no member is added or changed, and it does not carry `retention`. Non-members, each with its reason, are classified in the pin: `workflowRunStore` and `workflowScriptStore` (the file backend does not construct them), `permissionRuleStore` (the seat takes the composed provider), `memoryBackend`, `memoryCaptureRecordStore` and `sharedMemoryStores` (not keyed by the storage root), `peerDirectory` and `promptSource` (host-fed; no bundled backend constructs them). A new `RunnerDeps` seat stays red in that pin until it is classified if it is named like a store (`…Store` / `…Stores` / `…Backend`) or typed as one of the contract's store types. A seat with neither a store's name nor a contract store type is outside that guard, and the pin says so. Server parity, member by member (server `5df75153`): `session()` → `sessionStore`, `checkpoint?()` → `checkpointStore`, `toolResult?()` → `toolResultStore`, `sessionPolicy()` → `sessionPolicyStore`, `workflowJournal()` → `workflowJournalStore`, `fileHistory()` → `fileHistoryStore`, `fileUsageWindowStore?()` (local) → `usageWindowStore`, `retention?(opts)` → `retention`, `close()` → `dispose()`. `backgroundAgentStore`, `mailboxStore`, `rosterStore` and the SQL `usageWindowStore` are built in server `src/boot/stores.ts:398-561`, outside `StoreBackend`. The class's doc example now names seats that exist.
106
+ - **Behavior narrowing: none.** The built JS is byte-identical to the base except for one new type-only module (`export {};`). The declarations add the `implements` clause, the import and the root export.
107
+ - **Who is hurt.** Nobody: one added type and a structural clause on a class whose members did not move.
108
+ - **How to migrate.** *server* (stage two): type the core half of `StoreBackend` as one `EngineStorageBackend` composed with the product stores. That means `close()` → `dispose()`, the four seats built in `boot/stores.ts` move under the engine backend, and the `retention?(opts)` factory becomes the `retention` object. Its `taskAttachmentTable` option names a deployment table; in the SQL package's design, deleting deployment rows is the deployment's cascade hook, not an executor option. *Any host with its own backend*: implement `EngineStorageBackend` and wire it by naming the seats, not by spreading a class instance (an instance carries members that are not seats).
109
+ - **Pins.** `test/backlog1468-k3-engine-backend.test.ts` (compile time: every member names a seat, member and seat types match both ways, every seat named or typed like a store is classified, no seat is both, the file backend satisfies the contract; run time: a real Runner wired through the ten members runs a task into the backend's data root, the file backend carries no `retention`, the store-level retention judgment reads as before, a backend carrying the executor is refused when it is handed over as a store, and `dispose()` releases the root); `test/export-surface.snapshot.json` (+1 name).
110
+
111
+ ### 7.55.0 theme release, #1468 stage one S1: `sessionStoreContract(make, hooks)` — the `SessionStore` interface's obligations as one cross-backend case set, exported from the package root (#1468; additive export; behavior change: none; hard-breaking: none; closed sets: none; exports: +1 function, +1 type `SessionStoreContractHooks`; members: none) (@server)
112
+
113
+ - **Before.** The obligations a session store owes the engine were written as sentences on `SessionStore` (`src/core/session.ts:141-326`: concurrent acquire is one session, `requireExisting` never creates, `forget` never deletes, `noteTaskRun`'s two seats move together, placement obligations 1–6, `ownerOf`'s three states, `register`'s first-write-wins, `fork`'s ownership inheritance and committed-leaf snapshot) and on the session-tree storage it hands out (`src/engine/session/storage-base.ts:124-190`: the write CAS, a path that stops at a root or the declared floor, a missing parent above the floor is `invalid_session`). The only executable checks were `TtlSessionStore`'s own tests and one narrow kit, `sessionStoreIncarnationContract` (`src/core/store-contracts/session-store-incarnation-contract.ts`). A backend implementing `SessionStore` directly (the server's SQL stores and its local-lane store) re-wrote its own copies of these checks or none (`test/core-probe-session-stores.test.ts` pins `TtlSessionStore`'s observed behavior from the outside).
114
+ - **Now.** `sessionStoreContract(make, { deleteSession, reopen, replica?, runAssertion?, onOptionalMember? })` (`src/core/store-contracts/session-store-contract.ts`; export `src/index.ts`, the `// #1468 S1` block) runs 43 cases in eight groups: `[acquire]` (5), `[tree]` (8, the storage the store hands out), `[rows]` (4: a session lives in the rows; a leaf switch persists by itself; a woken session — whole or from a bounded window — rebuilds the context the writer saw; `forget` keeps history), `[fork]` (3), `[list]` (3; activity order is judged on `lastActiveAt` itself, ties in either order), `[placements]` (8: obligations 1–6, the creation squat `TtlSessionStore` refuses, a non-declaring store still creating, and `listPlaced`), `[ownership]` (6) and `[incarnation]` (the six cases of `sessionStoreIncarnationContract`, the same case text: both kits now register them through one module-internal function). Only today's obligations are encoded; an optional face a store lacks is reported through `onOptionalMember` (`verified` / `absent` per face, `replica` for the optional hook, and `requireExisting` for a store that takes the interface's other legal mode and rejects the option — such a store passes, its sessions are read back with a plain reopen), never passed in silence. One sentence the interface states today is not a case yet: an ownership row outliving its session across a restart (`session.ts:300-302`) — the bundled `TtlSessionStore` keeps owners in process memory (`src/core/session-store.ts:117-121`) and would fail it; the row-cell owner that makes it true is stage one S2. Same harness shape as the other store kits (`ContractAssertionRunner`; vitest-free). `reopen` may resolve later (a backend whose restart must first release an exclusive lock, such as `FileStorageBackend`). The hooks record is read by the engine's receipt law (`receiveOwnProperties`, `src/core/own-property-receipt.ts`) and a record the kit cannot run with — `make`, `deleteSession` or `reopen` not a function, or `replica` / `runAssertion` / `onOptionalMember` present and not a function — is refused synchronously with `config.invalid_argument` naming the hook, before any case is registered (never a `TypeError` from inside a case).
115
+ - **Server counterpart.** The server mounts the incarnation kit on its SQL stores in `test/store-contract-kit-db-integration.test.ts` §9 (`createVitestCaseRunner` wipes the session tables per case); the same binding shape runs this kit. Run against the server's `LocalSessionStore` (read-only, this tree's kit over the server checkout) the kit names three divergences from the interface: a fork copies an in-flight assistant turn (`src/plugins/local-session-store.ts:1002`; the SQL twins' `fork` at `src/plugins/pg-session-storage.ts:648` copies every event too), a fork without an owner argument does not take the source's owner, and a first `register` whose owner is `undefined` resolves instead of being refused.
116
+ - **Behavior narrowing: none.** No production path calls the kit; the incarnation kit's six cases are unchanged (same names, same assertions, `test/backlog1468-s1-session-store-kit.test.ts` K3).
117
+ - **Who is hurt.** Nobody: one new function and one new hooks type. A backend that adopts the kit may find red cases — those are divergences from sentences the interface already states.
118
+ - **How to migrate.** A store implementer mounts `sessionStoreContract(() => freshStore(), { deleteSession, reopen, runAssertion: (name, fn) => void it(name, fn) })` and asserts the `onOptionalMember` report for the faces it relies on. *server*: mount it next to §9 on the SQL twins, `CachingSessionStore` and `LocalSessionStore`; the three divergences above are the first reds.
119
+ - **Pins.** `test/backlog1468-s1-session-store-kit.test.ts` (K1: four lanes green — `TtlSessionStore` over the in-memory repo, the same reopened from a bounded window, over `FileSessionRepo` with placements declared, and `FileStorageBackend.sessionStore` — each with its exact face report; K2: three positive controls (the memory lane, the file lane, a store that rejects `requireExisting` outright) and twenty-one mutants of the bundled store, each reddening the case that pins its obligation — among them a read-then-write `register`, a leaf switch kept only in the handle, and a lost CAS refused with a hand-rolled error that only carries `code: "conflict"`, which the kit refuses because the engine's reconcile retry (`isSessionConflict`) does not recognize it; K3: the incarnation group is the standalone kit's six cases; K4: the root export).
120
+
121
+ ### (7.55.0 theme release, #1468 stage one K2) The computations and judges a store backend must answer exactly as the bundled stores do are package-root exports of the very functions those stores call: `canonicalJsonStringify`, `parkedApprovalCardOf` (+ type `ParkedApprovalCardReading`), `admitSessionPlacement`, `incarnationCell` / `forkRowCells` / `assertForkTarget`, `readIncarnationCell`; core's three literal approval-gate pairs read the pause registry (#1468; additive exports; behavior narrowing: none; hard-breaking: none; closed sets: none; exports: +7 functions, +1 type; members: none) (@server)
122
+
123
+ - **Before.** A backend that stores what core mints had to re-write four of core's computations, because core kept them inside its modules: the canonical JSON the memory export bundle hashes over (`canonicalJsonStringify`, in the memory File backend, not exported), the card a parked approval gate carries (an inline literal pair in `summarizeCheckpoint`), the placement admission of `acquire` (a private `TtlSessionStore` method, with the partition declaration read off `this`), and a session row's occupant cells (`incarnationCell` / `forkRowCells` / `assertForkTarget`, reachable only through the internal harness facade); a stored incarnation cell had no shared decoding at all. Core itself spelled "is this an approval gate" as a literal `human` / `irreversible_ask` pair in three places instead of reading the registry.
124
+ - **Now.** (1) `canonicalJsonStringify(value)` moved verbatim (byte-identical body) from `src/core/memory-engine/file-backend.ts` into the neutral leaf `src/core/canonical-json.ts:423`, beside `canonicalize`; the File backend, the engine and the bundle module import it from there (no re-export left behind). It is not `canonicalize`: that one is the type-tagged, length-prefixed, non-JSON serializer of the bound-input hash, deliberately unexported because nobody may recompute that hash across the wire; the two answer different questions and their outputs are not interchangeable, so they are not merged. Server counterpart: `canonicalJson` (`src/canonical-json.ts:40`, a verbatim port; `canonicalJsonStrict` is server validation policy and stays). (2) `parkedApprovalCardOf(gate)` (`src/core/pause-registry.ts:81`) answers four ways: `{ kind: "card", card }` (an approval gate carrying a closed-set word), `{ kind: "no_card" }` (an approval gate with no card), `{ kind: "unknown_word", word }` (an approval gate carrying anything else, `word` the value as read), `{ kind: "not_approval" }` (a registered gate whose `pending` column is not `tool_approval`, or a kind the registry does not hold). Each gate member is read once; it is pure and says nothing about loudness. `summarizeCheckpoint` projects `approvalCard` only on a `card` answer. Server counterpart: `parkedApprovalCard` + `readApprovalCard` (`src/approval-content-kind.ts:51` / `:31`). Name and shape differ from the design draft's `approvalCardOf(gate) → card | undefined`: core already has an internal `approvalCardOf(call, declared)` (the card a live call renders under) and one name must not mean two functions; a two-answer result would have left server re-reading the registry and the raw card to tell an out-of-set word from absence. (3) `admitSessionPlacement(sessionId, persisted, request, capability: { subagentPlacement })` (`src/core/session-store.ts:113`) is the retired private method's body with the declaration as an argument; `TtlSessionStore` calls it on its four resolution paths (cache hit, pending-promise inherit, open, idempotent re-create) and reads its declaration at each call. Server counterpart: `assertPlacementAdmission` (`src/plugins/session-placement.ts:151`, a declared-partition store: `subagentPlacement: true`). (4) `incarnationCell` / `forkRowCells` / `assertForkTarget` (`src/engine/session/repo-utils.ts:19` / `:43` / `:38`, the bundled repos' one mint) are root exports, plus `readIncarnationCell(raw, disclose)` (`:29`): a non-empty string is the token; `undefined` / `null` is an unknown occupant, silently; `""` or a non-string is an unknown occupant and `disclose` is told once with the cell's shape, never its value. Exported only: the File repo's header read is unchanged (it still hands out `""` as a token and still discloses `null`); switching core's and server's readers to the decoder is a later change. Server counterpart: `mintSessionIncarnation` / `incarnationFromCell` / `SessionIncarnationMissingError` (`src/session-incarnation.ts:45` / `:103` / `:77`). (5) The policy-ask capture lane keys on the row's registry `resume` column (`resumeGateMatches(kind, "policy_ask")` over the row's kind read once, `src/core/runner/resume-policy-outcome.ts:78-79`), the wake refusal names its decide from the same column (`src/core/runner/resume-admission.ts:22` / `:242`; a registry row with a new resume word now fails the build until its copy exists), and the summary's card reads the `pending` column through (2).
125
+ - **Behavior narrowing: none.** Every existing reader answers byte-for-byte as before: the summaries of every gate kind × card value, the placement outcomes of every resolution path (declared and undeclared, the declaration set or withdrawn after construction), the wake refusal copy per gate kind, the policy lane on a question row and an approval row, the bundle and erasure-selector hashes, and the File header's incarnation read were printed from the base tree and from this tree through their real entry points and compared identical (28 547 bytes). One read-count change, reachable only through a stored row whose gate is accessor-backed (no bundled or SQL store returns one; a custom store that hands back live objects could): the row's gate kind is now read once per judgment where it was read up to twice (the policy-ask lane, the summary's card), so an accessor that answers differently on a second read now gets the lane / card of its first answer. The placement declaration keeps its read position exactly: read only when the mirror direction is judged, after the request's members.
126
+ - **Who is hurt.** Nobody: additive exports. The serializer's old module path was never importable (the package's `exports` closes deep paths), so its move breaks no import.
127
+ - **How to migrate.** *server*: `canonicalJson` becomes `canonicalJsonStringify` (the same body, so the same bytes on every input, the two inherited non-JSON edges included); `parkedApprovalCard` reads `parkedApprovalCardOf(gate)` and counts `server.approval-card.closed-word-out-of-set` exactly on `unknown_word` (the current tuple and SQL `IN` literal `APPROVAL_GATE_KINDS` / `_SQL_IN` retire with the stage-two checkpoint car); render `word` only bounded and redacted (it is the stored value, unscreened), and do not let an `unknown_word` go silent (warn). `assertPlacementAdmission` becomes `admitSessionPlacement(id, persisted, opts ?? {}, { subagentPlacement: true })` — one sentence of copy changes (the absent-tuple refusal ends "or the repo dropped the tuple" instead of "or the tuple was written away out of band"). Mint new rows with `incarnationCell(undefined)` / `forkRowCells(source)`; adopting `readIncarnationCell` in place of `incarnationFromCell(…, "refuse")` turns a missing cell from a thrown 500 into an unknown occupant plus a disclosure: that change is stage two's, after the one-time backfill window closes. *cli* / *client-core*: nothing.
128
+ - **Pins.** `test/backlog1468-k2-laws.test.ts` (① root identity, the old home keeps no re-export; ② golden bytes and the inherited edges; ③ bundle identity hash; ④ four answers over every gate kind × card value; ⑤ one read per member; ⑥ the summary's card is the reading's; ⑦ the policy lane on a question row and an approval row; ⑧ the wake copy per kind, byte-exact; ⑨ the admission against the retired rule over 150 cells; ⑩ the four store paths, declared / undeclared; ⑪ the declaration read at the call; ⑫ the decoder table; ⑬ the cell functions; ⑭ the File header read unchanged); `test/design178-v2b-erasure-evidence.test.ts` imports the serializer from its home.
129
+
130
+ ### The question a server's tool meets when nobody granted it is cleared by the allow layer or by an answer given for the bytes that run, and by nothing else: a name-list policy's hit is no objection, never consent (#1359; behavior narrowing, ruled: clay C-R638 (2026-10-18); **hard-breaking, ruled: clay C-R638 (2026-10-18): `createAllowDenyPolicy`'s `scope` option is retired, refused by name**; closed sets: none; exports: none added or removed — `createAllowDenyPolicy` loses one option; members: none) (@server @cli @client-core @test)
131
+
132
+ - **Before.** At the fold's last station a call to a tool whose permission seat is `passthrough` (every MCP server tool and A2A skill) met the mode's `unstated` question only when the surviving allow carried no `decisionReason`. Any layer could therefore skip the question by putting a reason word on its allow: `createAllowDenyPolicy`'s allow list (unless `scope: "narrowing"`), `createApprovalPolicy`'s `autoAllow` and `createCoarseCommandNamePolicy`'s command list stamped `"rule"` on a hit; a host policy's own `{ action: "allow", decisionReason: … }` did the same; the fold carried the first such word out, and a delegated child's final-form re-check restated it. So "does this need asking" had two answers — the allow layer's members and a policy's word.
133
+ - **Now.** One rule (`asksUnstated`): a passthrough-seat call whose fold allow is not covered by an ANSWER — an engine record of a settled wait whose license covers the bytes that run, i.e. a person's or the deployment approver's yes (`createApprovalPolicy`'s `approve`, the ask resolver, an inherited approver) over those arguments — meets the question, and the allow layer answers it (`RunnerDeps.allowedTools`, `TaskSpec.allowedTools`, a stored row, a skill's list, a PreToolUse hook's own `allow`), as do dontAsk / auto / a person as before. `decisionReason` takes no part. A yes given for other bytes covers nothing: a later layer that rewrites the arguments leaves the call asked. The approved-edit round reads the same rule on the edit's final form. The three factories answer a bare allow on a hit; the license comparison has one judge, shared with the gate's settlement ledger. A license is compared by the EXACT digest only: arguments whose canonical form the serializer had to shorten (nesting past its depth cap, an unreadable property or structure, a function, a stateless object brand) are covered by no answer, so such a call meets the question again after its approval, and its settlement is not attributed on `tool_end` (the ledger shares the judge). A decision receipt's detached copy of a rewrite keeps the record of which bytes a tool's argument reading took it from, so an approved edit the reading normalizes (`"2"` → `2`) is still covered. The headless refusal's remedy for the question now names the allow layer (`grant it with an allow entry for its name (RunnerDeps.allowedTools / TaskSpec.allowedTools)`), not a policy.
134
+ - **Hard-breaking (ruled: clay C-R638, 2026-10-18).** (1) **What changed:** `createAllowDenyPolicy({ scope })` is retired — passing it throws `config.deps_retired_key` naming the replacement (an allow list is only ever a narrowing); an allow-list hit, an `autoAllow` hit, a command-list hit or a host policy's reason word no longer spares a passthrough tool the `unstated` question: in `default` / `acceptEdits` / `plan` / `auto` mode with an approver the call now carries a card first; in `dontAsk`, and on a leg with no approver (headless), the call that ran before is now **refused, with no card** (`deniedBy: "mode"` under dontAsk; `ask_resolution` / `no_approver` headless). (2) **Who is hurt:** a host that says "these tools need no asking" with a name-list factory or a reason word on its own policy's allow — server's per-leg auto-approve budget (`createDurableAskPolicy`'s budget arm, `src/approval.ts:423` → `grantedByRule()` `:332`, `decisionReason: "rule"`; its outside-the-list arm `:372` and its session-exemption hit `:404` already answer a bare allow and are unaffected), cli's settings seam (`policyFromPermissions`, settings allow → `createAllowDenyPolicy`) — sees MCP / A2A tools start carding, and refused where nobody can be asked; a host passing `scope` fails to compile / is refused at construction. (3) **How to migrate:** write the consent as an allow entry in the same grammar — `RunnerDeps.allowedTools: ["mcp__<server>__*"]` for the deployment's, `TaskSpec.allowedTools` for a request's; a consent decided per call (an auto-approve budget) is an ANSWER, so give it through an approver — `createApprovalPolicy`'s `approve` resolving `{ allow: true, settledBy: "policy" }`, or the ask resolver — and the engine records it for the call's bytes; keep the factories for what they still do (refuse what a list does not name, `denyByDefault`'s exceptions, the approval wait); drop `scope` (client-core's `AllowDenyPolicyOptionsView.scope` mirror goes with it).
135
+ - **Behavior narrowing, two shapes that ran unasked before (ruled: clay C-R638, 2026-10-18).** (a) An ancestor's PreToolUse screening that answered `allow`, replayed in a delegated child as an inherited constraint, no longer clears the child's question (an inherited constraint only tightens; the child's own hook member still answers) — who is hurt: a deployment whose child leg runs a different screening chain than its parent (its own `mods`, a chain the child's admission refused, a different matcher) and relies on the parent's `allow` to wave a passthrough tool through; a child that runs the same chain clears the question with its own hook member, as before. (b) An ancestor's frozen classifier that allowed an ancestor's ask settles no wait, so a child's passthrough call still meets its own question — who is hurt: an `auto` child pays one more classifier round on such a call (a `default` child, a card).
136
+ - **Behavior narrowing, the write gate's session exemption (ruled: clay C-R638, 2026-10-18).** (1) **What changed:** `createFsWriteGatePolicy`'s `isExempt` still answers `allow`, but for a write tool whose permission seat is `passthrough` (an MCP / A2A tool declaring a write `pathTarget` through `toolFaces`) that allow is no answer, so the call meets the `unstated` card again after the session exemption — `Write` / `Edit` / `NotebookEdit` (no seat) are unchanged. (2) **Who is hurt:** a deployment that records "allow for this session" grants through `isExempt` and serves MCP write tools under a mode whose own write gate lets the write through (`acceptEdits`): there the call ran with no card and now carries one `unstated` card; under `default` the mode's write gate already puts an `ordinary` card to such a write, which the exemption never cleared, so nothing visible changes. (3) **How to migrate:** grant the tool in the allow layer (`TaskSpec.allowedTools` per session / `RunnerDeps.allowedTools`); folding the write gate's session exemption into the rule store's session partition is a separate design step (#1360).
137
+ - **For the consumers (release-post sentence).** From 7.54.0 the server's per-leg auto-approve budget (`approval.ts:423`) no longer spares MCP / A2A tools: each such call meets the `unstated` card, and is refused where nobody can be asked; the budget for the engine's own tools is unaffected. To migrate, move the budget to the approval seat, so that it answers that card.
138
+ - **Pins.** `test/backlog1359-allow-layer-only-home.test.ts` (D1–D8 real Runner over a real stdio MCP server; G1–G5 the gate's station over each producer's real allow; N an inherited approved edit the reading normalizes; L one license judge, the ledger and the station row by row; source pins of the retired readings); re-pinned (the pins move with the rule; no fix of those tickets): #1065 `test/backlog1065-mcp-default-ask.test.ts` ③ (3c → a deployment allow entry; 3c' / 3d now card), ⑭b, ⑬c, ⑬d; #794 `test/backlog794-inherited-classifier-round.test.ts` ③; #1314 `test/backlog1314-unavailable-arm-remedy.test.ts` ⑪ (the `unstated` remedy sentence names the allow layer); `test/inherited-gate-pretooluse-hook.test.ts` (the screening-face mapping: an inherited constraint's `allow` carries no reason word).
139
+
140
+ ### `memory.enabled: false` is a tighten-only seat like every other: `tightenTaskSpec` refuses an override that turns it back on, and the child side reads the same clamp-table row instead of a hand-spelled `memoryOff` (#1567; behavior narrowing, ruled: clay C-R638 (2026-10-18); behavior change, the loosening direction, ruled: clay C-R646, 2026-10-19: an override writing `memory: undefined` / `null` no longer erases the base's declaration; **hard-breaking, ruled: clay C-R638 (2026-10-18): the public type member `ParentSpecSeat.memoryOff` is removed and `ParentSpecSeat.memory` (`{ readonly enabled: false }`) takes its place; `createSubagentResume`'s `currentClamps.memoryOff` is refused as a retired key**; closed sets: none; exports: none; members: −1 `ParentSpecSeat.memoryOff`, +1 optional `ParentSpecSeat.memory`, `createSubagentResume` deps `currentClamps.memory` replaces `currentClamps.memoryOff`) (@server @cli @client-core)
141
+
142
+ - **Before.** Two readings of one fact. The clamp table (`CLAMP_SET`) listed every seat that only tightens, and `tightenTaskSpec` merged by it — but `memory` was not a row, so an override's `memory` object replaced the base's whole: a base `{ enabled: false }` merged with an override `{ enabled: true }` came out ON, silently. Separately the child side carried "this run turned memory off" as a hand-spelled `ParentSpecSeat.memoryOff` (minted by one line, applied by another, folded for a retained child's wake by a third), which the table and its gate could not see.
143
+ - **Now.** `memory` is a row of the table: a FIELD row — an object-valued seat whose one member (`enabled`, restricting value `false`) is the clamp; the rest of the declaration (scopes, write scope, knobs) is the caller's own. One row, every reader: (1) `tightenTaskSpec` — a base `memory.enabled: false` meeting an override that states `enabled: true` throws `TaskSpecTightenError` ("cannot loosen memory.enabled from false to true"), exactly as every other restricting seat does; an override that declares `memory` without stating `enabled` (or with `undefined`) loosens nothing and its declaration stands whole with `enabled: false` stated on it; an override that does not declare `memory` keeps the base's; the later declaration is never mixed with the base's members. (2) The spec seat: a run whose spec states `memory.enabled: false` hands its children `ParentSpecSeat.memory = { enabled: false }` (the clamped member alone — the parent's scopes never ride it), and `applyParentSpecSeat` rewrites that one member on the child's own declaration, keeping everything else it authored. (3) The revival fold and the waker's clamp seat (`currentClamps`) carry the same row, so a retained child woken by a run that is itself off wakes off. The mint, the apply, the resume fold and the send lane's clamp record are table readers now (`applyClampSeats`, `clampFieldSeatsOf`); `memoryTurnedOff`, the hand-written mint line and the send lane's second record line are deleted. `gate:child-clamp-seats` grows the field-row legs: a field row rides the spec seat only and is never script-declared, its carried member must be exactly `{ readonly enabled: false }`, and a `ParentSpecSeat` member that names no `TaskSpec` member (the retired `memoryOff` shape) is red.
144
+ - **Behavior narrowing (ruled: clay C-R638, 2026-10-18).** `tightenTaskSpec(base, overrides)` with a base `memory.enabled: false` and an override stating `enabled: true` now throws where 7.53.0 returned a spec with memory on. An override declaring `memory` beside a base that is off now keeps `enabled: false` where 7.53.0 dropped it.
145
+ - **Behavior change (ruled: clay C-R646, 2026-10-19; the loosening direction).** A table row's law is "an override writing `undefined` erases nothing", and `memory` is a row now, so the law reaches the whole declaration: `tightenTaskSpec(base, { memory: undefined })` (and an untyped `memory: null`) keeps the base's `memory` declaration — the memory face stays mounted — where 7.53.0's plain spread erased it (no memory face). One law for every seat, not a rule for this one. To switch memory off in an override, write `memory: { enabled: false }`; `undefined` / `null` no longer mean "off". Nothing in the engine builds that override (the governed workflow lane's overlay cannot carry `memory`), so the readers it can reach are direct callers of the exported `tightenTaskSpec`.
146
+ - **Hard-breaking (ruled: clay C-R638, 2026-10-18).** (1) **What changed**: `ParentSpecSeat.memoryOff?: true` (a member of the exported `ParentSpecSeat` type) is removed; the same fact is `ParentSpecSeat.memory?: { readonly enabled: false }`. `createSubagentResume`'s `currentClamps.memoryOff` is refused by name (`config.deps_retired_key`, the sentence names `currentClamps.memory: { enabled: false }`); `currentClamps.memory` is the seat. A seat that still carries `memoryOff` (a host's `enrichCtx`, a hand-built tool context, `RunWorkflowToolDeps.parentSpecSeat`) is refused by name too — `applyParentSpecSeat`, the tool-context reader and the `SendMessage` wake each throw `config.deps_retired_key` ("ParentSpecSeat.memoryOff is retired"), never read it as absent, which would have dropped the restriction. The seat's `memory` member is frozen at mint, like `envFacts`, `compaction` and the two directory lists (the seat's `clientContext` and `mods` members are passed by reference, as before). (2) **Who is hurt**: a host that reads or builds `ParentSpecSeat.memoryOff` or passes `currentClamps.memoryOff` (none in cli or client-core source; server reads nothing of it at runtime, but its `test/memory-off-request.test.ts` ⑥ pins the exact text of the retired mint and apply lines in core's `parent-spec-seat.js`, which goes red when it takes this version); a caller of `tightenTaskSpec` that relied on an override turning a base-off memory back on (the merge now throws). (3) **How to migrate**: read `seat.memory?.enabled === false` where you read `seat.memoryOff === true`; build `{ memory: { enabled: false } }` where you built `{ memoryOff: true }`; a `tightenTaskSpec` caller that must start from a memory-on base states the base without `enabled: false`. *server*: re-anchor `test/memory-off-request.test.ts` ⑥ ①② on the seat's `memory` member and the table's apply (the behavior it pins — a parent off ⇒ children off, a child's own `enabled: true` cannot lift it — is unchanged); `src/http/wire-types.ts` names `memoryOff` in a comment. *client-core*: docs and two comments only — the S-3 consumption rows of `docs/INTEGRATION-CLIENTS.md` name `ParentSpecSeat.memoryOff?` / `currentClamps.memoryOff?` as declined typeKeys, and `src/request/taskRequest.ts` mentions the member in two comments; the package reads neither. *cli*: nothing (its `memoryOff*` identifiers are its own request-side names).
147
+ - **Other members of the declaration.** `scopes`, `writeScope`, `scopeContract`, `trustedTools`, `execIsExternalContent`, `scopeOrigins` and `projectKey` stay the later author's, whole (naming layers or tools; mixing a base's `writeScope` into an override's `scopes` would mint a layering nobody wrote). Not closed here and now a finding rather than a row: `capture: "off"` and `writeScope: null` (read-only) also narrow, and a later declaration that omits them still drops them in `tightenTaskSpec`; the table's one-row-per-seat shape needs a decision on a second clamped member of one seat before they can be rows.
148
+ - **Pins.** `test/backlog1567-memory-clamp.test.ts` (F1 the cx01 shape and its neighbours; F2 no floor; F3 tightening; F4 each other member is the later author's; F5 type-level agreement of the table, the seat and the waker's record; the field row's readers — projection, fold, apply, seat mint / apply; the gate over mutated trees, including the old `memoryOff` put back); `test/backlog863-memory-off-floor.test.ts` and `test/backlog1052-clamp-set.test.ts` re-anchored on the `memory` row.
149
+
150
+ ### An allow member clears a standing ask exactly when the ask's class row says it may — one reading, the classification the card carries; `bypassPermissions` refuses an unresolved write target only where that class is itself immune, and reads a classification's face from one place (#1329 C1a; behavior change, ruled: PM C-R645 → clay C-R646, 2026-10-19; behavior narrowing for a host's own `runToolGate` input only; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `AskClassification.askFace`) (@server @client-core @cli @test)
151
+
152
+ - **Before (v7.53.0).** The gate's allow layer — a hook's allow, `RunnerDeps.allowedTools`, a stored allow row, a skill's `allowed-tools`, `TaskSpec.allowedTools`, the read-only shell reader, an extension's `tool.check` allow — cleared an ask only when three readings agreed: a structural predicate (the org layer spoke, `requiresRealApproval`, a hook's ask, the question tool, a marked call), the mandate derivation's enforcement output (its doors and the tool / seat marks), and the class row's `ruleMayClear`. The first two answered for doors the third already held, so changing one class row changed nothing anyone could see (the removal breaker's row could be flipped with no pin going red). One input of the gate had a mandate word and no class: `runToolGate`'s `egress: true` beside `shellGated: true` — its ask was class `ordinary`, held only by the second reading. A Runner never hands the gate that pair: a `ToolSpec.egress` on a shell-face name keeps the shell seat off the tool (`shellGated` is false for a name the deployment marked), so on the Runner such a tool's ask was already `operator_forced`.
153
+ - **Now.** One reading: the standing ask's class row (`classifyAsk`, the classification the card's `askClass` / `classes` carry). The `egress` mark is a class on every seat, classed by who put it there like every other tool mark (`operator_forced` for the deployment's own declaration, `tool_marks_mcp` / `tool_marks_a2a` for a server's or a peer's), whatever `shellGated` says. Over every combination of the inputs the three readings took (501 568 cells: every mark × provenance × tighten record × decision shape, every decision-member combination × gate-fact shape, and a seeded sample of the whole product) the one reading clears exactly what the conjunction cleared.
154
+ - **Behavior narrowing, a host's own `runToolGate` input only (open to veto).** No Runner deployment changes here. A host that calls the root-exported `runToolGate` itself with `shellGated: true` and `egress: true`: its asks carry the tool-mark class, so `bypassPermissions` keeps the question (v7.53.0: released, class `ordinary`), `acceptEdits` no longer answers a file command by itself, and the card names `operator_forced` (or the MCP / A2A word) where it named `ordinary`. With `target_unresolved` beside it the question goes to a person too (v7.53.0: released) — see the next bullet. A stored allow row still does not clear it — unchanged.
155
+ - **Behavior change at the mode point (ruled: PM C-R645 → clay C-R646, 2026-10-19).** `bypassPermissions` refuses an ask holding `target_unresolved` only where THAT class is bypass-immune on the call's face — every face but the shell's, which releases it — instead of wherever the folded row is immune. Before (v7.53.0), on the shell face (`Bash`, `Monitor`) a set holding `target_unresolved` beside ANY bypass-immune class was refused outright (`denied_by_mode`, nobody asked); now that immune class keeps the question and the call reaches a person or the park (`ask`). Against v7.53.0 the mode point's input-domain table (968 000 cells × six modes, every classification `classifyAsk` derives) moves 73 641 cells: 71 640 are this transition — shell face, `bypassPermissions`, `denied_by_mode` → `ask`, `target_unresolved` in the set — and 2 001 are the `egress` cells of the bullet above (`released` → `ask` 1 785, `accepted` → `ask` 81, both 135; a host's own `runToolGate` input only). Zero cells move to `released` and zero to `denied_by_mode`: nothing is let through that was asked, and no new hard refusal appears. The immune companions seen beside `target_unresolved` in the moved cells, the whole set: `operator_forced` (a deployment-authored ask, a self-declared `mandated`, the `egress` cells above), `rule_unreadable`, `ask_rule`, `hook`, `requires_user_interaction`, `shell_floor` (the operator's `shellGate: "always"`), `policy_rra`, `denial_limit_fallback`, `classifier_no_ruling`, `dangerous_delete`, `unverifiable_delete`, `unverifiable_shell_text`, `org_rule`, `ancestor`, `content_question` — every bypass-immune class. No other face, mode or class set moves; a set with `target_unresolved` and no immune class is released on the shell face and refused on every other face, as before.
156
+ - **`AskClassification.askFace` (+1 optional member) — the one carrier of the face the mode point reads.** The tool face the classification was derived on. Present exactly when it was derived with a face (`AskClassFacts.askFace` set — the gate's own classification of every call, and every inherited station's); absent when it was derived face-less (a host's own `classifyAsk` call with no `askFace`; the engine's fixed classifications for the shared-artifact read and the ancestor-unresolved remedy), which reads as the `other` face. `arbitrateByMode` and the headless remedy read whether a mode can release the ask from the class set on this face alone — never from `row.bypassImmune` — so a classification a host builds itself cannot answer two ways: with no `askFace` it is read on the `other` face whatever face its `row` was folded under (fail-closed: an unresolved target is refused). For every classification `classifyAsk` derives, `row.bypassImmune` and this reading agree. The wire manifest records the member (+1 `typeKeys`). *server*: a reader that projects or alerts on a classification it received from core may see the member; a classification it builds itself for `arbitrateByMode` must carry `askFace` to be read on the shell face (@server).
157
+ - **Who is hurt.** A deployment or probe that pinned the shell-face hard refusal of an unresolved write target beside an immune class (`denied_by_mode`) now sees the question asked (an approver answers it, or a park holds it; with no approver it is the headless refusal of an ask) — on a Runner, e.g. `touch loop/x` whose target the environment cannot resolve, under `bypassPermissions`. A host that calls `runToolGate` itself with `shellGated: true` + `egress: true`: the question is kept under `bypassPermissions` and its `askClass` reads `operator_forced` instead of `ordinary`. A host that builds its own `AskClassification` with its `row` folded on the shell face and no `askFace`: read on the `other` face now.
158
+ - **How to migrate.** Nothing for a Runner deployment beyond answering the ask (an approver, a park). A host driving `runToolGate` itself: pass `egress` only where a person confirms the tool's external writes. A host building an `AskClassification` by hand: set `askFace` beside the `row`, or take the classification from `classifyAsk`. *server*: keep `mandated` in the standing-answer predicate (`standingAnswerHome`) — an inherited station hands its frozen approver a card with `classes: ["ordinary"]` and `mandated: true` (the ancestor's question), so the class row alone would let a remembered release answer it; only `requiresRealApproval` there is redundant with the class row (every real-approval ask holds a class whose row says no). *client-core* / *cli*: no wire shape changes — the class words are members of the existing closed set.
159
+ - **Pins.** `test/backlog1329-gate-one-reading.test.ts` (① a stored allow row over every door, ② the read-only reader over the same doors, ③ the `runToolGate` shell-seat `egress` under `bypassPermissions`, with and without an unresolved target beside it, ③c a host-built classification is read on its `askFace` alone, ④ a real Runner: the removal breaker under a whole-tool allow entry, ⑤ the read and the card are one classification); re-pinned: `backlog997`, `backlog1111`, `backlog1139`, `backlog1303d` (the mandate derivation no longer has an enforcement output — the class row is asserted where it was); `backlog1423` (the shell face's unresolved target beside an ask rule or an unreadable rule: `ask`, not `denied_by_mode`); `backlog955` F5 (a real Runner: `touch loop/x` whose target the environment cannot resolve, under `bypassPermissions` — a card `unverifiable_shell_text`, not `denied:mode`); `backlog1212` (its hand-built classification carries the face it folds under, as `classifyAsk` writes it).
160
+
161
+ ### A parked approval covers ONE object — its tool (identity and face), where its target resolved, and a Skill card's lists — judged by one judge at three stations of the resume; a moved face or target vetoes the call (`resume.target_moved`) (#1391 / #639 / #1185; behavior change and behavior narrowing, ruled: clay C-R638 (2026-10-18) — the Cost bullet stays open to veto; hard-breaking: yes, four; closed sets: `REQUIREMENT_WORDS` +1 `approval_targets`, the engine-minted `tool_end.errorCode` words +1 `resume.target_moved`; exports: −1 function `judgeParkedToolIdentity`, +2 functions `approvalObjectOf` / `judgeApprovalObject`, +2 constants `APPROVAL_STATION_MEMBERS` / `APPROVAL_TARGET_ROLES`, +7 types; members: −1 optional `pendingAction.rosterDigest` (no longer minted), +1 optional `pendingAction.targets`; `parkedToolIdentity` no longer returns `rosterDigest`) (@server @cli @client-core @test)
162
+
163
+ - **Before (v7.53.0).** A parked call's row bound its argument bytes, a Skill card's lists and the tool's four identity members, each judged by its own check at its own place; the tool's FACE (display family, declared path target) was copied onto the row and never compared, and where the target resolved was not recorded at all. A resume after the tool's `pathTarget` declaration changed, or after a link the call names was re-pointed, executed the approved bytes against the new object — a person approved reading `/out/a.txt` and the resumed call read whatever the link pointed at by then.
164
+ - **Now.** (1) The row's approval object (`approvalObjectOf(row)`) is the tool (four identity members + face), the targets (new row member `targets`: per reading of the call's path slot — `protective` / `declared` — the location it resolved to when the card was asked, copied from the gate's own reading of the parked bytes, or `null` when the environment was asked and did not answer, so a target that resolves later counts as moved) and the grants (the existing `boundGrantDigest`). (2) ONE judge, `judgeApprovalObject(row, live, station)`, and ONE table, `APPROVAL_STATION_MEMBERS`: before the claim the grants (refused, the row pending — unchanged); before the resumed session is touched the tool; right before the execution, after every station that can rewrite the bytes, the tool again (as mounted then: a host's MCP re-dial can replace it between the two stations), the grants and the targets, read again there. (3) The law: the tool not mounted (`resume.tool_unavailable`) or a replacement wearing its name (`resume.tool_contract_mismatch`) ⇒ the row is reopened for the original tool, as before; a moved FACE or TARGET ⇒ the call is vetoed: not run, the approval spent, `tool_end.isError` with `errorCode: "resume.target_moved"` and `gate.disposition` `{ kind: "denied", deniedBy: "policy" }`, the model reads why and re-issues — the new call's card shows the current target. A reopen would keep the decided winner and meet the same mismatch again, so it is not used for these. (4) "Are these the approved bytes" has one judge, the engine's license judge `licenseCovers(row.boundInputHash, bytes)` — the bytes, as the tool reads them, digest EXACTLY as the row's binding (a value whose canonical form a depth sentinel shortened is never the approved bytes; such a value cannot park anyway — the row digest refuses it). A decision that rewrote the bytes to ones the binding does not cover is not judged on targets (the row never recorded a target for other bytes; the person approved those) and is judged by the resumed belts as bytes no gate saw (a deny row; the read boundary's edit seat refuses a read outside the allowed directories); an `updatedInput` the tool reads as the parked bytes — another key order, `"50"` for `50` — is no edit: its targets are judged. The same judge decides whether the gate's target reading describes the bytes a park files. (4b) A parked file approval never meets a moved workspace root: a resume that would execute an approved call on a workspace restored under another root is refused before its leg is built (`resume.env_failed`, nothing runs, the row back to `pending`), as before this change — so the recorded targets are always compared in the root they were recorded in. (5) A `deny` is never judged against the object: it runs nothing (before, an identity mismatch on a deny reopened the row, which kept the deny and reopened again). (6) `rosterDigest` is no longer minted (nothing decided by it). (7) The leg's target-reading stamp now runs whenever the park lane is armed, not only while a lane holds a path rule: the park lane binds the target, so it is a path judge.
165
+ - **Cost (open to veto).** On a leg whose park lane is armed — a checkpoint store together with `durableApproval`, `runtimeCaps.forceDurableGate`, egress / irreversible tools, or a mounted shell (its read station's out-of-root question parks), which covers a typical server leg — every call that names a path takes one more canonicalization of its target at the gate (measured: an in-root `Read` canonicalizes 23 times where it did 22), and one symlink-chain read when the leaf is a link. A leg whose park lane is not armed is unchanged. Who is affected: hosts whose high-frequency path tools run on park-armed legs (latency per call; no change in what is decided).
166
+ - **Closed sets ("this word was the only one, now it is one of two").** `REQUIREMENT_WORDS` +`approval_targets`: a row with a `targets` member lists it, so an OLDER worker refuses such a row as `version_newer` instead of executing a target it cannot judge (the `skill_card_binding` precedent). `tool_end.errorCode` on a vetoed resumed call: `resume.target_moved` (present exactly when the approval object's face or target moved, or its targets member is damaged; absent on a grants veto, which keeps its v7.53.0 shape). A table exhaustive over the requirement words, or a consumer that read every engine-minted `errorCode` on a resumed call as a reopen code, sees one more word.
167
+ - **Hard-breaking (four, ruled: clay C-R638, 2026-10-18).**
168
+ 1. *What changed:* a resumed approval whose tool face or target moved is vetoed (not run, approval spent) where v7.53.0 executed it against the new object. *Who is hurt:* a deployment that changes a caller tool's or an MCP override's `pathTarget` / `family` while approvals wait, and a parked call whose link is re-pointed before the decision — each waiting approval is refused once and asked again (a workspace restored under another root is not among them: its approved resume is refused before the leg is built, unchanged, (4b)); a probe pinned on "face changed ⇒ still executes". *How to migrate:* nothing to change in code: the model re-issues, a person approves the new card. A face change that is truly the same tool is meant to cost exactly that — each waiting approval vetoed once and approved again; do not bump `implementationRevision` for it (a replaced tool's reopen never meets the original again, so the row would stay pending with nothing able to run it).
169
+ 2. *What changed:* `judgeParkedToolIdentity` is removed from the package root. *Who is hurt:* a host that judged a row's identity itself. *How to migrate:* `judgeApprovalObject(row, { tool: rosterEntry }, "session")` — `"match"`, or `{ member, disposition, code, message }` (`code` `resume.tool_unavailable` / `resume.tool_contract_mismatch` / `resume.target_moved`).
170
+ 3. *What changed:* a row with `targets` carries the requirement word `approval_targets`; `rosterDigest` is no longer written. *Who is hurt:* a mixed fleet where an older worker resumes a row a newer one parked (refused `version_newer`, the row pending); a store or projection that persisted `rosterDigest` as a column (it stays absent on new rows; old rows keep it and nothing reads it); a store that whitelists row members and drops `targets` (the row's digest then fails: `requirements_damaged`). *How to migrate:* *server*: upgrade every worker that resumes rows before parking on the new one; a SQL store that column-izes `pendingAction` members stores `targets` (a record of at most two roles, each one canonical key — an absolute path — or `null` for a target that did not resolve when parked; a NOT NULL text column breaks on it) or keeps it in the JSON payload; drop any `rosterDigest` column read. *cli*: the vetoed call's result carries the reason and the model's re-issued call parks a new card whose args name the current target — render as any denied tool result; `errorCode` `resume.target_moved` is available to key on.
171
+ 4. *What changed:* the root export `parkedToolIdentity(roster, toolName)` no longer returns `rosterDigest` (it returns the four identity members, `Partial<ToolIdentity>`). *Who is hurt:* a host that read `parkedToolIdentity(...).rosterDigest` — it was a string in v7.53.0 and is `undefined` now; a type that spread the old return shape. *How to migrate:* nothing decided by the roster digest; to know where a parked call's target resolved read the row's `pendingAction.targets`, and to judge a row's tool read `judgeApprovalObject(row, { tool }, "session")`.
172
+ - **Known limit.** The execution station reads where the target resolves and the tool then resolves the spelling again; a link moved inside that window still reaches the new target for a caller or in-root file tool (an out-of-root file read is held to the read station's admitted key by the tool): `KL-1391-a`.
173
+ - **Black-box criterion (for the criteria post).** On a leg with no path rule: park an out-of-root `Read` of a symlink (durable approval, no live approver); before deciding, re-point the link to another file; decide `allow` ⇒ the resumed `tool_end` for that call is `isError` with `errorCode: "resume.target_moved"`, the new file's content never reaches the model, the row is `resolved` (not reopened), and the model's re-issued `Read` parks a new row whose `pendingAction.targets.protective` is the new file. Same for a caller write tool through a re-pointed link (nothing written).
174
+ - **Pins.** `test/backlog1391-approval-object.test.ts` (B4r / B4w / B4' / B6 vetoes, B4e / K1 / K2n the parked bytes handed back — verbatim, in another key order, or in a form the tool reads alike — are judged, K2 an undefined-valued member is other bytes, B6' an edit executes, B6'b the edit is still judged by the read boundary, B1 face veto, B2 / B3 / B11 reopen, X1 a tool replaced by a host's re-dial between the two stations reopens at the execution, R an approved resume onto a workspace restored under another root is refused before the leg (`resume.env_failed`, nothing runs, the row pending), DN deny unchanged, FC no reading ⇒ veto, DM damaged member, B8 control, the judge's table and the requirement word); `test/tool-roster.test.ts` (G6e re-pinned on the judge); `test/tool-face-bounds.test.ts`; `test/backlog1328-parked-skill-rescreen-swap.test.ts` and `test/backlog1414-approval-card.test.ts` B7 (unchanged, rerun).
175
+
176
+ ### `@sema-agent/mods-kernel` is a public package, cut with core at the same version, with a README that states what a stranger installs (#2104; release packaging; additive; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none) (@client-core @cli @web-client)
177
+
178
+ - **What changed.** (1) `packages/mods-kernel/package.json` carries `"private": false` (an explicit boolean, as the package gate has always required) and `"publishConfig": { "access": "public" }`, so the first publish is public by the package's own word and not by a command-line flag someone must remember. A tag cut from this tree therefore promises `@sema-agent/mods-kernel@<that version>` on the registry, which `scripts/verify-published-tags.mjs` already reads for every non-private package; until the 7.54.0 publish the name is a 404. (2) The tarball holds `package.json`, `README.md`, `LICENSE` and the 14 compiled files under `dist/` (seven modules, `.js` and `.d.ts`), nothing else; one entry `.`, zero dependencies, the same compiled source the engine embeds (`src/core/mods/kernel/`), the same version as core. (3) The README is rewritten: how to install, that the package is cut with core at the same version, that a host loading two copies compares `KERNEL_CONTRACT_VERSION` for equality (the number itself is not written in the README, the constant is its one place), how to read which side raises an event, and an Exports section listing the 67 names of the frozen export surface. (4) `docs/RELEASE-TIERS.md` (multi-package base): the publish step reads `npm publish --ignore-scripts -w packages/<dir>`, the first publish's access comes from `publishConfig`, and the registry check gains a stranger smoke (an empty directory, `npm i @sema-agent/<dir>@X.Y.Z`, `node -e` reading the contract number and one event's side); `docs/INTEGRATION-CORE.md` §10 says the package is on the registry from 7.54.0.
179
+ - **Licence (ruled: clay C-R646, 2026-10-19).** The package ships the licence text: `packages/mods-kernel/LICENSE` is a byte copy of the root `LICENSE`, and the root file's "Licensed Work" line now names the satellite packages published from this repository (`@sema-agent/core` and currently `@sema-agent/mods-kernel`); no other clause of the licence changes. Before this, a workspace tarball did not inherit the root file, so a consumer of the package alone received only the `BUSL-1.1` identifier in `package.json`, without the Additional Use Grant, the Change Date and Change License, or the terms. The wording of the Licensed Work line is the licensor's to rule.
180
+ - **Which side raises an event.** No new column. Every row of `MOD_EVENT_TABLE` already carries `side` (`engine`, `surface` or `both`; the closed set is `MOD_EVENT_SIDES`), the package exports the table, the closed set and the readers `readOnSide` / `stationOn`, and the shipped declaration types the member (`ModEventRow.side: ModEventSide`). `KERNEL_CONTRACT_VERSION` is unchanged and nothing in `src/` changes.
181
+ - **Not established here.** That the package resolves from the registry: this is the cut's own publish step, and the stranger smoke above is its check. Before it, the same reading runs on the gate's own tarball, offline.
182
+ - **Who is hurt.** Nobody: no root export, behavior or declaration changes. A tag that promises the package and does not publish it now blocks the next release's start, by the check that already exists.
183
+ - **How to migrate.** Nothing for hosts of the engine. A shell or client that wants the kernel on its own bus installs `@sema-agent/mods-kernel@7.54.0` (the same number as `@sema-agent/core`) once it is published, and compares `KERNEL_CONTRACT_VERSION` if it loads a second copy.
184
+ - **Pins.** `test/backlog2104-mods-kernel-publish.test.ts` (A: `private` is `false`, `publishConfig` is `{ access: "public" }`, the release gate reads the package as a promise of the root version, and for each such package its `LICENSE` is the root file's bytes and the root's Licensed Work parameters name it; B: the README's install, same-version and contract sentences, no contract number written down, no stale status banner, the Exports section equal to the frozen surface; C: every name has a `side` in `MOD_EVENT_SIDES` and the two readers answer from that row, the closed set is spelled once in the kernel source, the surface reaches the table / set / readers / row type; D: over the gate's own tarball, the file set is exactly `package.json`, `README.md`, `LICENSE` and the compiled modules, the shipped `LICENSE` is the root file's bytes, the installed manifest and declarations are the published ones, and a stranger install answers the source's contract number and the source's side for every name and runs the README's example); `test/mods-kernel-package-wiring.test.ts` (the manifest pin now reads `private: false`).
185
+
186
+ ### A tool's effect is read from the tool that answers to the name: the safety scan writes only the caller tools' own declarations, every engine effect arrives with its tool's mount, and a caller alias's safety axes no longer land on an engine tool's name (#1680, #2105; behavior narrowing and behavior change for #1680, ruled: clay C-R638 (2026-10-18); for #2105 a safety narrowing for a caller alias's probe and a loosening for its `egress` / `always` marks, both ruled: clay C-R646, 2026-10-19; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @cli)
187
+
188
+ - **Before (v7.53.0).** The prepare safety scan wrote four engine effects into the leg's effect table by name, before any tool mounted: `ReadToolResult` → read (over a caller tool of that name), every hand-band name (merged with a caller tool's claim into the less repeat-safe of the two), `Bash` → read under `handsReadOnly`, and `ExitPlanMode` / `EnterPlanMode` → read under plan mode (over a caller tool of that name). Each entry stood whether or not the engine tool mounted. Measured before this change: with offload off (`toolResultThresholdChars: 0`), a caller's own `ReadToolResult` declaring `write` mounts and runs, but the table said `read`: a session `allowDirs` confinement admitted it and a wake reconcile called an interrupted call a safe retry. A caller tool declaring nothing there had a roster row that said `read`. A caller `ExitPlanMode` declaring `write`, under plan mode with no checkpoint store (the engine's plan pair does not mount), behaved the same. A caller `Write` declaring `read` under read-only hands (the band mounts no Write) read `write` in the table and `read` on its roster row. A manager-less hands leg kept an `LSP` row, and a read-only one a `TaskOutput` row, for tools nothing mounted.
189
+ - **Now.** The scan writes the caller tools' own declarations (the roster's kept instance, as before) and nothing else; every engine effect arrives with its tool's mount, from the tool's own declaration (the roster builder records it, as it already did). The four by-name writes are gone, and so are the two by-name writes the delegation surface made right before its own mounts (`ReadInbox`, `ListAgents` / `ListPeers`: the builder records the same `read` at their mount). A name nothing mounted under has no entry. The wake reconcile is the one reader that runs before any mount, so before the roster can say who owns a name that a caller tool and an engine definition both answer to. It reads the scan's projection of the table, built by one rule: each name reads the least repeat-safe effect among the members that may answer to it — the caller tool claiming it (which counts as a write when it declares nothing) and every engine definition the catalog lists that this leg may still mount under the name or an alias (every definition but the `scenario` ones, which reach a leg only as a caller's own entries; the hand-mounted ones only where the hand is enabled; of the hand's two shells, the one matching `handsReadOnly`). The one merge rule replaces the three shapes of the old writes (overwrite / merge / overwrite). `handBandEffects()` is unchanged. Aliases follow the same rule. After the mounts, an alias's entry is the entry of the member the alias resolves to (the dispatcher's own lookup over the harness's own list: a member's own name first, then the first member carrying it). When an engine mount takes a name from a caller tool, the aliases that tool claimed move to their next claimer, effect and safety axes alike; an unmount (the leg's selector) moves the removed members' aliases the same way and drops the effect entry of a name nothing answers to by its own name. Before the mounts, the wake reads every caller tool carrying the alias. Measured before this change: with `Read` (`effect: "read"`, `aliases: ["Foo"]`) and `Charge` (`effect: "write"`, `aliases: ["Foo"]`) on a full-hands leg, the band's `Read` takes the name and a call by `Foo` runs `Charge`. The table still said `read` for `Foo`. A reconcile reads the table under a call's persisted spelling, so an interrupted call by that spelling would have been told to retry a write as safe.
190
+ - **Open defect #2107 (7.55.0, design ticket).** A leg selector that names a caller tool's alias can keep that caller instance under an engine tool's name while it unmounts the engine's own instance (`Read{effect: "write", aliases: ["X"]}` with `allowTools: ["X"]` on a full-hands leg). The table then keeps the unmounted engine member's `read` for `Read` while the caller's write tool answers to it, and a session `allowDirs` confinement admits a call to it (the same on v7.53.0). The roster row says `write`; the wake reads `write`. A removed name that another member still answers to by alias reads as write (over-careful, never fail-open).
191
+ - **Safety narrowing (#2105, ruled: clay C-R646, 2026-10-19).** (1) **What changed:** a caller tool's irreversibility tier, reversibility probe and egress mark are no longer mirrored onto an alias that is an engine tool's name. Before: `Deploy{irreversibility: "maybe", reversibilityProbe: () => ({ reversible: true }), aliases: ["Bash"]}` on a full-hands leg. The engine's full shell takes `Bash` by exact name, the shell seat kept `Deploy`'s probe, and a `touch` through the engine shell ran unasked in the default mode. Now it asks as it does with no `Deploy`. (2) **Who is hurt:** a host whose own tool aliases an engine tool's name (server documents `MyShell{aliases: ["Bash"]}` as a host pattern) and that expected its marks to apply to the engine tool's calls while that tool is mounted. Those calls now get the engine tool's own seat. When the engine tool is not mounted, a call under the alias still carries the claimer's own marks, because the gate reads a call's axes under every name it answers to. A caller tool NAMED exactly like the engine shell is unchanged: its marks still reach the engine shell, by design. (3) **How to migrate:** nothing for the documented alias pattern. To set the engine shell's judgment, declare it on a tool named exactly like it, or set the shell gate's `"always"` floor (`TaskSpec.shellGate: "always"`).
192
+ - **Behavior change, the loosening direction (#2105, ruled: clay C-R646, 2026-10-19).** (1) **What changed:** the same withdrawn mirror carried a caller alias's tightening marks too. A caller tool aliased `Bash` with `egress: true` or `irreversibility: "always"` no longer tightens the engine shell. Measured with a real Runner on a full-hands leg, `Deploy{effect: "write", egress: true, aliases: ["Bash"]}`, the model calling `Bash`: a default-mode `ls` asked once on v7.53.0 and is not asked now. A `bypassPermissions` `touch made` asked once and made no file on v7.53.0; it now runs unasked and makes the file. `irreversibility: "always"` behaves the same: default `ls` goes from 1 ask to 0; bypass `touch` goes from 1 ask to 0, and the file is made. (2) **Who is hurt:** a deployment that tightened the engine shell through a tool of its own aliased `Bash`, for example the server's documented `MyShell{aliases: ["Bash"]}` host shape when it carries `egress` or `always`. The engine shell's calls are no longer asked because of that tool. (3) **How to migrate:** name the tool exactly `Bash`, which is the designed channel: a same-named tool's marks still reach the engine shell, and `Bash{egress: true}` still asks under `bypassPermissions`. Or make every shell call ask with `TaskSpec.shellGate: "always"`, which is kept under `bypassPermissions`. There is no `RunnerDeps` seat for the engine shell's own axes.
193
+ - **Behavior narrowing (ruled: clay C-R638, 2026-10-18).** A caller tool under an engine name the engine does not mount (`ReadToolResult` with offload off, `ExitPlanMode` / `EnterPlanMode` under plan mode with no plan pair mounted) is read by its own declaration: declared `write` or nothing ⇒ `allowDirs` refuses it and an interrupted call is reconciled as UNKNOWN rather than safe to re-issue. Its roster row says `write` where it said `read`.
194
+ - **Behavior change (ruled: clay C-R638, 2026-10-18).** The other direction. A caller tool under a hand-band name the band does not mount on that leg (a `TaskStop` or a `Write` under read-only hands, an `LSP` with no manager wired) reads its own declaration after the mounts, where it read the merge. A `TaskStop` that declares `read` is now admitted under `allowDirs`, as its roster row always said. A tool under a path-confinable catalog name (`Write` / `Edit` / `NotebookEdit`) is still refused there when it declares no path target of its own: that refusal is the confinement's own and does not read the effect. The wake reconcile still reads the merge for such a name (before the mounts the band's tool may own it). The effect table (`Prepared.toolEffects`) no longer holds rows for engine tools that did not mount.
195
+ - **Who is hurt.** A deployment whose own tool shares a name with an engine tool and leaned on the engine's `read` to pass an `allowDirs` confinement (`ReadToolResult`, `ExitPlanMode`, `EnterPlanMode`) is now refused there unless the tool says `effect: "read"`. A test or probe that pinned an entry for an unmounted engine name (an `LSP` row on a manager-less hands leg) sees no entry (reads as write). *server* (host tool family) / *cli* (local tool family): your tools whose names are engine names read their own declarations now; no consumer-side code change is needed when the declarations are true.
196
+ - **How to migrate.** Rename your tool off the engine's name, or declare its real `effect` (a read-only tool says `effect: "read"`; omitting it is a write).
197
+ - **Pins.** `test/backlog1680-effects-by-mount-identity.test.ts`: F1 (a member the band does not shadow reads its own declaration); F2 (a band tool that mounts reads its own effect); F3 (no entry for a name nothing mounted under: LSP, read-only TaskOutput, a shell-less read-only Bash); F4 (offload off `ReadToolResult` / plan mode `ExitPlanMode`: wake UNKNOWN, allowDirs refuses; controls); F5 (contested `Write`: the band wins, the wake reads write); F6 (engine-only names keep their wake wording); F7 (table = roster across leg shapes); F8 (an alias displaced by a band mount or an unmount follows its next claimer; the wake reads every claimer); F9 (the wake's candidates come off the catalog: a caller `Monitor` declaring read is not a safe retry); F10 (#2105: `Deploy` aliased `Bash` does not vouch for the engine shell; an unmounted engine shell leaves the claimer's own `always` on a call under the alias); F11 (an alias's tier and probe follow its member). `test/design385-slice2b-agents.test.ts` C1c reads `ListAgents` / `ListPeers` off the builder's mount write. Also `test/backlog1674-hand-band-same-name.test.ts` ② (no entry for an undeclared caller `LSP`) and `test/name-readings-behavior.test.ts` (the merge lives in `wakeEffects`; the table keeps the member's own declaration).
198
+
199
+ ### A text holding a lone surrogate — a string value, an object key, a RegExp source, an Error's name or message, a tag, a binary view's brand — has its own binding form: two different such texts no longer share one digest (#2109; behavior change, ruled: clay C-R646, 2026-10-19; hard-breaking: none for plain JSON arguments, see Who is hurt; closed sets: none; exports: none; members: none) (@server @client-core)
200
+
201
+ - **Before (v7.53.0).** The binding serializer (`canonicalize`, the input of `boundInputHashOf`, the license judge's exact digest and the checkpoint row digest) emitted every text raw (a string value, an object key, a RegExp source, an Error's name and message, a URL, a tag) and the digest hashed it as UTF-8, which replaces every lone surrogate (U+D800–U+DFFF without its pair) with U+FFFD — so `{ leaf: "\uD800" }`, `{ leaf: "\uD801" }` and `{ leaf: "�" }` hashed alike, and so did `{ "\uD800": v }`, `{ "\uD801": v }` and `{ "�": v }`: one row `boundInputHash` and one answer `inputDigest` for each such family. Every reader that compared bytes by that digest let one lone surrogate stand for another: `createApprovalPolicy`'s "the request still holds the shown bytes" (a request rewritten from `"\uD800"` to `"\uD801"` while its approval was pending was allowed and ran the rewritten bytes) and the synchronous gate's license (a resolver that showed the person `"\uD800"` while `"\uD801"` ran was allowed as `human_allowed` on a real-approval ask). Only the resumed belt was not fooled — it compared the bytes that run with the parked ones by structural equality (`deepJsonEqual`), so a resumed leg's PreToolUse rewrite from one lone surrogate to another was re-checked as unseen bytes (in this version's earlier stage a digest-based reading of "the parked bytes" let that through too; fixed before release). Now every such text has its own digest, and every one of those readers tells them apart.
202
+ - **Now.** ONE spelling law for every text the serializer emits (`segment`, `src/core/canonical-json.ts`; seven sites: a string value, an object key, a RegExp source, an Error's name and message, a URL, a tag, a binary view's brand): a well-formed text is `<utf8 byte length>:<text>`, exactly as before, and a text with a lone surrogate is `S<length>:<its JSON spelling>` (JSON escapes the lone surrogate). Every digest minted over well-formed values is unchanged, with two exceptions, both reachable only through a value a deployment hands the engine (model arguments are JSON): a binary view (typed array / `DataView` / `Buffer`), whose brand is now length-prefixed like every other text, and a boxed `String` (`new String(…)`) holding an astral character, whose own index keys are the character's two surrogate halves. All readers of the serializer — the row's `boundInputHash`, `licenseCovers` (the resumed belts' "are these the approved bytes", the park's target mint, the approval object's targets judge, the synchronous gate's license and "the request still holds the shown bytes"), the row digest — tell such texts apart with no reader of their own.
203
+ - **Who is hurt.** A row parked by v7.53.0 or earlier with a lone surrogate in ANY member its row digest covers — an argument value or key, the gate's `reason` (a deployment policy's ask text), the `toolCallId` (minted by the provider) — and a row whose arguments hold a binary view or a boxed `String` with an astral character (the two exceptions above), no longer recomputes its digest, so the resume is refused before the claim as damaged (`requirements_damaged`, the row pending): re-issue the call. A host that compared argument digests it stored from the old engine against new ones for such values sees them differ. Rows and arguments of plain JSON values that are well-formed throughout: nobody. Only bindable values (the #2111 section: a record of plain JSON data nested at most 256 levels deep) have an exact digest, so no sentinel ever stands in a license; every value the digest does not tell apart — inside that domain, and in the total digest the row digest takes over other values — is listed in `KL-2109-a`.
204
+ - **How to migrate.** Nothing for well-formed values. A host holding parked rows that may carry lone surrogates expires them and lets the model re-issue.
205
+ - **Pins.** `test/backlog1391-approval-object.test.ts` (K-sur: a resumed PreToolUse rewrite `"\uD800"` → `"\uD801"` in a value reaches the deployment policy's recheck and is refused, the rewritten value never runs, the same lone surrogate handed back runs; K-sur-key: the same in an object key, to `"\uD801"` and to `"�"` — both are regression guards on the belt's reading, green on v7.53.0 too, not proof of the digest fix; the digest units — the proof: distinct lone surrogates differ in a value, a key, a RegExp source, an Error message and a tag, the same one is equal, twenty well-formed keys keep the old spelling byte for byte).
206
+
207
+ ### Arguments no approval can be bound to — anything but a record of plain JSON data nested at most 256 levels deep — are refused before anyone is asked, by one judge in one sentence, and so is a person's edit to them on every path; every answer is licensed by the exact digest it is judged by (#2111, #1391; behavior narrowing, ruled: clay C-R646, 2026-10-19: such arguments and edits are refused, never asked; hard-breaking: none for plain JSON arguments; closed sets: none; exports: none; members: +1 optional `EngineAttestation.bindingRefusal`) (@server @client-core)
208
+
209
+ - **Before (v7.53.0).** An answer's record (`inputDigest`) was minted AND judged with the lossy `boundInputHashOf`. For arguments whose canonical form a sentinel shortens — nesting deeper than 256 levels or a cyclic object graph (values that clone, but no exact digest binds) — a person's yes ran as `human_allowed`, bound to a lossy digest that other such arguments share: two argument sets that differ only below the cap (a deep leaf `x` against `y`) had ONE digest, so a yes for one licensed the other. (A difference above the cap changed the lossy digest too: `command: "safe-operation"` against `"dangerous-operation"` beside the same deep padding hashed differently and the license refused it.) A function, a symbol or a throwing getter does not clone: those were already refused before any ask, as "could not present the args safely", and still are. In this version's earlier stage the mint stayed lossy while the judge became exact, and such a yes on a `requiresRealApproval` ask was refused with "the resolution's allow carries no engine settlement record for it", which was not the cause.
210
+ - **Now.** An approval can be bound only to arguments that are a record of plain JSON data nested at most 256 levels deep — the BINDABLE domain, judged in ONE place (`bindableValueOf`, `src/core/canonical-json.ts`: at the top the payload's own shape — a call's arguments a record, a question's answers a list — never a bare scalar or `undefined`; inside, records, dense lists, strings, finite numbers, booleans, `null`, a tree; read by the engine's one receipt of a caller's value FIRST — the top's shape is then read off its copy, so a Proxy is refused before anything is asked of it — so no getter, Proxy, hidden or symbol-keyed member, function, symbol, bigint, Map / Set / Date / binary or other object passes, nor a cycle; then JSON's own five — a number that is not finite, a hole, a named member on a list, an `undefined` list element, one object met twice). The exact digest is defined by it: `exactBoundInputHashOf` has a value exactly for bindable values (the serializer's own lossy marks retired). That one judge is asked wherever an approval is about to be asked or adopted — through `bindableArgsOf` (`src/core/tool-policy.ts`) at every seat and edit entry, through `capturedJsonOf` at the durable capture — on the arguments as given (no getter is ever called, a value read through one has no fixed bytes; the same reading every license and mint takes), and every refusal is its one sentence (`bindingRefusalOf`), naming what it met and where by the receipt's own reading (a value is never asked what it is: a Map, a class instance or another realm's list is "an object that is not a plain record or list") — e.g. "no approval can be bound to the arguments of "X": they hold an object that is not a plain record or list at args.when — an approval binds arguments that are a record of plain JSON data (…) nested at most 256 levels deep; denied fail-closed": before the ask resolver calls the approver (`presentation_failed`), in its no-approver arm (settled `no_approver`, as every headless refusal is — the denial-limit stop and its notice read that word; the tool text is this sentence alone, without the headless remedy, whose approver half would not let the call run, and the `delegation.ask_unresolvable` notice still goes out with this sentence as its remedy — the refusal carries the finding on its engine record, `EngineAttestation.bindingRefusal`, and the gate's deny exit reads it rather than judging the arguments again; a mode that releases the ask, `bypassPermissions`, still runs such arguments, since a release needs no binding, and a literal blanket `onAsk: "allow"` allows before any judge, as before (an ask requiring a real approval aside) — neither is an approval), before `createApprovalPolicy` calls `approve` (`presentation_failed`), where the resolver adopts a person's edit (`presentation_failed`), in the gate's approved-edit loop, where an edit a host's own `resolveAsk` hands back is judged (recorded as the engine's resolver records the same refusal: `deniedBy: "ask_resolution"`, `presentation_failed`; the engine's own resolver's edit is already bound and passes as it is), and where a durable decide's edit is captured, before the claim (`checkpoint.invalid_outcome`, the row pending; no `detail.field`). A persisted payload has ONE capture (`capturedJsonOf`, `src/core/canonical-json.ts`): the judge on the value as given, then its JSON form — a durable decide's edit and a question's answers both take it. The durable capture's own plain-JSON domain check and its two sentences ("could not be captured as plain data", "outside the plain-JSON value domain") are retired into it, and so are the seats' "could not present the args safely" and the edit's "is not safely clonable": a cycle, a list's named member, a Map or nesting past 256 levels reads the same on the live and the durable path. The gate's record of what it decided (`finalInput`) is read by the engine's one receipt too: a plain-data projection, frozen at every level, of the input's containers to 256 levels (one receipt per record; a container's list of symbol-keyed markers sits one level below it, so the record is at most 257 deep), with a `[not recorded: …]` marker in place of every member that is not data (a bigint, a Map, a Date, a class instance, a function, an accessor; a symbol-keyed member's marker in its container's `[not recorded: symbol-keyed members]` list, under a key no data member holds, so no recorded member is ever overwritten), never a reference to the caller's objects — on either exit: an allowed call runs with the value itself, the record being its projection; and an approved edit refused as unbindable is no args of the call: it leaves through the gate's one exit for a value nothing took (`untakenValueExit`, the exit the model's args the gate cannot take into its own copy already leave by), so no reading of the tool's runs over it and the refusal carries no judged bytes — the deny exit's one receipt records the edit as given, as its projection, or as the one marker when the receipt refuses it whole (a Map, a Date, a class instance, a Proxy or a function in place of the arguments; never handed to the tool's reading as if it were arguments) — so nothing of the input runs, at the exit or in the deny observer's copy; and a getter that throws in a host's input to `runToolGate` is refused as a deny (v7.53.0's record copied the input again and let the thrown value escape `runToolGate`). Every answer's record is minted with `exactBoundInputHashOf` — the digest the one license judge (`licenseCovers`) reads — at all five mints: the ask lane, the ask resolver, the approval policy, the inherited-grant collapse of one call's two frames, and the park row's `boundInputHash`. The "no engine settlement record" refusal is said only where no record of THIS ask covers the bytes that run — no record at all, one for other bytes (a resolver that showed the person something else), or one for another question (a host that wraps `resolveAsk` without passing the ask's `origin` and classes). `createApprovalPolicy`'s "changed while its approval was pending" keeps its one cause.
211
+ - **Behavior narrowing (ruled: clay C-R646, 2026-10-19).** *What changed (where an ask is put — a mode's release and a literal blanket `onAsk: "allow"` still run any of these, as before):* a call whose arguments are not a record (`undefined`, a scalar, a list — never the engine's own arguments, which are always a record; a host calling a seat directly) is refused, never asked, and so is an edit that is not one (a durable decide's `updatedInput` of `null`, `-0` or a list, which v7.53.0 recorded — normalizing `-0` —, claimed the row for and left to fail the tool's validation; a host resolver's edit that is a list, which v7.53.0 adopted); a call whose arguments are plain records and lists but outside the bindable domain — nesting deeper than 256 levels, a cycle, a number that is not finite, a bigint, a hole, a named member or an `undefined` element on a list, one object met twice — is refused, never asked, where v7.53.0 asked and ran it on a person's yes; so is a person's edit into such a shape (live, v7.53.0 adopted it; durable, v7.53.0 already refused every such shape but the depth, in sentences of its own); a value v7.53.0's seats cloned into plain data and then asked about — a class instance, another realm's object or list, a list of a subclass, a record with a symbol-keyed or non-enumerable member (the clone dropped the prototype and those members) — is refused at both asking seats, at the resolver's edit adoption, in the gate's edit loop and at the durable decide; `createApprovalPolicy`, called directly with a Map / Set / Date / binary value, refuses it too (the resolver already refused those; the policy cloned and judged them); with no approver wired the refusal of such arguments says why (still `no_approver`, without the headless remedy); a host's own `resolveAsk` that approves with an edit into such a shape is refused in the gate's edit loop (v7.53.0 adopted it, reading a getter once, and ran it unless the ask required a real approval); a question's answers holding a getter, a class instance or a symbol-keyed or non-enumerable member read as contradictory (v7.53.0 cloned them first); the durable park binds its row only to bindable bytes — a backend that keeps the structured-clone domain (the in-memory one) refuses such arguments with the park's own sentence (a bigint used to park there), while a JSON or undeclared backend first projects them into JSON (a non-finite number, a hole and an `undefined` element become `null`, a list's named member is dropped, sharing becomes copies), re-judges and parks the projection, and refuses only what its codec cannot encode (a bigint, in the codec's sentence) — only a deployment's rewrite can hold such values, model arguments are JSON; and a getter in arguments a host hands a seat directly, or in a person's edit (live, durable, or from a host's own resolver), is refused and never called, where v7.53.0 read it once into its snapshot (the Runner's own arguments are the gate's copy and hold none). A function or a symbol was already refused before any ask ("could not present the args safely") and still is, now named with its place. A refused input's `finalInput` holds a `[not recorded: …]` marker in place of every member that is not data, where v7.53.0 held a clone of a Map or a Date (or, around a function, a shallow copy of the live members, reading their getters again); a container with a symbol-keyed member keeps its data members, the symbol-keyed ones listed by marker; an allowed call's `finalInput` is the same projection, which differs from v7.53.0's (a deep-frozen clone of the args the call runs with) in exactly three ways — a bigint is a marker (the call runs with the bigint), so is the member that closes a cycle, and containers past 256 levels are cut to one marker (`[not recorded: containers nested deeper than 256 levels]`) — since nothing else that is not data reaches the allow exit (the gate's own copy refuses a Map, a Set, a Date, a binary value and a function, and its clone has already read a getter into data, dropped a symbol-keyed member and a class instance's prototype, as in v7.53.0). A host's own resolver's edit refused by the judge is recorded as the resolution's refusal (`deniedBy: "ask_resolution"`, `presentation_failed`), the record the engine's resolver mints for the same refusal (v7.53.0: `deniedBy: "policy"`, no settlement). *Who is hurt:* only an in-process host can reach any of this — server, cli and client-core hand the engine JSON (an edit over HTTP, a UI's answer); such a host whose hooks or policies rewrite arguments into such values and expected a person to approve them (or edits approved arguments into such a shape); a host that calls `createApprovalPolicy(...).check()`, `resolveAsk` or `runToolGate` itself with such values, or hands a decide, an edit or an answer built with getters or class instances; a consumer pinning a retired sentence ("could not present the args safely" at the two seats, "the approved edit … is not safely clonable", "could not be captured as plain data", "outside the plain-JSON value domain"; the repo's own pins re-pinned: `test/core-toolgate-coverage.test.ts`, `test/ask-bidi-presence.test.ts`, `test/backlog1359-allow-layer-only-home.test.ts`, `test/question-sync-first.test.ts` — its getter edit is now refused, never read, and its `null` / `-0` edits are refused pre-CAS — `test/gate-judged-is-run.test.ts` J5b / J8, `test/ask-bidi-presence.test.ts`'s bare-string case (now refused before the approver is shown it), and `test/park-mint-backend-fidelity.test.ts` — a bigint no longer parks on the in-memory backend); a consumer that renders `presentation_failed` alone, which shows the family and not the cause — render the message. *How to migrate:* pass plain JSON data; keep nesting below 128 levels (the depth a durable park can hold).
212
+ - **Pins.** `test/backlog1391-approval-object.test.ts` — the bindable domain (each class outside it has no exact digest and is named; inside, 256 levels, `-0`, an `undefined` member, a null-prototype record, lone surrogates keep their digest); E-dom (a person edits approved plain arguments into a cycle, a list's named member, a Map or 257 levels: the SAME sentence on the live resolver, `requiresRealApproval` and not, and on the durable decide, naming what was met; nothing runs, the row pending) and E-dom control (256 levels: adopted live, run durable); NA (no approver: 300 levels, a function, a getter or a Date — the sentence the asking seats give, settled `no_approver`; the resolver's approver seat and `createApprovalPolicy` give the same sentence, nobody asked; plain arguments keep the headless refusal); NA-gate (a real Runner with no approver: the tool text is the binding's sentence alone, the `delegation.ask_unresolvable` notice carries it as its remedy; plain arguments keep the headless remedy); DL-stop (auto mode, no approver, every call 300 levels deep: `failed / classifier.denial_limit`, its notice and `delegation.ask_unresolvable`, nothing ran); HE (a host's own `resolveAsk` approving with an edit into a getter, a bigint, a non-finite number, 300 levels, a cycle, a shared object, a list's named member, a class instance or a symbol-keyed member: refused in the one sentence by the gate's edit loop, the getter never called; a plain edit adopted); WIDE (a plain record of 130 000 members: bindable, digested, asked once at both seats, no throw); the refusal's own reading (a `Symbol.toStringTag` getter, a subclass's list, another realm's list: "an object that is not a plain record or list", the getter never called); LIST-edit (a durable decide's list edit refused before the claim, the row pending); REC (a refused caller input's `finalInput`: markers for a Map and a function, symbol-keyed members listed, data members kept — a recorded member named `Symbol(s)` or like the list's key is never overwritten, two symbols of one description get two markers — and the Map changed afterwards changes nothing recorded); REC-allow (an allowed call's record: a bigint is a marker while the call runs with the bigint); REC-deep (6 000 levels with a function: recorded to 256 levels then a marker, one bounded receipt, under 100 ms); HE-throw (a host resolver's edit with a throwing getter: the refusal returned, not thrown, the getter never read, the edit recorded by the deny exit's one receipt — its projection); REC-not-args (a host resolver's edit that is a Map, a Date, a class instance, a Proxy or a function in place of the arguments, under a tool reading that uses `in`, spreads or hands the value back — fifteen cells: the refusal returned in the one sentence, no reading run over the edit or its record, `finalInput` the one marker); REC-revoked (a revoked Proxy, whole or as a member: the judge, the ask resolver's approver seat, `createApprovalPolicy` and a host resolver's edit each refuse it in the one sentence, nothing thrown, nobody asked); REC-allow-three (an allowed call's record against v7.53.0's clone: the same for a hole, a list's named member, `undefined`, `-0`, non-finite numbers, a shared object, a null-prototype record, a getter, a symbol-keyed member, a class instance and 256 levels; markers for a bigint, a cycle and 300 levels; a Map, a Date or a function in a policy rewrite refused before the exit); REC-depth (a symbol-keyed member on the 256th container: its marker list at the 257th level, nothing deeper); KL-2111-a / KL-2111-b (the two known limits `docs/KNOWN-LIMITS.md` enters for this section, each pinned: the sentence names the first fault the receipt meets, so a list holding a getter is refused for the getter first; a list's symbol-keyed markers are absent from the record's JSON form, as in v7.53.0); PROXY (a Proxy wearing a list or a record: refused as a Proxy before its shape is read); X2 (a host resolver handing the engine's resolver other bytes: the exit reads the refusal's own record — the binding's sentence alone when the asked bytes are unbindable, the headless remedy when they bind); one capture (an answer with a getter, unread, or a class instance: contradictory; plain answers keep their JSON form); R18-DEEP (300 levels at both asking seats: nobody asked, nothing runs, `presentation_failed`); A2-sub (a resolver showing `safe-operation` + 300-deep padding for a `dangerous-operation` request: refused before the person is asked; without padding the license refuses the substitution, unchanged); P-exact (the park row's binding is the exact digest; 130-deep arguments never park); `test/backlog1359-allow-layer-only-home.test.ts` G5 (re-pinned: the approval policy refuses 260-deep arguments before its approver is asked; nothing reaches a card) and G5 control (unchanged); `test/ask-bidi-presence.test.ts` (the cyclic-graph cell names the cycle; the scan itself fed a cyclic graph: no hang, no throw, still detects); `test/core-toolgate-coverage.test.ts` (a getter throwing a hostile value: `runToolGate` returns its deny, the getter tried once by the gate's own copy and never by the record; the seat's judge never calls it).
213
+
214
+ ### The post-compaction restore is a Read on the model's behalf delivered in the attachment seat's frame: each restored file is an engine-note frame after the compaction entry, read with the Read tool's read under the synthetic-Read judge and deduplicated against what the summary leaves; the `<working-file>` blocks in the summary and the second file reader retire (#1311 car C; behavior change, open to veto; behavior narrowing, open to veto: a file a deny or mandated-ask layer refuses, or that a layer cannot answer for (`unanswerable`: a throw, a timeout, an unreadable rule store, a rewrite), is no longer restored, a file over its share is named, not sent in part; **hard-breaking, open to veto: `MaybeCompactOptions.workingFileAttachments.readFile` is replaced by `restore` (+ optional `undelivered`), and `MaybeCompactOptions.onApplied` loses its first parameter `attachedComplete`**; closed sets: none; exports: +2 types `CompactionRestoreAsk` / `MintedAttachmentFrame`; members: +1 optional `UserMessageAttachment.origin` (`"compaction_restore"`)) (@client-core @cli @server @test)
215
+
216
+ - **Before (v7.53.0).** A landed compaction re-read up to three recently read files with a second reader (`readTaskFile`: the hands' resolution and read-deny table — the person's rule deny rows compiled into it included — but none of the other layers the synthetic-Read judge consults: caller and inherited policies, skill narrowing, org rules, asks; the whole file buffered) and appended their sanitized contents, clipped at 16 000 of the file's own characters, INTO the summary text as `<working-file path="…">` blocks — ahead of the kept tail, though they are newer than it. "Still visible in the kept tail" was read from the kept tail's Read tool calls only, so a file a user attachment frame in the kept tail already showed was re-attached into the summary (a duplicate; a whole re-attachment re-registered its read state, a clipped one dropped it). A deny-withheld file was a `<working-file … withheld=…/>` block; a vanished one was skipped.
217
+ - **Now.** One reader, one frame: the restore is a Read the engine issues on the model's behalf — the run's synthetic-Read judge (probe face `compaction-restore`), then the Read tool's own `readForModel` (the Read tool's gate order: a judgment that re-reads the person's rows stands for the read after it); a judgment that named another file than the read's own final resolution is the attachment's own "changed while it was being checked" — and its frame is the attachment seat's (`src/core/user-attachment.ts` `readCompactionRestore`). **The restore holds no object** (it reads as the Read tool reads, on every execution lane, including the ones that cannot bind a read); **a user's declared attachment still holds its read** (`openBound`), since a person declared that path. Each restored file is its own `provenance: "engine-note"` frame after the compaction entry (summary, kept tail, restored files — the reference's order, cli296.js:249517 `hookResults`), opening `The conversation was summarized, so the engine re-read a file it had read before (attachment n of N).`, record `origin: "compaction_restore"`; the derived views name it `file re-read at summarization n of N`. Visibility in the kept tail is one predicate over both carriers — a Read call with real content, or a frame whose record says its contents were delivered (an `unchanged` frame is not visible). The reads dedup against the read state the summary leaves (`readFileStateAfterSummary`, the one answer the clear itself applies; the reference's restore turns its unchanged-dedup off the same way, cli296.js:250054 `dedupUnchangedReads`), so a summarized-away file is never answered "unchanged", and they are not held back by the 60 s disclosure throttle (the disclosure the window remembers left with the summarized context; #2125); the read happens before the landing and the read state's clear plus the restored reads' commits happen together after it — a pass that does not land touches neither. **The per-file share is counted in the file's own characters** (`maxCharsPerFile` keeps the v7.53.0 unit: the text before the Read's line numbers), **the total in the file contents the frames send** (line numbers included — the unit of the window's ~15% share, so short-line files cannot drown a small window: their line numbers can outweigh their text several times over; a refusal's sentence is not contents and is not counted); an image costs the estimator's fixed weight × chars per token in both. A PDF is a reference (size and page count, no PDF pipeline run). Every candidate gets a frame: a file over its per-file or total share, a spent total included, is a frame that names it and says it is too large (the reference's `compact_file_reference` cli296.js:250425); a file a judge layer refuses carries that layer's message (a layer that cannot answer: the judge's sentence alone); a file the read itself refuses (gone, outside the roots, the read-deny table) carries the Read refusal; each of these is also an `onError` line (phase `degraded`, classification `attachment-read`), since a restore's outcome has no wire face. When the request guard's last-resort trim drops an attachment frame (a restore's or a user's) or a Read's result, the read-state entry it delivered retires with it — by transmission, so a later read of the same file survives, and a call id that a kept message still carries is not gone — and the next Read sends the contents again (v7.53.0 retired nothing on a trim: a Read whose result the trim had dropped was answered "unchanged since you last read it" with the contents gone). A read state rebuilt from the transcript by a later run (a new process on the same session) carries the same transmission (a frame's entry the frame's spelling, a whole-file Read result's entry its call id), so the trim retires it there too.
218
+ - **Behavior change (a user's attachment frame's words, open to veto).** A user's attachment whose path resolves, at the held read's own final resolution, to another file than the one opened (re-pointed between the open and the read) now says the attachment's own sentence — "The file changed while it was being checked, so it was not read." — with no data, the record's note `target_moved`; v7.53.0 put the Read refusal ("… resolved to a different file while it was being checked, so it was not read.") into the frame's data under "The contents were not attached; the reason is stated above." (note `not_attached`). The outcome (`not_attached`) and the `human_input` projection are unchanged; a judgment that named another file already said the first sentence.
219
+ - **Behavior change for `compacted.attachedFiles` (shape unchanged, @client-core @cli).** The list is projected from the delivered frames' records — the frames that carried contents: `path` as before, `chars` now counts the characters of the line-numbered data the frame delivered (it counted the raw clipped characters; the total share is counted in this unit, the per-file share in the file's own characters, so for one file the two differ by the line-number prefixes), `truncated` = the frame holds a partial view; a file named too large, refused, gone, an empty file or a PDF reference is not listed (no contents were delivered). A display that shows `chars` shows a slightly larger number for the same file.
220
+ - **Hard-breaking (open to veto).** *What changed:* `MaybeCompactOptions.workingFileAttachments.readFile(path) → string | null | { withheld }` is removed; the option takes `restore(ask: CompactionRestoreAsk) → Promise<ReadonlyArray<MintedAttachmentFrame>>` (and an optional `undelivered(label, reason)`), and `onApplied(attachedComplete, preserveReadState?)` becomes `onApplied(preserveReadState)`. *Who is hurt:* a host that calls `maybeCompact` directly and passed `readFile` or read `attachedComplete` — none in server / cli / client-core (grepped). A consumer that parsed `<working-file>` out of the persisted summary text finds none (none found). A deployment that relied on the restore being bound to the judged object: none (the v7.53.0 restore was not bound either). *How to migrate:* use the Runner's compaction (it wires the restore); a direct caller that wants no restore omits `workingFileAttachments`, and an `onApplied` drops its first parameter. A direct caller that wants the restore loses it: `restore` must return minted frames, and their mint (`readCompactionRestore` / the frame mint) is not exported — there is no such caller in server / cli / client-core.
221
+ - **Pins.** `test/backlog1311c-compaction-attachments.test.ts` (C1 frame after the compaction entry, same frame shape and `readForModel` read, marked static sentences, contents outside the marks; C2 dedup against the post-compaction context; C3 an `unchanged` kept-tail frame is not visible; C4 a pass that does not land keeps the read state and adds nothing; C5 / C6 the views and `resumeAt` on a restore frame; C7 no `<working-file` literal or second reader in `src/`; C8 `attachedFiles` against the v7.53.0 shape on a real Runner; C9 / C9b the per-file share in the file's own characters and an image's weight; C10 a lane without `openBound` still restores, a user's attachment there does not; C10c / C10d the read held to the file the judgment named; C11 a guard trim of a restore frame makes the next Read send the contents; C12 / C12b a summarized-away disclosure reappears in the restore frame (text and notebook); C13 a PDF is a reference, not listed; C15 / C15b the same after a new process rebuilds the read state from the transcript (a frame, a whole-file Read result); C16 / C16b short-line files stay within the window's share; C16c a spent total still gives every later candidate its frame; C17 a trim that drops a Read's result makes the next Read send the contents, a kept result (also under a reused call id) keeps its entry; C18 a paged restore is charged what it sent and the next page is still served).
222
+
223
+ ### The device lane's cross-boundary vocabulary and data plane: every tool states the legs a call travels (`ToolRosterEntry.boundaryLegs`), one table answers a call's cross-boundary obligation (`crossBoundaryObligation`), the deployment's posture has a seat (`RunnerDeps.crossBoundaryPosture`), an MCP / A2A deployment can declare an engine-only tool (`toolAxes[tool].boundaryFlow: "engine"`) — the launch gate (the last section of this release) reads it (#469 O9b S0; additive; behavior change: none in any decision the engine mints, and a host's policy or hook decision is received exactly as v7.53.0 received it; behavior narrowing, open to veto: two new refusals at configuration, see below; behavior loosening: one `toolAxes` form v7.53.0 refused is accepted, see below; hard-breaking: type-level only, see Who is hurt; closed sets: `EngineNoticeCode` +1 `config.cross_boundary_posture_undeclared`, `CheckpointConfigRefusalCode` +1 `config.cross_boundary_posture` (41 → 42), +3 sets `CrossBoundaryTaintBit` / `CrossBoundaryPosture` / `BoundaryLeg`; exports: +2 functions `crossBoundaryObligation` / `readCrossBoundaryRecord`, +4 constants `BOUNDARY_LEGS` / `CROSS_BOUNDARY_TAINT_BITS` / `CROSS_BOUNDARY_POSTURES` / `CROSS_BOUNDARY_RECORD_TYPE`, +10 types; members: +1 required `ToolRosterEntry.boundaryLegs`, +1 required `ToolFaceDeclaration.boundaryLegs`, +1 optional `ToolSpec.boundaryLegs`, +1 optional `RunnerDeps.crossBoundaryPosture`, +1 optional member `boundaryFlow` of `McpServerSpec.toolAxes[tool]` / `A2aServerSpec.toolAxes[skill]`) (@server @client-core @test)
224
+
225
+ - **What lands.** The vocabulary the launch gate reads: the tree's two taint bits and its `governed` fact (`CrossBoundaryTree`, presence-coded, joined only upward), the three legs (`env` — into or out of an execution environment; `outside_read`; `outside_write`), and the one obligation table: on a governed armed tree an outside write is a person's (`mandate`), and so is a device-bound call after an off-target read; an off-target read on an armed tree sets the off-target-read bit once allowed (`record`), governed or not; a tree that never armed (the host lane) has no obligation in any cell. Every roster row carries its legs: a catalog definition declares them (required — a definition without them does not compile); the registry `TaskOutput` / `TaskStop` and `Artifact` declare `"per_call"` (the legs follow the handle / the action, given by their dispatch); an MCP tool or A2A skill states `[]` exactly when its deployment's `toolAxes` entry declares `boundaryFlow: "engine"`; a tool a host mounts with a core contract (`contract.contractId` beginning `core.`) is read by its own declaration, like the engine's definitions; every other row follows its effect (`read` ⇒ `["outside_read"]`, anything else ⇒ `["outside_write"]`) — a caller tool without a core contract is read by its effect when it declares well-formed legs (its declaration is not read), and a malformed declaration refuses the roster (`config.tool_face_invalid`). The session record is a custom entry (`CROSS_BOUNDARY_RECORD_TYPE`, not replayed to the model) and `readCrossBoundaryRecord(branch)` its reader: the branch's records are joined (the state only grows, so a later record with fewer bits never lowers it), and any record or entry that cannot be read reads as the strictest state. Nothing writes the record, mints the notice or reads the posture past its door in this version. The ask class, the mandate word and the decision member that put the launch gate's question to a person land with the station that enforces them (the launch-gate section): this section alone adds none of them to the decision face.
226
+ - **Behavior narrowing (open to veto).** *What changed:* (a) `RunnerDeps.crossBoundaryPosture` other than `"single_user"` / `"multi_tenant"` refuses the prepare (`config.cross_boundary_posture`, the two words in the sentence) and a resume before its claim (`checkpoint.config_refused`, `detail.field` `"deps"`); (b) a `boundaryLegs` declaration that is neither `"per_call"` nor a list of legs refuses the roster (`config.tool_face_invalid`, the code a caller tool's bad face already has) on every row — an engine definition, a host's tool with a core contract and a caller's tool alike (a caller's declaration is not read, and a malformed one is not let pass either). A caller's tools are judged where the roster enters (the config-door phase's first door), every tool the caller hands, mounted or not — one the valve takes out (`excludeTools`, `excludeAllTools`), one the tool model gate trims and one a later same-named tool shadows included; every other source at the row mint. A list whose slot holds anything but a leg word — a list or a record included (`[["env"]]`, `[{}]`) — is such a declaration, judged at that door like a word outside the set. On a resume the tools the config re-supplies are judged by the same function before the claim (`checkpoint.config_refused`, `detail.code` `config.tool_face_invalid`, `detail.field` `"taskConfig"`, the row pending and byte-identical on any store); that screen reads the roster through the engine's one receipt and runs none of the caller's code, so it leaves to the leg's prepare, after the claim, only a declaration it could read by running that code — an accessor (as the name, the declaration or a slot; an entry or a list of another prototype, which its prototype chain answers), a Proxy — or one the receipt does not take (a non-enumerable member or slot) (KNOWN-LIMITS KL-469-i). *Who is hurt:* a deployment that writes a misspelled posture; a host that puts a malformed `boundaryLegs` on a tool, mounted or not (a member v7.53.0 ignored). *How to migrate:* spell the posture exactly; correct or drop the member.
227
+ - **Behavior loosening.** *What changed:* v7.53.0 refused every `toolAxes` entry naming `boundaryFlow` (an unknown member, `config.invalid_argument`); this version accepts exactly one form, `boundaryFlow: "engine"` with `egress: false` in the same entry (the roster row then states `[]`). Every other form stays refused under the same code, the remedy at the head of the reader's sentence (`needs "egress": false in the same entry — add it, or drop "boundaryFlow"`): `egress: true` contradicts it, and an absent `egress` would leave the server's open-world hint, or an A2A skill's external-write default, in force beside an engine-only claim. *Who is hurt:* nobody relative to v7.53.0. *How to migrate:* nothing; a deployment declaring an engine-only tool writes both members.
228
+ - **Hard-breaking (type level).** *What changed:* two closed sets gain a word (`EngineNoticeCode`, `CheckpointConfigRefusalCode`); `ToolRosterEntry` and `ToolFaceDeclaration` gain a REQUIRED member; a non-empty roster's `digest` changes once (the new column is in the digest; an empty roster digests as before). No checkpoint row format moves in this version: the row's capability mark and its reader-requirement word land with the session record's writer and reader. The machine wire manifest does not list the `ToolRosterEntry` member (the type is declared through TypeBox, which the manifest reader does not resolve): this bullet is its record. *Who is hurt:* a downstream table exhaustive over one of the two sets (it stops compiling, or misses the word at run time); a host that builds `ToolRosterEntry` / `ToolFaceDeclaration` literals or validates roster rows with its own copy of the schema (`additionalProperties: false`); a consumer comparing roster digests across versions. *How to migrate:* add one row per new word (`config.cross_boundary_posture_undeclared`: audience operator; `config.cross_boundary_posture`: `detail.field` `"deps"`, correct the deployment and resume the same token); read rows with the engine's `ToolRosterEntry` schema (`boundaryLegs` is a list of legs or the string `"per_call"`); re-baseline roster digests.
229
+ - **Consumers.** *server:* feed `RunnerDeps.crossBoundaryPosture` explicitly and the same on every worker (the launch gate that makes it decisive lands with the wiring); add the notice's audience row (`operator`); every `toolAxes` entry that declares `boundaryFlow: "engine"` carries `egress: false` (an A2A skill's default egress is `true`); the injection gate strips a request-side `boundaryFlow` — the engine cannot tell a request's MCP entry from a deployment's on an unlocked list; a host tool given a core contract is read by its own leg declaration (the trust lies with whoever attaches the contract). *client-core:* `EngineNoticeCode` +1, the roster column (a list or `"per_call"`). *test:* the definition-by-definition leg table is the pin's `LEG_TABLE` (S0-2), observable on every wiring manifest's `tools.entries[].boundaryLegs`.
230
+ - **Pins.** `test/design469-r3-boundary-legs.test.ts` (S0-1 the table over 128 cells and literal rows; S0-2 the catalog, the real-lane roster, a caller's and a core contract's reading, a malformed declaration refused on every row, a caller's at the door whether or not it mounts (a slot holding a container included), the door's judge running none of the caller's code; S0-3 the reader, the remedy against the refusal's bound, the MCP / A2A lanes; S0-5 the posture door, prepare and resume, and the zero-behavior arm; S0-6 the record reader's join, its strictest readings and its guard; the decision face's class, word and member, pinned with their enforcement in `design469-r3-launch-gate.test.ts`; the notice); `test/backlog1950-resume-config-doors-preclaim.test.ts` (A / I: the posture door before the claim; A: a re-supplied tool's leg declaration, a slot holding a container included, mounted or not, before the claim; C: a getter-backed declaration read once, by the snapshot); `test/backlog126-definetool-axis-carry.test.ts` (the member is carried).
231
+
232
+ ### A run of stars inside one read-deny / sensitive-path pattern segment compiles to one wildcard, so judging a path against a long run no longer takes exponential time (#2117; src only; behavior change: none, every pattern accepts and refuses the same segments; hard-breaking: none; closed sets: none; exports: none; members: none)
233
+
234
+ - **What changed.** `compileSegmentPattern` (`src/tools/fs/read-deny.ts`, the one compiler behind the read-deny rows, a task's or deployment's read-deny additions, and the sensitive-path write list `createSensitivePathPolicy`) turned every `*` of a segment into its own `[^/]*`, so `****x` became four adjacent expressions and a failing match backtracked across all of them. Measured on this tree before the change: twelve stars before a literal against a 17-character segment took 1.6–2.9 s; sixteen did not finish in 90 s; a read-deny table of 64 distinct rows of two stars each (128 stars) judged against one 10 000-character path segment took 23–48 s (16 rows: 15–19 s); nine stars in a sensitive-path pattern took 10.7 s to allow one 17-character file name. A run of stars is now one `[^/]*`: the same shapes take 0.5–6 ms. The pattern is split on `/` before it is compiled, and inside a segment `**` matches exactly what `*` matches (this compiler has no recursive-directory wildcard: a segment of stars is "any one segment"), so no pattern accepts or refuses a different segment, under any of the three case folds. The one reader that looks at the compiled expression (`isAnySegment`, whether a segment is any-one-segment) reads the folded form as it read the run. One comment (`isAnySegment`'s JSDoc) said "each star"; it now says each run.
235
+ - **Who is hurt.** Nobody: match results are identical before and after (the pins below compare the compiler with the unfolded one pair by pair, 13 680 enumerated and 10 680 random pairs, and a baseline-against-head probe of 16 patterns on 19 paths each, through the deny-rule matcher and the segment matcher, differed in no cell). A host whose deployment lists carry long star runs only gets faster. *server* / *cli* / *client-core*: nothing to do.
236
+ - **Not in this release.** Stars separated by a literal (`*a*a*a*b`) still backtrack, polynomially (the degree is the number of stars): `*a*a` against 9 999 `a`s and a `b` takes about 0.4 s, and a 64-row table of such rows takes tens of seconds. Bounding that is a behavior change (a segment-length or star-count bound with a loud refusal, or a linear matcher) and stays on #2117. A workflow script's own deny patterns are already bounded to one star per segment (`child-clamp-seats.ts`).
237
+ - **Pins.** `test/backlog2117-read-deny-star-fold.test.ts`: F1a–c (the compiler against the unfolded reference over 24 shapes x 4 run lengths x 3 folds x 38 subjects, a seeded 400-pattern random corpus, and the readings a person would write; they hold with or without the fold), F1d (the reference's expression with its adjacent wildcards collapsed is the compiler's), F2a–d (12 and 16 stars, the 128-star table through `compileReadDeny`, nine stars through `createSensitivePathPolicy`: upper bounds of 50 / 50 / 200 / 1000 ms, declared in `test/flaky-guard.test.ts`; the 16-star shape reads the compiled expression before the clock so a regression fails at once), F3 (`*/`, `**/`, `***/` before a literal name the same roots). Mutations: reverting the fold reds F1d and F2a–d with F1a–c green; `.*`, an empty replacement, a `[^/]+` fold of runs and a fold capped at three stars each red F1d or more.
238
+
239
+ ### One shell lexer: the tightening reader's second lexer is retired — `readShellCommand` is a projection of the one lexer's words, and every reader it fed, and the activity summary, the exit-1 hint and the auto-background gate, read the lexer's facts (#994 S2 + S3, #2121; behavior narrowing and behavior change, open to veto; hard-breaking: none; closed sets: `UnresolvedReason` +1 `trailing_backslash`, `ParseFailureReason` +1 `unterminated_arithmetic_expansion`, the lexer's redirection operators +1 `<>` (none is a package-root export); exports: none added or removed — `readShellCommand`, `isFullyReadable`, `MAX_SHELL_READ_CHARS`, `SHELL_WRAPPER_TABLE` and the four shape types keep their names and shapes, now from `core/shell-run-candidates.ts`; members: none) (@server @cli @client-core)
240
+
241
+ - **What changed.** `src/core/shell-lexer.ts` (424 lines) and its byte scanners `src/core/shell-scan.ts` (168) are deleted. `readShellCommand` reads `shell/lex.ts` and adds only its own domain — the keyword strip and the additive peel; leading assignments are the lexer's bare-assignment fact, a wrapper's `NAME=value` operand its dequoted fact. The read-only allow member reads the assignment and expansion facts instead of three regular expressions; the wrapper table's integer-assignment test reads the assignment fact. The activity summary names the first segment's written command word (the program-position reader's), the exit-1 hint reads the last segment's, and the auto-background gate reads the lexer's segments and a wrapper's command-string word whole (its quote-aware split, its text prefix strip and its payload unquoting are gone). The text assignment grammar (`ASSIGNMENT_PREFIX`, `writtenAssignmentPrefix`, internal) is retired: the assignment grammar is spelled once, in the lexer.
242
+ - **Lexer facts this needed (bash 3.2 / 5.3 measured).** A parse failure carries the segments read before it (`readBefore`: bash runs every complete line before the one it cannot parse); `$[…]` is the arithmetic expansion it is; a backslash that ends the text is `unresolved` (`trailing_backslash`: 3.2 drops it, 5 keeps it); a line continuation between `$` and its quote opens the quote (`$\<newline>"rm"` is `$"rm"`); an unquoted here-document body's expansions are read (`${…}`, `$NAME`, `$(…)`, `$((…))`, `$[…]`, backquotes); `yields` is `many` for an unquoted arithmetic expansion, a quoted per-element parameter (`"$@"`, `"${a[@]}"`) and a tilde prefix holding a pattern character.
243
+ - **Behavior narrowing (the deny/ask lanes ask or reach more).** A deny reaches a program on a line before a line the lexer cannot delimit (`rm -r x⏎echo "…`); `$\<newline>"rm"` reaches a deny on `rm`; a run hidden in `$[a]`, in a parameter text the lexer does not read (`${x:-${arr[a]}}`), in an assignment's subscript (`A[x]=v cmd` — bash evaluates it), in a here-document body's `${arr[a]}`, or behind a trailing backslash on the program word, makes every other rule `unreadable`. The read-only member refuses a locale string `$"…"` (ruling C-2: both faces ask).
244
+ - **Behavior change, the loosening direction (open to veto).** Each is bash's reading where the retired lexer over-read, measured on both bashes: a prefix assignment's subscript or brace is no pattern (`A[0]=v cat f`, `A=x{1,2} cat f` no longer make every rule `unreadable`); a parameter expansion closes at its first `}` (`${x:-{} y`); a bracket holding a `/`, an unclosed `{a,`, are literal; a compound assignment `A=(1 2)` is one word, not a subshell; a syntax error stops the reading — what follows it on its line is not read as a run (bash runs none of it); a here-document's plain arithmetic hides no run. **The here-document `${` arm is retired (design/1102 §2.3):** an unquoted body that holds `${…}` no longer makes every name unknown — a name the body assigns (`${N:=…}`, `${N=…}`, `$((N=…))`, `$[N=…]`) is unknown after it, a parameter text the lexer does not read or a body it cannot delimit still makes every name unknown, a command substitution's assignments stay in its subshell. *Who is hurt:* a reader that relied on the blanket arm — `N=/work/a; cat <<EOF⏎${N:-x}⏎EOF⏎cat "$N"` is vouched where it was a mandated ask.
245
+ - **Security, stock leak closed (#2121; narrowing only — Who is hurt: none).** An expansion a brace list or a bracket class encloses (`{${a[n]},x}`, `[${a[n]}]`, `{a,${a[n]}}`) was swallowed by the pattern piece: with `n='b[$(…)]'` both bashes run the substitution while the full-shell station vouched and the classify faces read a plain pattern. The lexer now keeps it as the pattern piece's fact (`WordExpansion.inner`), and the station, the classify faces, the tightening reach and the read-only member read it as the expansion it is. An unquoted extended-glob opener (`@(…)`, `!(…)`) leaves every segment of its text `unreadable` to the tightening reach (the lexer reads a word and a group where bash with `extglob` reads one pattern). A wrapper's `NAME=value` operand is read by the launcher's own grammar (`env 'A[0]=1' rm …` peels to `rm`), the one spelling the program-position reader already used.
246
+ - **Security, stock leaks closed (bash 5.3 forms; narrowing).** Bash 5.3's function substitution `${ list; }` and value substitution `${| list; }` were read as parameter expansions: `echo ${ cat .env; }` was vouched by the full-shell station under a `.env` deny although 5.3 runs the `cat`, and a deny or ask row on `rm` read `echo ${ rm -r x; }` as `not_reached`. The lexer reads both as command substitutions whose list runs in the CURRENT shell (bash 3.2 refuses them as a bad substitution; measured on 5.3: what the list assigns, where it moves, what it defines outlive the word). The list is read by the walker itself, in one pass: it closes at the first `}` that starts a word where a command may start once the list's own `{` groups are closed, whatever follows the brace (`${ echo a; }x` expands to `ax`); a list that never closes, still owes a here-document body at its brace, or stands more than 64 substitutions deep is `undelimited_substitution`. The deny / ask lanes read the list as a hidden run (`unreadable`, as `$(…)`'s is), the read-only member refuses, the auto-background gate checks the list as a command; the read station plans the list as text this shell re-enters — what it reads still answers to the deny rows (`refuse`), and the segment holding it is never vouched (`N=a; echo ${| N=.; N+=env; }; cat "/work/$N"` reads `/work/.env` on 5.3). *Who is hurt:* a command holding one of these forms asks where v7.53.0 vouched it, even when the list reads nothing (`echo ${ echo hi; }`); a stored rule body spelling an unclosed one (`echo ${ cat .env }`, a syntax error to 5.3) is no longer a readable body.
247
+ - **Security, stock leak closed (`$[…]`; narrowing).** The read station planned an `$[…]` body as command text — its arithmetic test was written for `$((…))` bodies — so `x='a[$(cat .env)]'; echo $[x]` was vouched under a `.env` deny although both bashes evaluate x's value and run the `cat` (measured). The lexer's arithmetic kind is the station's one reading now: what `$[x]`'s evaluator re-reads is planned, as `$((x))`'s always was (`refuse`).
248
+ - **`$((` is arithmetic exactly where its body is one parenthesised group (behavior change, both directions; measured on 3.2 / 5.3).** The `(` right after the opening one must close right before the closing one: `$((1+2))`, `$(((1)+(2)))` and `$((1+2)\⏎)` are arithmetic; `$((git push); :)`, `$(( cmd ) )` and `$((1)+(2))` are the command substitution of a subshell bash reparses them as. Bash reads a `$((` as arithmetic FIRST — its parentheses matched, no here-document read in it — so `$((1<<2))` (`4` on both) is arithmetic, where the one lexer read an owed here-document and refused the whole text (every rule `unreadable`: a deny on `rm` asked about `rm -r x $((1<<2))`); a `$((1<<2))` nested inside another substitution's body still leaves that body undelimited. The fact is read off the walk that delimits the body; the read station's own second test of it (`isArithmeticBody`, internal) is retired, and an arithmetic body's own expansions are read with the same rule (a nested `$((…))` is arithmetic, a `${ list; }` in it runs in the current shell). *Narrowing:* the evaluator reads what a command substitution in an arithmetic body PRINTS as an expression and evaluates its subscripts (`$(( $(echo 'a[$(cmd)]') ))` runs `cmd`, measured on both bashes) — text the station does not see, so it asks where v7.53.0 vouched (`echo $(( $(date) ))`, `echo $[ $(printf x) ]`; what the substitution itself reads still answers to the deny rows) and the auto-background gate keeps such a body in front; the deny / ask lanes read `$(( 1 + 2 ) )` and `$((1)+(2))` as hidden runs (`unreadable`); the auto-background gate checks the command in `$(( git status ) )`, `$((git push); :)` and `$(( sleep 9 ) )` (kept in front; v7.53.0 backgrounded them). *Loosening, open to veto:* `N=a; echo $((N=1); :); cat "/work/$N"` keeps `N` — the assignment stands in the subshell (measured) — so the station vouches it where v7.53.0 asked; `echo $((1+2)\⏎)` is plain arithmetic to the deny / ask lanes (`not_reached` where v7.53.0 read `unreadable`).
249
+ - **Hidden runs in a parameter expansion and in arithmetic (narrowing; the auto-background gate and the deny / ask lanes; measured on 5.3).** A transformation that evaluates (`${P@P}`, `"${P@P}"`, `${a[0]@P}`, `"${a[']']@P}"`) runs what P's value spells: the deny / ask lanes read it `unreadable`, the auto-background gate keeps it in front, and an unquoted here-document body holding one leaves every name unknown after it. The operator is the one written right after the name and its subscript — the subscript closing at its first `]` no quote or backslash holds — so an `@x` ending a default, replacement or pattern word is text (`${USER:-me@x}`, `${x/foo/@B}`, `${var:-x@P}`, `${a[0]:-]@P}`: unchanged from v7.53.0), and a respelling transformation (`@Q` `@E` `@A` `@K` `@k` `@a` `@U` `@u` `@L`) evaluates nothing. A substring offset or a subscript naming a variable is evaluated arithmetic for every parameter — a special one (`${*:N+=1}`, which v7.53.0's station threw on: `Invalid regular expression`) and a multi-digit positional one (`${10:N}`, `${11:N+=1}`) included: `unreadable` to the lanes, kept in front, and a name it assigns is unknown after it on the word path and in a here-document body (#2122). An indirect expansion (`${!R}`) evaluates a value the text does not show: `unreadable`, kept in front, every name unknown after a here-document body holding one. The auto-background gate reads an arithmetic body as arithmetic text — it runs no program of its own, stays in front where its evaluation assigns (`$(( N++ ))`), and what it expands first is checked as any text's is (`$(( $(git push) ))`, `$(( ${| git push; } ))` and `$(( ${P@P} ))` kept in front; v7.53.0 backgrounded all three).
250
+ - **Behavior narrowing, open to veto: the read station asks about every expansion that re-reads its value — an evaluating transformation `${P@P}` or an indirect `${!N}` — and follows no value.** One predicate decides it (`parameterRereadsValue`, the one the deny lanes and auto-background read through `expansionHidesRun`). Wherever the station reads a parameter expansion — a word, double quotes, an unquoted here-document body, an arithmetic body, a brace list — such an expansion leaves the text unestablishable (inside a compound assignment's value, which the station does not read, the next bullet's predicate asks instead): a mandated ask on the full-shell seat (`echo ${P@P}`, `"${P@P}"`, `cat <<EOF⏎${P@P}⏎EOF`, `echo $(( ${P@P} ))`, `echo {${P@P},y}`, `N=HOME; echo ${!N}`, `echo ${!1}`, `echo ${a[0]@P}`; v7.53.0's station vouched each, while bash runs what the value spells — `@P` on 5.3, an indirect reference's subscript on 3.2 and 5.3, measured). The station reads no value: what the text assigns, how deep a value nests, whether the text assigns one at all does not change the answer, so a plain-text value asks too (`P='plain text'; echo ${P@P}`) and a value the text never binds (the environment, `read`, `printf -v`, a nameref, a positional parameter) asks the same (KL-994o). A deny hit read elsewhere in the text still refuses (`P=x; echo ${P@P} $(cat x)` with `x` denied). A name or key list (`${!prefix*}`, `${!a[@]}`) and the respelling transformations (`@Q` … `@L`) re-read nothing and read as before. The deny lanes and auto-background are unchanged (they read these expansions as a hidden run). The card carries the station's sentence for text it cannot place (`the command's text could not be read as shell, so where it reads cannot be established`); it does not name the value re-read. *Who is hurt:* every command expanding `${P@P}` or `${!N}` asks where v7.53.0 vouched it, even when the value is plain text or names nothing. Following the value precisely is #994 S4 (#2131).
251
+ - **A parameter body is read once (narrowing; measured on 3.2 / 5.3).** The read station read a `${…}` body's subscript and substring offset by slicing past the expansion's name, blind to a `#` or `!` before it, while the deny lanes read the body's own grammar — so `x='b[$(cat x)]'; a=(1 2); echo ${#a[x]}` (and `"${#a[x]}"`, a here-document body's `${#a[x]}`), `echo ${!a[x]}` and `v=abc; V=v; echo ${!V:x}`, which evaluate x's value as arithmetic on both bashes (its subscript's substitution runs, measured; with the array or value unset only the indirect `${!a[x]}` evaluates it), were vouched by v7.53.0's station under a deny on `x`; the station reads the subscript whether or not the array is set (it refuses or asks where bash would evaluate nothing). The lexer now holds one reader of the body (`parameterArithmeticTexts`, internal: the head — an optional `#` / `!`, then the parameter, spelled once for every reading of a body — then the subscript and the offset), and both consumers read it: the deny lanes ask whether either text names a variable (their answers unchanged), the station plans what either runs (`refuse` above). The station also reads a here-document body's and an arithmetic body's parameter expansions with it: `a='arr[$(rm -r x)0]'; cat <<EOF⏎${arr[a]}⏎EOF` plans the `rm -r x` its evaluated subscript runs (v7.53.0 vouched it). A length with an offset (`${#a[@]:x}`) is read with its offset too, though 3.2 ignores it and 5.3 refuses it as a bad substitution (over-read, the asking direction; both consumers alike).
252
+ - **Behavior narrowing, open to veto: a compound assignment value the lexer does not read may run something — one predicate, three readers (closes a deny-lane loosening this section had opened against v7.53.0).** The one lexer keeps a compound value `(…)` as one opaque piece and hands the segment only its command substitutions, so a value whose element hides a run — `declare -a A=(${P@P})`, `(${!N})`, `(${v:x})`, `(${x:-$(rm -r x)})`, `([x]=1)`, which bash runs (`@P` on 5.3, the subscript and offset forms on 3.2 and 5.3, measured) — read `not_reached` to the deny / ask lanes as an operand of `declare` / `local` / `typeset` / `readonly` / `export` (v7.53.0: `unreadable`), was vouched by the read station and backgrounded. `compoundValueHidesRun` (internal, in the lexer; the value itself still unread) answers for all three: a `$` (but a `$'` that only opens a standalone ANSI-C string element, which runs nothing), a backquote, or a `[…]` subscript carrying a `$` (an ANSI-C `$'\170'` among them: bash decodes it to a name and evaluates the subscript — `([$'\170']=1)` runs x's value), a backquote or a variable name ⇒ the lanes read the segment `unreadable` (v7.53.0's reading), the station asks (the plan's `unreadable` floor: "a compound assignment value is a list this lexer does not read"; a deny hit read elsewhere still refuses), and auto-background keeps the command in front. A bare `A=(…)` already read `unreadable` under every single-program rule; the compound rule body `echo ok && rm -r` now reads it so too. *Who is hurt:* a command assigning such a value asks where v7.53.0's station vouched it and stays in front where it was backgrounded, even when the value runs nothing (`files=($x)`, `args=("$@")`, `local A=(${P@Q})`, `declare -A M=([key]=v)` — an associative key is not evaluated — and a single-quoted element that only spells a `$`, `a=('$(x)')`: the text is read, not the quoting). An integer array's ANSI-C element (`declare -ai A=($'\170')`) decodes and evaluates too but is not caught here — the `-i` attribute is not in the value text; its deny lane already reads `unreadable`, and #994 S4 closes the station and the gate (KNOWN-LIMITS KL-994p). *Loosening kept, open to veto:* a compound operand with no `$`, backquote or variable-naming `[…]` (`export A=(*.txt)`, `declare -a A=([0]=a [1]=b)`, `typeset -a A=(~/x)`) reads `not_reached` where v7.53.0 read every expanding compound operand `unreadable`.
253
+ - **A here-document parameter's default word is read for what it runs (narrowing; v7.53.0's refusal restored).** `cat <<EOF⏎${x:-$(cat x)}⏎EOF` (and `:=`, `:+`, `-`, a backquote form) was refused by v7.53.0's station under a deny on `x` — its body reader found the substitution — and only asked by this section's reading (the parameter text is one the lexer does not read). The station now reads such a text with the same double-quoted reader it reads the body with, keeping the ask floor: the substitution's deny read refuses again. A default word nested a second time (`${x:-${y:-$(cat x)}}`) still asks where v7.53.0 refused — a loosening in the security direction, open to veto (KL-994p ⑤: the lexer ends a here-document `${…}` body at its first `}`; the ask floor stays).
254
+ - **Behavior narrowing: an arithmetic body is double-quoted text (the bash manual's arithmetic expansion; measured on 3.2 / 5.3).** Quotes and an apparent `#` comment are ordinary characters there — `$(( '$(cmd)' ))`, `$(( "$(cmd)" ))`, `$[ '…' ]` and `$(( 1 # $(cmd)⏎ ))` run `cmd` before the evaluator reports its syntax error. Both readers read an arithmetic body's expansions off the lexer's one reader of such text (the unquoted here-document's) instead of re-lexing it with shell quoting: the station plans the substitution (`echo $(( '$(cat .env)' ))` refused; v7.53.0 vouched every form above) and the auto-background gate keeps it in front (v7.53.0 backgrounded every form above). The same reading takes a `<<` in arithmetic text as the shift it is: `echo $(( 1 << 2 ))` (`4`, runs nothing) is vouched and backgrounded where the re-lexed body read an owed here-document and asked (the loosening direction, measured).
255
+ - **`<>` is a lexer fact (#1106).** The read-write redirection was a parse failure to the one lexer; it is now the redirection `<>` (descriptor 0 unless one is written): the deny/ask lanes read the segment as before (a deny reaches through it), the read station judges its target as a READ and as a WRITE (it is opened for writing and created), the descriptor reading binds descriptor 0, and a shell reading its standard input reads that file. The read-only member treats it as a write.
256
+ - **Not fully reached in this section: the five importers of the retired lexer read only the lexer's facts; `permission-rule-model.ts` does not yet** — its `foldSpacing` quote walk and four whitespace splits (`ruleBreadthWarningsOf`, the `cd` segment and cursor readers, the suggestion tokenizer) still scan the text themselves (design R17), with #994 S4.
257
+ - **Sentences.** An unreadable segment's reason is the lexer's sentence (`a double-quoted word never closes` for `an unterminated double quote`, …); a redirection's operator is the lexer's (`>|` reads `>`, `>&file` reads `&>`, a here-document reads `<<`).
258
+ - **Who is hurt / consumers.** No public name or shape moves. A host that renders `readShellCommand`'s segments or reasons sees the readings above; `readOnlyShellVerdict` judges a shape `readShellCommand` returned (a shape built by hand carries no lexer facts: its assignments are programs and its expanding words values). @server @cli @client-core: no action unless you pin these sentences.
259
+ - **Pins.** `test/backlog994-s2-one-lexer.test.ts` (the deletions, the one assignment spelling, no text scanning in the fact readers, each moved reading on the real function, the here-document arm on the station; the bash 5.3 forms — the closer, the current-shell re-entry, the deny lanes, one-pass nesting under a wall-clock bound registered in `test/flaky-guard.test.ts`; the arithmetic kind, `$[…]`'s re-read value, the subshell's assignment; the transformation's subscript; the value re-read asks, a deny hit still refuses, the one body reader, and the controls that stay vouched — one cell fixture, `test/fixtures/backlog994-s2-fix7-cells.json`, each cell red under the mutation of its own rule; the 35 declaration-operand forms × two rule bodies `unreadable`, with bare-assignment, written-substitution and run-nothing controls); `test/backlog993-tool-policy-leading-assignment.test.ts` "the grammar has ONE spelling" re-pinned on the lexer's fact; the shell pins importing the retired module re-pointed; `test/module-size-ratchet.test.ts` (two rows retired, `shell-run-candidates.ts` 427 (FIX8: one import name), `fs-bash.ts` banked 1_962 → 1_877); `scripts/weak-identity-allowlist.json` +1 row (`LEXED_WORDS`, `call_binding`); the #1102 C equivalence pin's allowed file +5 classes (the #994 S1 dual run's attribution list unchanged: every new difference falls under an existing class); `test/backlog631-shell-lexer-reach.test.ts` `echo @(a|b)` re-pinned to `unreadable`, sentences to the lexer's (`<>` keeps its `reached` cell); `test/backlog1102-read-station-semantics.test.ts` B5 re-pinned to the retired arm (+ B5b); the activity-summary F-3 cells re-pinned (`workflow-caps`, `backlog532`).
260
+
261
+ ### The device lane's launch gate: a run that executes on a target device puts a person in front of every call that writes beyond the device — and, once content from beyond it was let in, every call back into the device — under the `multi_tenant` posture; the host lane decides exactly as before (#469 O9b S1; behavior narrowing, open to veto: only on a tree that executes on a target under `multi_tenant`, see below; behavior change: none on the host lane and none on a target tree that is not governed beyond a record, a trace and a report when the record cannot be written (C-R655); **hard-breaking, open to veto: a parked row of an armed tree carries the requirement word `cross_boundary`, which 7.49.0–7.53.0 workers refuse**; closed sets: `AskClass` +1 `cross_boundary` (after `requires_user_interaction`), `PersistedRuleMandate` +1 `cross_boundary_taint` (heads the ladder), `REQUIREMENT_WORDS` +1 `cross_boundary`, `TraceEvent` +1 `permission.cross_boundary_taint`, `EngineNoticeCode` +1 `config.cross_boundary_record_unwritten` (after `config.cross_boundary_posture_undeclared`; audience `operator`, minted by the engine); exports: +6 types `BoundaryLegsFor` / `BoundaryLegsGiven` / `CrossBoundaryCall` / `CrossBoundaryCheck` / `CrossBoundaryExitRefusal` / `CrossBoundaryTreeToken` (the tree's opaque identity); members: +1 optional `crossBoundaryMandated` on `PermissionResult`'s ask arm (and so on `PreToolUseResult`) — the flag's one carrier, +1 optional `CheckpointState.crossBoundaryRecorded`, +1 optional `ToolSpec.boundaryLegsFor`, +1 optional `ToolGateInput.crossBoundaryCheck` (engine-built), +1 optional `ToolExecuteContext.crossBoundaryTreeForChildren` (a `CrossBoundaryTreeToken`), +1 optional `RunInternals.crossBoundaryTree` (the same token), +1 optional `crossBoundaryTree` on `RegisterBackgroundAgentInput` and `BackgroundAgentTaskHandle` (the spawning tree's token, which a handle records) and on `RunWorkflowOptions` and `RunWorkflowToolDeps` (the same token, riding the workflow lane), +1 method `TaskRegistry.boundaryLegsOf` (the legs of a `TaskOutput` / `TaskStop` call, read off the handle the id dispatches to), +1 `uncoveredContent` on `CrossBoundaryRecordRead`'s `absent` / `recorded` arms) (@server @client-core @cli @test)
262
+
263
+ - **Before.** v7.53.0 and the vocabulary section above: every call was decided as if no device existed, and a policy's or hook's ask had no way to say it stands on a cross-boundary obligation (an unknown `crossBoundaryMandated` member was dropped).
264
+ - **Now.** One rule, read at three moments, under the tree's state at that moment (`crossBoundaryObligation` — the legs the call travels, whether the environment its `env` leg reaches declares itself the target now, the tree's three bits): the gate's cross-boundary station (after the write-protection tighten, before the unstated station) turns a surviving allow into a real-approval ask carrying `crossBoundaryMandated` (class `cross_boundary` — bypass-immune, no classifier, no sandbox admission, no persisted rule) and adds those members to a surviving ask; the live gate's allow exit and a resumed call's exit read the obligation again on the bytes that run — a person's yes stands, any other allow of a call that now needs a person (a policy's, the blanket seat's, a mode's release) is refused (`blanket_allow_refused`), and an off-target read sets the tree's bit and is written to the session before the call runs. The tree's state is one object every leg of the tree shares, named by an opaque engine-minted token (`CrossBoundaryTreeToken`: the delegation and workflow lanes hand the token down, a background agent's handle records it, so polling one's own child moves nothing; no tool reads or writes the state through it); each leg's session holds a record of it (`CROSS_BOUNDARY_RECORD_TYPE`, written only for an armed tree — at prepare and at an exit when the state grew, and at a leg's end, before a suspend's row is put and when the leg settles, whenever content follows the newest record), so a new turn, a durable resume and a tier-3 revival continue from it. A record covers the content before it: an armed leg that takes over a session whose content no record covers — none on the branch, or content after the newest one (a leg that ended without writing its record) — reads it as off-target. `TaskOutput` / `TaskStop` (the registry pair) give their legs from the handle (a background shell or monitor reaches its own environment; this tree's agent moves nothing; any other handle is content from beyond the device on a poll) and `Artifact` from its action (a publish reads its source through the device and, on a hosted plane, writes beyond it; the read family reads beyond it on a hosted plane). A policy's or hook's own `crossBoundaryMandated` now means what it says: classed `cross_boundary` and carrying the real-approval bit wherever it is read (the decision receipt adds the bit beside the member, so a composed, delegated or parked ask keeps it; the hook fold keeps the member). The member is legal on the ask arm only: the receipt refuses it on an allow, a deny or a hook result with no `action`, as it now refuses every ask-only member there (below: "Behavior narrowing — every member on the arm that declares it").
265
+ - **The host lane is byte-for-byte unchanged (I-2).** No posture and no target environment: no station is engaged, no record, trace or notice, the same decisions (`P17`; `.gate-logs/car-469-B2/cx/cx1` compares a v7.54.0-stage tree and this one over the same scripted runs). On a target tree that is not governed (it never ran under `multi_tenant` — no record of it says `governed`) a leg records and traces but never asks: every origin cell of every ask equals an undeclared run's (`P2`); a record that cannot be written mints the engine notice `config.cross_boundary_record_unwritten` and the call runs (ruled C-R655: no judgment reads the record of a tree that is not governed). A tree is governed by its state, not by the leg's posture: a session that once ran under `multi_tenant` stays governed under a later `single_user` leg.
266
+ - **Behavior narrowing (open to veto) — only on a tree that executes on a target under `multi_tenant`.** *What changed:* a write beyond the device, and after an off-target read any call into the device, needs a person; a configuration's allow of such a call is refused; on this governed tree, one disposition for a session record that cannot be written (the store throws, or answers with no entry id — ruled C-R655; a non-empty id the store did not actually write cannot be told apart by the engine, and the next leg's reader still finds content after the newest real record and reads it as off-target): a call that owes it before it runs is refused (`deniedBy: "policy"`, fail-closed), a suspend does not put its row (the fallback then answers the question and the record's sentence rides its refusal as the park note, the one note every failed park carries; where nobody judged the call — no approver, or one that reported nobody reachable — the note says the failed park is why it stopped and that it can be issued again once its park can be minted; where someone judged it, that answer is the reason and the note only reports the park), and a prepare or a settle, which have nothing to refuse, mint `config.cross_boundary_record_unwritten` — the next leg on the session then reads the content after the newest record as off-target. A record write is judged again after its store waits: when another leg grew the tree meanwhile, it writes again until the newest record covers the tree as it stands; a resumed call runs only on a pass — the approval-object station, then the exit — whose exit waited on nothing (each pass after a store wait reads its target again). The passes end: an exit waits only on a write the tree's growth owes — a write that failed on a tree that is not governed is not owed again for the same state (the tree's growth, or the leg's end, writes next) — and the tree has three bits. *Who is hurt:* a `multi_tenant` device deployment that ran unattended (blanket `onAsk: "allow"`, a policy-settled approver, `bypassPermissions`) — those calls now stop for a person; a deployment whose session store fails writes; a resumed call whose later pass's station vetoes it keeps the bit an earlier pass's exit set (the tree only grows), so the next leg's device calls ask a person once more. **Open to veto, a residue of KNOWN-LIMITS KL-469-c:** a background child that reads beyond the device while its parent's settle is writing its record, when the first append succeeds and the catch-up append fails, leaves that first (earlier-state) record as the session's newest — it tells the truth about the content already there, but content that arrives later from the child (its completion notice in the next turn) does not make the next leg read the strictest state. *How to migrate:* route these asks to a person (they carry class `cross_boundary` and `requiresRealApproval`); declare an engine-internal MCP / A2A tool `boundaryFlow: "engine"` with `egress: false`.
267
+ - **The failed-park note (every lane; wording only).** A refusal that follows a durable park that could not be minted (a checkpoint the store refused, or — on a governed device tree — a session record that could not be written) carries one note, worded by the deny exit's one reading of whether anyone judged the call (the reading its headless remedy and `delegation.ask_unresolvable` already use: no approver, or one that reported nobody reachable): nobody judged ⇒ the failed park is why the call stopped and it can be issued again once its park can be minted; someone judged ⇒ the note only reports that the park was tried, and the judgment stands as the reason. v7.53.0's note always said the refusal above was "not the reason the call stopped" — untrue where a person had refused.
268
+ - **Behavior narrowing — every member on the arm that declares it (open to veto; every lane, not only the device lane).** *What changed:* the decision receipt reads its member list off one member × arm table (fenced to the `PermissionResult` union both ways) and holds every present member to its row: the 21 members the union declares on the ask arm only (`requiresRealApproval`, `mandated`, `mandate`, `probeMandated`, `crossBoundaryMandated`, `probeCause`, `origin`, `classes`, … `foldedAskClasses`) are refused on an allow, a deny or a hook result with no `action` — a deny, `decisionReason: "rule"` (the gate's `deniedBy: "policy"`), the same family as a word outside a closed set; `action` / `updatedInput` / `message` / `decisionReason` / `attestation` stay legal on every arm. v7.53.0 carried the member on the other arm or dropped it (`{ action: "allow", crossBoundaryMandated: true }` ran). *Who is hurt:* a policy, hook or host wrapper that spreads an ask's members into an allow or a deny, or answers an allow with an ask-only member (`requiresRealApproval: false` included) — that call is now denied, fail-closed. *How to migrate:* answer an allow or a deny with the five members every arm holds.
269
+ - **Hard-breaking (type level) — the decision face.** *What changed:* `AskClass` gains `cross_boundary` (row: bypass-immune, never the classifier's, no sandbox admission, no rule clears it, no hook floor; it explains the real-approval bit) and `PersistedRuleMandate` gains `cross_boundary_taint` (its disclosure: the deployment's approver confirms; a configuration's allow is refused); the ask arm gains `crossBoundaryMandated` (`true` only; any other value is refused at the decision receipt, the `probeMandated` rule). *Who is hurt:* a downstream table exhaustive over `AskClass` or `PersistedRuleMandate` (it stops compiling, or misses the word at run time); a policy or hook that already returned a member of that name (v7.53.0 dropped it; it now classes the ask, refused under `bypassPermissions` when nobody can be asked); **behavior change, the loosening direction, open to veto:** a policy's own `askClass` / `classes` naming `cross_boundary` were unknown words v7.53.0 refused at the receipt, and are now replaced by the stamp's reading as every class word is (under `bypassPermissions` that ask is released) — a policy puts a call to a person on its cross-boundary obligation with `crossBoundaryMandated`, not with the display members (pinned in `design469-r3-boundary-legs.test.ts`, "the decision face with the launch gate"). *How to migrate:* add one row per word; drop the member from a policy that did not mean it.
270
+ - **Hard-breaking (type level) — the notice catalog.** *What changed:* `EngineNoticeCode` gains `config.cross_boundary_record_unwritten` (audience `operator`, minted by the engine through `crossBoundaryRecordUnwrittenNotice`; per failed write; `detail: { sessionId, runId, phase, governed, cause }`, `phase` one of `prepare` / `exit` / `suspend` / `settle`) — the one report of a session record that could not be written and refused nothing (ruled C-R655; it replaces the `onError` line an earlier stage tree printed). *Who is hurt:* a downstream table exhaustive over `EngineNoticeCode` (a notice projector, an audience or minter map) stops compiling or misses the code; a host that watched `onError` for this report sees it on `onNotice` instead. *How to migrate:* add one row (operator audience); project it where `config.cross_boundary_posture_undeclared` is projected (@server @client-core).
271
+ - **Hard-breaking (open to veto) — the requirement word.** *What changed:* a row parked by an armed tree carries `CheckpointState.crossBoundaryRecorded` and lists `cross_boundary` in its requirements. *Who is hurt:* during a rolling release, a 7.49.0–7.53.0 worker refuses such a row before the claim (`checkpoint.unsupported_version`, `version_newer`, `requires: ["cross_boundary"]`; the row stays pending); a ≤ 7.48.0 worker refuses every v10 row already; **a new turn on a device session has no version gate at all** — an older worker reads its content without the record. *How to migrate:* redeem marked rows on ≥ 7.54.0 workers; do not open a `multi_tenant` device lane before every worker of the fleet is ≥ 7.54.0; declare the posture on every worker.
272
+ - **Consumers.** *server:* feed `RunnerDeps.crossBoundaryPosture`, derived from `REQUIRE_PRINCIPAL`, explicitly and identically on every worker; project `permission.cross_boundary_taint` onto `GET /v1/runs/:id`; keep stripping a request-side `boundaryFlow` at the injection gate; three DEPLOY-PREREQS lines (marked rows redeem on ≥ 7.54.0 only; a new turn has no version gate, so the whole fleet is ≥ 7.54.0 before a `multi_tenant` device lane opens; one posture fleet-wide). *client-core:* the `cross_boundary` class / mandate word, the posture notice code and the trace kind are now live (the roster column landed in the section above). *cli:* no code dependency (the card's words come through client-core). *test:* the twelve criteria cells of design/380c §5.1 (G1–G12, G6′, G2′).
273
+ - **Pins.** `test/design469-r3-launch-gate.test.ts` (P1–P18 on the real lane: I-1 / I-2 / I-3, the Artifact actions, the registry handles, one batch in order, a mixed tree, the resumed exit on a moved declaration and on a tree changed since the park, U1, the new turn, the tier-3 revival, the live race, the Workflow line, G12, the host lane, the record that cannot be written; the policy's own member on the real gate under `bypassPermissions`, `auto`, the blanket seat and a persisted allow row); `test/design469-r3-boundary-legs.test.ts` "the decision face with the launch gate" (the member on an allow, denied by policy and never run; the receipt's arm screen per arm); per-fixture mutations in `.gate-logs/car-469-B2/mutations.md`.
274
+
275
+ no-notes: #794(③ 按新律重钉:allow 层之外的表态不再免 unstated 问;无新行为面), #1065(⑬c / ⑭b / ⑬d / ③ 重钉;改形 A 本节正文已述), #1314(⑪ 重钉:继承改参归一化后仍一卡), #595(a pin re-worded and restored within this version: the approval policy's changed-while-pending sentence is unchanged), #548(FIX8 只是修回 FIX7 把无审批人臂结算词改掉造成的 DL-3 停跑回归,#548 自身机制未变;见 #2111 小节「无审批人臂」句), #2122(#994 FIX2 顺手关:参数体 / 下标里的算术赋值在 word 路也按 lexer 事实 kill,机制句在 #994 小节;无独立行为面), #2096(verify-backlog-ledger 反搁置门随 main 并入;脚本门,零包面行为)
276
+
277
+ ## 7.53.0 — 2026-10-17
278
+
279
+ ### Docs and pins: the task-stream thinking arm, the classifier attribution sentence, the TaskEvent roster, one re-anchored pin (#1042, #1961, #2029, #1432)
280
+
281
+ - **#1042 (docs).** The task stream's thinking arm is `reasoning_delta` with no start / end frame — a sidecar that listens for the provider-layer name `thinking_delta` counts nothing (`docs/sdk/05-streaming-and-http-server.md`, one pointer sentence in `README.md`). INTEGRATION-CORE states the two host-side facts beside it: a host that renders a local date passes `TaskSpec.clientContext.timeZone` (erratum #2072: the first edition said `EnvironmentFacts.timeZone`, which is the facts carrier the engine fills from this field — a host driving a Runner passes the zone on `TaskSpec.clientContext`); a cache-stable deployment keeps one tool block across modes and refuses at call time rather than varying the block (`KL-1042-a` records the `</think>` leading newline as the template's own).
282
+ - **#1961 (docs, JSDoc only).** `GateDisposition.classifier` is the classification round's model attribution — present whenever the classifier ran, including the ask it answered that a person then settled; who settled is read from the settlement record (`human_allowed` / `human_refused` / …), never from the presence of `classifier` (`src/core/gate-outcome.ts` comment lines; one sentence in INTEGRATION-CORE §gate).
283
+ - **#2029 (docs).** INTEGRATION-CORE §1.0 no longer keeps its own copy of the TaskEvent roster; it references the frozen table in `test/defectscan-task-event-surface.test.ts`, so one list exists.
284
+ - **#1432 (pin re-anchored, no behavior change).** `test/backlog1432-deny-universe.test.ts` ② asserted that an undeclared extraTools product's names appear in the model-facing refusal; since #2051 (this release) the model gets one fixed sentence and the names live in `details.cause`. The pin now asserts exactly that (names absent from the text, present in `details.cause`).
285
+
286
+ ### `brainLaneOf(model, routes, by?)` is the one judge of which mounted line serves a model, exported from the package root; `createRoutingBrain` asks it over the same table (#1842; additive export; behavior narrowing for a model with no route word and for a mounted route whose value is not a brain, open to veto; hard-breaking: none; closed sets: none; exports: +1 function; members: none) (@server)
287
+
288
+ - **Before.** The rule "the model's provider word names a mounted route ⇒ that line, else the fallback (the gateway)" was written inside `createRoutingBrain`; every host that needed the same answer (server `dispatchBrainLane` at `brain.ts` ×2, `model-route-endpoint.ts`, `hooks/hook-llm.ts`) wrote its own.
289
+ - **Now.** `brainLaneOf(model, routes, by = "provider")` answers the route's name, or `undefined` when the fallback serves the model (a word with no route under it, or no string word at all). `routes` is the route TABLE itself — the very object the host hands `createRoutingBrain`, which asks this function for every request — so the judge and the router answer from one reading of one table, whatever its shape. The question is `Object.hasOwn(routes, word)`: it evaluates no entry and lists no keys, so a non-enumerable own route is a route, a table that answers for names it does not list (a lazily keyed `Proxy`) is asked about the one name, and a name the table only inherits (`constructor`, `__proto__`) matches nothing unless a route is mounted under that very name. The router reads the selected entry once; an entry the model did not select is never evaluated, as before. The choice between the provider word and the api word is written once.
290
+ - **Behavior narrowing (open to veto).** (1) **What changed:** a model that carries no string route word (`provider` / `api` absent or not a string) is served by the fallback; a route mounted under the model's word whose value is not a brain fails that request with an error terminal naming the route (`createRoutingBrain: route provider="x" is mounted but its value is not a brain`) where 7.52.0 served it from the fallback, because the judge names that line and a request is never moved off the line the judge named. **The form most likely to meet this is `{ anthropic: cfg ? brain : undefined }`**: the own key is present with the value `undefined`, so 7.52.0 served every request for that word from the fallback and this version fails each of them with that error terminal — the key must be absent, not `undefined`. With no fallback configured, the same request was already an error terminal and stays one; its text changes from `createRoutingBrain: no brain for provider="x"` to `createRoutingBrain: route provider="x" is mounted but its value is not a brain` (the old sentence called a bad table a missing brain). Not changed: a lazily keyed table is routed as in 7.52.0. Also: a route table that throws while it is asked or read (the selected entry's getter, a descriptor trap) fails the request with an error terminal where 7.52.0 threw synchronously out of `stream()`, against the brain contract; so does a `Model` whose `provider` / `api` / `id` getter throws. The router reads the route word once per request and every failure sentence spells that one answer (7.52.0's failure arm read the provider a second time, and a getter that threw there threw out of `stream()`); nothing else of the model is read on the way to a brain, and the words the error terminal names the request by (`api`, `provider`, `id`) are read where the terminal is built, in one guard (a getter that throws leaves them blank and the sentence says so). A route word that cannot be read at all is the error terminal `createRoutingBrain: the model's provider could not be read …` (or `api`), not a move to the fallback. (2) **Who is hurt:** a table with a route literally named `"undefined"` served such a model by string coercion of the missing word; a host whose table holds a non-brain under a word some model carries had those requests silently served by the gateway. Nobody is known to rely on either. (3) **Compensation:** mount the route under the word the model carries; mount a brain, or remove the entry so the fallback serves the word.
291
+ - **How to migrate.** *server*: replace `dispatchBrainLane(provider, anthropicRouteMounted)` with `brainLaneOf({ provider }, routes)` over the route table it mounts (the one it hands `createRoutingBrain`), mapping `undefined` to `"gateway"`; a names list is not a table (the type refuses an array or a `Set`). *any host that builds the table with an optional brain*: write `{ ...(x ? { anthropic: x } : {}) }`, never `{ anthropic: x ? brain : undefined }` — a mounted key whose value is not a brain is a broken line now, where it was the fallback's.
292
+ - **Pins.** `test/backlog1842-brain-lane-judge.test.ts`.
293
+
294
+ ### A `toolAxes` declaration has one reader, `readToolAxes(declared, at)`, and one disposition for an MCP server and an A2A peer alike: a task whose A2A peer declares axes the engine cannot read is refused at the preparation door, as one whose MCP server does (#1835; behavior narrowing, open to veto; **hard-breaking: the public type `McpToolAxesRead` is removed, `ToolAxesRead` is its replacement, no alias** — no consumer seen in server, client-core or cli; closed sets: +1 `ToolAxesSurface` (`mcp` / `a2a`, the `at` of `readToolAxes`); exports: +1 function, −1 type `McpToolAxesRead`, +2 types `ToolAxesRead`, `ToolAxesSurface`) (@server @web-admin)
295
+
296
+ - **Before.** `readMcpToolAxes` judged an MCP server's `toolAxes` at the preparation door (a declaration it cannot read refused the whole prepare); `A2aServerSpec.toolAxes` (the same declaration) was trusted as the type said, and a host that assembled it from JSON kept its own reader (server `src/tool-axis-words.ts`).
297
+ - **Now.** `readToolAxes(declared, at)` with `at` `"mcp"` or `"a2a"` (`ToolAxesSurface`, a closed set: a value outside it throws `config.invalid_argument` naming the two, whatever is declared) is the reader of both. `readMcpToolAxes(declared)` stays as its MCP entry (`readToolAxes(declared, "mcp")`). The answer and every refusal arm are the ones of 7.51.2; the one difference is the noun for the keys in the sentence (`tool` for MCP, `skill` for A2A). One judge per entry asks it for both surfaces, and one function builds the refusal (`config.invalid_argument`, "<subject> declares toolAxes the engine cannot read: …"; subject `MCP server "x"` or `A2A peer "x"`). (a) The preparation door judges every `TaskSpec.mcp` server and every `TaskSpec.a2a` peer: a declaration it cannot read refuses the whole prepare before any server is spawned or any peer is dialed (zero turns), and the task dials the door's frozen copy of each peer (the door reads `spec.a2a` once — a `{ ...spec }` rebinding later in prepare reads it again, KL-1835-b; a `toolAxes` — or any member — carried as an own property the object does not enumerate is read and judged like any other, and one carried on a prototype is refused: neither is read as if it were absent). (b) The materialization entrances — `materializeMcpTools` / `materializeA2aTools` called as a library — ask the same judge per entry as their own guard: there a declaration it cannot read fails that server's or that peer's dial (the A2A peer: never dialed, warning `a2a.peer_unavailable` whose cause is the refusal; a peer the snapshot refuses is held — in its warning, its status and the collision check — under the name its own string `name` data property held when the snapshot read it, which the refusal carries as `declarer`, or by its place when the name is no such property (a getter included); a peer the axes judge refuses is held under the name its snapshot copied; either way the caller's object is never read a second time to name it), its siblings unaffected. An A2A peer's declaration is read and judged once per materialization and is frozen for the run, **to its members**: the entrance copies `headers`, `allowSkills` and `toolAxes` into frozen plain data, so a refresh dials that copy and no longer re-reads the host's object — a declaration refused at the entrance is refused again by every refresh, and a host that rewrites its own object afterwards, by replacing `spec.a2a[i].toolAxes` / `headers` or by editing one in place (a header value, `allowSkills.push`), changes nothing a refresh dials; what a refresh can still fail on is the peer (its card, the network), and there the tools it had are kept, as before. An MCP server's entry is read to the same depth by the same function (`transport` with its `args` / `env` / `headers`, `allowTools`, `toolFaces` are copied too; 7.52.0 copied an MCP entry's top level only), so the dial, a refresh and a re-dial work from the copy. The door and the library entrances are total over a declaration they cannot read: a getter that throws, a Proxy, a nesting past 1 000 levels below the declaration (the engine's one depth for a caller's value — the bus holds a handler's values to the same — said `it is nested past 1000 levels` on every platform, with the member path bounded at the reader as at the door, #2017; one held to no depth would be refused by one platform's stack and judged by another's vocabulary, its path running to thousands of characters), an entry that is not an object, a `TaskSpec.a2a` / `TaskSpec.mcp` that is not an array (a Proxy included) or whose element cannot be read — all are `config.invalid_argument` naming the declarer and the member (`A2A peer "p" declares url the engine cannot read: …`), the same sentence for both surfaces and both entrances (the door refuses the whole prepare; a library entrance refuses the A2A peer alone, the MCP list as a whole call), never the raw error the host's code threw and never read as "no entries". One reading of a declaration, the engine's own: an entry, a `toolAxes` record and a lock declaration are read by the engine's one receipt of a caller's object, in its declaration mode — a Proxy refused at any level before anything is asked of it (whatever length or keys its traps would answer), a record's prototype `Object.prototype` or null, every own string-keyed member read once off its captured descriptor (enumerable or not; a getter called once, as captured — never through a `call` member the getter function carries, for every reader of the receipt), every level copied and frozen; a list is copied from its own index descriptors (an array whose iterator answers otherwise than its indices cannot rewrite an allowlist); an object the entry reaches through two members is read once and both hold the one copy. A symbol-keyed member is not a member of a declaration (JSON cannot produce one and no reader reads one): it is neither read nor copied (7.52.0 carried an MCP entry's enumerable symbol members onto its top-level copy, where nothing read them). On that copy, the declaration's own vocabulary: (c) each axis of a tool or skill is read by its fixed name, once, never assembled from a key listing — a per-tool record whose listing leaves an axis out cannot drop it (such a record is a Proxy, refused), and a value a name answers that the copy does not hold as its own (an axis supplied from `Object.prototype`) is refused as a hidden member; (d) a record's prototype is compared by reference — this realm's `Object.prototype` or null — so a record built in another realm (a `vm` context, a worker), one built on a Proxy of `Object.prototype`, or on a null-rooted object that carries the root's names, is not plain data and is refused; (e) every list a declaration carries is a list of names: an element that is a hole, `undefined`, `null`, a number or a record (`allowTools`, `allowSkills`, `transport.args`, a face's lists, `lockedConfig.keys`) is refused naming its index (`MCP server "s" declares allowTools[1] the engine cannot read: it is a hole, not a string: …`), as is a list's own member that is not an index (`allowTools.extra`, a name spelled as digits quoted — `allowTools."4294967295"`; 7.52.0 carried it and no reader read it), and no member is named `__proto__` (a tool, a face, an env or header name, an axis member — a copy by assignment would turn it into a prototype); and every record a declaration's type writes as names to strings holds strings — an MCP server's `transport.env` and `transport.headers`, an A2A peer's `headers`: `env: 5`, `env: null`, `env: []`, `env: { A: 5 }` or `headers: { A: 5 }` is refused naming the member (`transport.env.A`, `headers.A`), on a fresh run and, before the claim, on a resume (7.52.0 did not judge these values: for `env` the SDK spread a value that is not a record away and handed a number to the process as text); a member holding `undefined` is absent there as everywhere in a declaration; each surface judges only its own paths (an A2A peer's `transport` is a member it does not read). And a declaration's `name` is a string and is there: `name: 5`, `name: {}` or no name is refused naming `name` (7.52.0 did not judge it). A snapshot is copied whole or refused whole: a value the copy cannot carry whole — a Proxy, an object whose prototype is neither `Object.prototype` nor null (a class instance, a `Map`, `process.env`, a record built with `Object.create(defaults)` or in another realm), a function, a bigint, a structure that contains itself — refuses it as `config.invalid_argument` naming the member where reading stopped (`MCP server "s" declares toolFaces.write_file the engine cannot read: it is not plain data (…); copy it into plain data (a spread or a JSON round trip), writing out any member it inherits`). The host's object is never handed on, so a member whose getters have run once is never read again downstream. The lock declaration, the first thing the same preflight reads, is total the same way: a `RunnerDeps.lockedConfig` that is `null`, a list or a primitive, that is not plain data (a Proxy, a class instance, a `keys` that is a `Set` or a Proxy, a getter that throws), whose `keys` is absent or not a list, or whose `keys` holds an element that is not a string or a string that is not a registry name, is `config.locked_registry` naming `RunnerDeps.lockedConfig` and the member, an element by its index (`RunnerDeps.lockedConfig.keys[1] is "bogus", which is not a member of the locked-key registry …`), read once by the same reading — on a prepare, and on a resume's screen before the claim, which enters the same preflight (#1950).
298
+ - **Behavior narrowing (open to veto).** (1) **What changed:** a task whose A2A peer declares `toolAxes` the reader cannot read (an unknown member, a word outside the vocabulary, a string `egress`, a shape that is not a record of per-skill records) is refused at the door with `config.invalid_argument` — the same disposition and the same sentence, subject and noun aside, as an MCP server's. In 7.52.0 such a declaration was folded as written; one class was refused already, by the fold, with another code: an out-of-vocabulary `effect` on a peer the engine marks egress (`config.egress_requires_write_effect`), which now reads `config.invalid_argument`. Also: a refresh no longer re-reads the host's declaration (7.52.0 re-read the host's live object on every refresh and folded whatever axes it then held, unjudged), nor any nested member of it (`headers`, `allowSkills`). Also, on both surfaces: a declaration the engine cannot read is `config.invalid_argument` (7.52.0: the host's raw error, with no code — or, for a `TaskSpec.a2a` that was a record or `null`, no peers at all, silently); a `TaskSpec.a2a` / `TaskSpec.mcp` that is present and not an array is refused (`null` included); and an MCP library entrance (`materializeMcpTools`, the no-run probe) whose entry carries a nested member that throws when read now throws for the whole call, where 7.52.0 failed that server's dial and mounted its siblings (the A2A library entrance still isolates the peer). Also, on both surfaces: a declaration that is not plain data to its depth is refused (`config.invalid_argument`, naming the member) — 7.52.0 handed an MCP entry's nested members on as the host's own objects (a tool face built on a defaults prototype was read through it; `env: process.env` was passed as it was), copied only the entry's own enumerable members (an `allowTools` inherited or not enumerable was dropped, and every tool mounted — now one inherited refuses the entry, and one not enumerable is read and in force), read a `toolAxes` inherited or not enumerable by name (now: not enumerable, read; inherited, refused), and copied nothing of an A2A peer; a `contentOrigin` that is a self-containing value, a record carrying a function or a BigInt now reads `config.invalid_argument` naming the member, where 7.52.0 answered `config.mcp_content_class`. Also, on both surfaces, by the same reading: a Proxy anywhere in a declaration (7.52.0 read through its traps) and a record built in another realm (a `vm` context, a worker; 7.52.0 took it — the axes reader as a root it recognised by its names) are refused; a list element that is not a string is refused naming its index (7.52.0 kept it: `allowTools: ["Read", null]`); a member named `__proto__` is refused (7.52.0 kept it as an ordinary key, a tool so named included); a per-tool axis the record holds as a non-enumerable own member is read (7.52.0's axes reader refused the record); and at an MCP library entrance a `toolAxes` that is not plain data (a `Map`, inherited members) is refused with its entry, the whole call, where 7.52.0 failed that server's dial alone — one rule for every member that is not plain data, while a plain-data `toolAxes` the vocabulary refuses still fails that server alone. Also: a lock declaration that does not parse is `config.locked_registry` — 7.52.0 threw a bare `TypeError` out of the prepare for `null`, a list, a primitive or a missing `keys`, threw the host's own error for a getter that throws, iterated a `Set` of keys and took it (and read a Proxy list or a class instance's `keys` through), and took a member that only converts to a registry name (`{ toString: () => "mcp" }`) while the lock it named was never in force. (2) **Who is hurt:** a host whose task declared such a peer: the task started before, it is refused now; a host that changed a peer's axes mid-run and relied on a refresh to pick them up — a declaration is a run's configuration, change it for the next run; a host that edits a nested object of a server or peer in place and expected a refresh or re-dial to see it; a host that passes `a2a: null` for "no peers" (omit the key, or pass `[]`); a host whose MCP entry has a hostile getter below the top level and relied on its siblings still mounting; a host that declares an entry, or a record in it, as anything but plain data — `env: process.env` is the form most likely to meet this; a class instance, a `Map`, a record built with `Object.create(defaults)`, a Proxy, a declaration built in a `vm` context or received from a worker — and every member is judged, the ones the engine never reads included (a host's own `createdAt: new Date()`, a method, a `Headers` object); a host whose list holds an element that is not a string; a host that names a key `__proto__` (a remote tool literally so named can no longer be given axes or a face); a deployment that passes `lockedConfig.keys` as a `Set` or a Proxy (pass a list). (3) **Compensation:** the refusal names the peer, the skill and the member (an element by its index); fix that one declaration, or ask `readToolAxes(declared, "a2a")` at the host's own door and never submit the bad one; copy a value that is not plain data into plain data, the members it inherits included (`env: { ...process.env }`, `{ ...defaults, ...face }`, `JSON.parse(JSON.stringify(x))` for another realm's record).
299
+ - **BREAKING (type only; exports −1 +2).** `McpToolAxesRead` is removed from the package root (`index.ts`, and the `core/types` re-export); `ToolAxesRead` is the same shape under a name that is true of both surfaces. No alias is kept (one name per fact). Who breaks: a host that imports the type by name. Checked for this entry: `sema-server/src`, `sema-client-core/src`, `sema-cli/src` hold no import of it (server's `test/mcp-tool-axes-deployment.test.ts` names it in a test title only), so no consumer is seen. Migrate: rename the import; a value typed `McpToolAxesRead` is typed `ToolAxesRead`.
300
+ - **Who breaks (behavior).** *server*: `test/mcp-tool-axes-deployment.test.ts` cell ⑧ pins the axes reader's 7.52.0 wording for four shapes; the verdicts are unchanged (refused, one row, no value in the sentence), the wording is: a class instance or a record whose tools are inherited — `toolAxes cannot be read: it is not plain data (…)`; one tool's entry inherited — `the axes declared for tool "fetch_page" do not fit: it is not plain data (…)`; a getter that throws — `the axes declared for tool "fetch_page" do not fit: its getter threw while it was read`. *server*: a request whose A2A peers carry a declaration the reader refuses now fails at the engine's door (`config.invalid_argument`, subject `A2A peer "<name>"`) instead of starting; a test that expected the task to start with such a peer, or that pinned `config.egress_requires_write_effect` for an out-of-vocabulary peer `effect`, re-pins to the door's refusal. A resume whose `taskConfig` carries an unreadable A2A declaration is refused the way an MCP one is: before the claim, `checkpoint.config_refused` carrying `config.invalid_argument` (#1950, below). *any host* (open to veto): an MCP transport declared with `env: process.env` — or any record of an entry that is not plain data — is refused at the door (`MCP server "<name>" declares transport.env the engine cannot read: it is not plain data (…); copy it into plain data (a spread or a JSON round trip), writing out any member it inherits`) where 7.52.0 started the task; write `env: { ...process.env }`. The same for a declaration built in another realm (`vm`, a worker: a JSON round trip), a Proxy, a list element that is not a string, a key named `__proto__`. Checked for this entry: no MCP transport in `sema-server/src`, `sema-cli/src` or `sema-client-core/src` passes `process.env` as a declaration record (server builds its entries as literals, `src/task-mcp.ts`).
301
+ - **How to migrate.** *server*: the A2A request leg calls `readToolAxes(declared, "a2a")` at its own door and retires `tool-axis-words.ts` (the M19 migration reader keeps its own pre-upgrade reading). *web-admin*: nothing.
302
+ - **Pins.** `test/backlog1835-tool-axes-one-judge.test.ts` (the over-deep nesting cell: 50 000 and 1 001 nested lists under `allowSkills` in one sentence, the bound's, and 1 000 read whole); `test/backlog1989-declaration-receipt.test.ts` (E: the declaration mode is held to `MODS_VALUE_DEPTH`); `test/backlog2013-iterative-receipt.test.ts` (C); `test/backlog1763-mcp-tool-axes-judge.test.ts` re-pinned to the renamed type and to the one reading; `test/backlog1842d-plain-data-snapshot.test.ts`; `test/backlog1992-axes-fixed-name.test.ts`; `test/backlog1989-declaration-receipt.test.ts`.
303
+
304
+ ### `isMintableProbeReason(text)` is the length judge of a probe's one-sentence account, exported from the package root (#1883; additive export; hard-breaking: none; closed sets: none; exports: +1 function; members: none) (@server)
305
+
306
+ - **Before.** The engine capped `probeReason` at `PROBE_REASON_MAX` (200 code points) inside `inlineUntrusted`, whose longest result is the cap plus one truncation ellipsis; the cap was not exported, so a relaying host (server `approval-card.ts` `MAX_PROBE_REASON_CODEPOINTS = 201`) hand-copied the number and the "+1".
307
+ - **Now.** `isMintableProbeReason(text)` answers whether `text` is at most the longest sentence the engine mints (code points). The ceiling is `PROBE_REASON_MAX` plus `INLINE_TRUNCATION_MARK_CODEPOINTS` (the width of the engine's one truncation mark, a named constant `inlineUntrusted`'s own re-cap reads too), so the "+1" is written once. `PROBE_REASON_MAX` itself is not exported: no host needs the number, only the question. The judge is total: a value that is not a string is not a sentence the engine mints and answers `false` (never a throw), which is the relay's answer to an over-long one too — drop it whole.
308
+ - **How to migrate.** *server*: `if (!isMintableProbeReason(raw)) return undefined;` and delete `MAX_PROBE_REASON_CODEPOINTS`.
309
+ - **Pins.** `test/backlog1883-probe-reason-envelope.test.ts`.
310
+
311
+ ### The machine-read slot of a rule's text is the original in both notice codes, through one reading: `permission.rule_row_dropped` `detail.rows[].rule` changes from the neutralized spelling to the original string (#1884; consumer-visible value change in a notice detail, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @client-core)
312
+
313
+ - **Before.** `permission.allowed_tool_not_granted` handed `detail.entries[].entry` over as written (`Write ` with its trailing space), unbounded; `permission.rule_row_dropped` handed `detail.rows[].rule` over as `inlineUntrusted(rule, 200)` (`Write`; whitespace folded, ends trimmed, cut at 200): one kind of fact told two ways.
314
+ - **Now.** Both machine-read slots carry the original text through one function (`noticeRuleText`, `src/core/permission-rule-model.ts`): the string as written when it is at most 1 024 code points (`NOTICE_RULE_TEXT_MAX`, twice the grammar's own 512-character bound, so every text a rule can be is carried whole); a longer one is cut to 1 024 and ends in the engine's one truncation mark (`…`, the constant `inlineUntrusted` reads too), 1 025 code points in all. `NOTICE_RULE_TEXT_MAX` is not exported: a consumer reads the slot and never judges its length. Control characters (a NUL, a newline, a tab, U+2028) ride as data, like any other string in a notice `detail`: the detail is not a display surface, a consumer that renders it neutralizes it as it does `entries[].entry`. Key names are unchanged (`entries[].entry`, `rows[].rule`). `scopeRoot` stays neutralized and capped at 200.
315
+ - **The sentences.** They already share one spelling and are unchanged: the allowed-tools sentence writes each entry through `renderListEntry` (quoted, invisible characters written out, capped; the engine's one writer of a list entry into a sentence), and the dropped-rows sentence names no rule at all (its pin keeps the sentence free of a project path, which a rule text can carry), so no sentence spells one rule two ways.
316
+ - **Consumer-visible value change (open to veto).** (1) **What changed:** `rows[].rule` is the rule text as the store served it (the original up to 1 024 code points; a longer one is 1 024 plus the truncation mark, 1 025 in all) and no longer the neutralized spelling cut at 200; `entries[].entry` is bounded the same way (a longer entry was carried whole before; such an entry reads as no rule at either length). (2) **Who is hurt:** a consumer that relied on `rows[].rule` being one line and at most 201 code points, or that compared it with the neutralized form; the rows come from the person's own store (a foreign writer or a hand edit makes them) and the notice carries the first eight. (3) **Compensation:** a consumer that renders the slot neutralizes it, as it does `entries[].entry`.
317
+ - **How to migrate.** *server*: delete the 🚧 pin in `test/notice-entry-presentation.test.ts` (the two slots are now byte-identical) and keep `RULE_TEXT_DETAIL_FIELDS`'s two (array, field) pairs. *client-core*: nothing generated changes (no key added or removed).
318
+ - **Pins.** `test/backlog1884-notice-rule-text-one-reading.test.ts`.
319
+
320
+ ### `ANTHROPIC_DEFAULT_BASE_URL` is the Anthropic API's own request root, exported from the package root; the brain and the `fable5Model` preset read it (#1905; additive export; hard-breaking: none; closed sets: none; exports: +1 constant; members: none) (@server @cli)
321
+
322
+ - **Before.** The Anthropic brain kept the root as a private constant (`ANTHROPIC_LANE_DEFAULT`) and `fable5Model` wrote the same URL as a literal; server `src/brain.ts` and the shell mirrored a third and fourth copy.
323
+ - **Now.** One exported constant, `"https://api.anthropic.com"`, read by the brain (request root, pairing law's lane default) and by the preset's `baseUrl`. The engine's reading of a declared root beside it is unchanged: nothing, an empty string and whitespace read as undeclared (the lane default applies); trailing slashes fold.
324
+ - **How to migrate.** *server* (S-1655) and *cli*: import the constant and drop the mirrored literal.
325
+ - **Pins.** `test/backlog1905-anthropic-default-root.test.ts`.
326
+
327
+ ### A suspended `GoalResult` carries the gate it is parked at: `suspendGate`, on every suspended result and on no other (#1934; additive; hard-breaking: none; closed sets: none — the gate's words are `CheckpointGate`'s; exports: none; members: +1 optional `GoalResult.suspendGate`) (@cli)
328
+
329
+ - **Before.** `status: "suspended"` did not say whether the run was waiting for quota, stopped by the host to be resumed, or waiting for a decision; the gate that says it sat one level down (`result.terminal.gate`) behind an optional `result`.
330
+ - **Now.** `GoalResult.suspendGate?` (a `CheckpointGate`, narrowed to the kinds whose status word is `suspended` — `human`, `irreversible_ask`, `resource_limit`, and `task_done`, the handle a deployment parks, which a run of this engine mints in no case but a custom runner seat may return as a paused terminal; the two review kinds are `needs_review` and never appear: the pause registry's `taskStatus` column is the one table the status word and the type both read), present IFF `status === "suspended"`: the paused terminal's own gate, the same object as `result.terminal.gate`, beside the `checkpointToken` that rides up from the same terminal. Why the run is parked is read from the gate alone, never from the caller's signal: `{ kind: "resource_limit", reason: "usage_window" }` waits for quota (`resumeAfterMs`); `reason: "preempt"` is the host's own resumable stop (`TaskSpec.preemptSignal`); `human` / `irreversible_ask` wait on an approval; another `resource_limit` reason waits on an allowance. A caller's abort that ends a run is `status: "aborted"` (no checkpoint, no key); a park that won the race with it reads that park's gate. `needs_review` and every other status never carry the key.
331
+ - **How to migrate.** *cli* (L-1533): render off the gate — `gate.kind === "resource_limit" && gate.reason === "usage_window"` (waiting for quota), `gate.kind === "resource_limit" && gate.reason === "preempt"` (stopped by the host, resumable; test the kind too — a `human` / `irreversible_ask` gate's `reason` is free text, the approval's own sentence), every other gate by its own words (an approval pending, an allowance spent); read `"suspendGate" in result` or `status === "suspended"`, which are one test.
332
+ - **Pins.** `test/backlog1934-goal-suspend-cause.test.ts`.
333
+
334
+ ### A run with no memory store mounted and nothing that can persist is told so by the engine, in the memory band; `NO_PERSISTENT_MEMORY_NOTICE` leaves the package root (#1920; behavior change — a prompt addition, open to veto; **hard-breaking: the exported constant `NO_PERSISTENT_MEMORY_NOTICE` is removed, no alias**; closed sets: none; exports: −1 constant; members: none; the bundled catalog gains one message, `core/memory.no-persistent-store`, so its digest moves) (@server)
335
+
336
+ - **Before.** The memory disclosure seat had four arms, all for a mounted store (capture opt-out, capture state unknown, read-only in two forms, a declared channel retracting the read-only notice). With no store mounted the engine said nothing; the notice text was exported as `NO_PERSISTENT_MEMORY_NOTICE` for a host to compose in, and a host that did had to judge for itself whether memory was mounted and whether anything on the roster could persist (server's per-request judge: memory face mounted, hands read-only, file plane local, a persisting tool left after the selector).
337
+ - **Now.** The seat's fifth arm: no store configured to mount (no `TaskSpec.memory`, memory disabled, or enabled with no `RunnerDeps.memoryBackend`) **and** the roster-wide persistence verdict false ⇒ the notice is the memory band, at the tail of the prompt after the environment block, in the slot a mounted store's disclosure takes. The verdict is the one the four arms read, `rosterCanPersistMemory`: `TaskSpec.memoryPersistenceCapable` when it is set, otherwise whether a tool the run's tool face leaves mounted (what the tool valve leaves, below) can write a file on this host (a local `Write` / `Edit` / shell; a remote hand band or a read-only one cannot). The text is byte-identical to the retired constant and names no tool. A store that IS configured but failed to mount this run (the fail-open mount arm; the run's `effectiveMemoryScopes` reads `memoryless` / `mount-failed`) is not told this sentence, which is categorical and false once a later mount succeeds: in the same slot, under the same verdict and the same delegation rule, it gets the seat's writes-unavailable-right-now notice, with the failed mount as its cause and no stored notes promised below it. That notice is one template with two typed facts (`what`: the capture record store is unreadable, or the store did not mount; `notesReadable`), and a mounted store's unreadable-capture-state arm reads the same template — its text is byte-for-byte what it was (the internal constant `MEMORY_CAPTURE_INDETERMINATE_NOTICE`, never a package-root export, becomes `memoryUnavailableNotice(facts)`). Said to the run that holds the user's conversation: a root run, and a fork (it continues the parent's conversation); a delegated child (an `Agent` spawn, a workflow leg) is not told — the delegation fact is the one the prompt's `isSubagent` fact and `loadProjectMemory`'s `isSubagent` read. A mounted store's four arms are unchanged byte for byte, except under `excludeAllTools` (below).
338
+ - **Behavior change (open to veto).** (1) **What changed:** a root run or fork with no memory mounted and no persisting tool carries about 80 more tokens of system prompt (the notice). (2) **Who is hurt:** a host whose own memory tool persists through its closure (a tool that is not a local file writer) and that has not declared `memoryPersistenceCapable: true` — the model is now told it cannot store, beside a tool that can; and a deployment that mounts an MCP memory server (a server whose tools write but whose tool entries carry no `pathTarget` face, a knowledge-graph memory server for one): the verdict counts a tool as able to persist only by a known store path (a path-confinable write, or the shell family), so that server's writer does not count, and a run with no store mounted is told it cannot store while the writer sits mounted beside it (before this version it was told nothing). (3) **Compensation:** either of two, per deployment. Declare the channel, `memoryPersistenceCapable: true` (the existing seat; the notice is not said and the declaration means the same thing to the four mounted arms). Or give the server's writer tool a write face, `toolFaces.<tool>.pathTarget` with `access: "create"` on that server's entry (its `param` must name a top-level parameter of the tool, and the face also puts that tool's calls under the path fences, so it fits a tool whose argument names where it writes): the verdict then counts the tool and the notice is not said. The face is the remedy only where the hands are local: under a remote execution environment with hands no tool counts (a remote env's file tools write the sandbox, not the host-side memory root), and only the declaration silences the notice there.
339
+ - **The tool valve has one reading, `valveLeaves(spec, selector)` (behavior change, open to veto).** What `excludeAllTools: true` unmounts (the whole face) was known to the valve and, by a hand-written copy, to the full shell's reachability; the persistence verdict, the memory write channel and the two memory pairs' planning asked only the selector, so they answered for tools the valve then removed. All six now read the valve's one predicate (`src/agents/tool-filter.ts`, beside `compileToolSelector`). (1) **What changed:** with `excludeAllTools: true` — a run with no store mounted gets the notice (it had none, beside tools that were unmounted); **a run with a mounted writable store is no longer taught the write path, it gets the read-only notice** (it was taught to write through a `Write` the valve had unmounted), and the memory pair's retrieval paragraph is no longer taught; the memory pairs' not-mounted advisory now fires, its reason reading "the run's tool face (allowTools / excludeTools / excludeAllTools) does not admit …" (was "the run's tool selector (allowTools / excludeTools) …", for both pairs). (2) **Who is hurt:** an operator matching the advisory's old wording. (3) **Compensation:** none needed; the prompt now says what the run can do.
340
+ - **BREAKING (exports −1).** `NO_PERSISTENT_MEMORY_NOTICE` is removed from the package root (and from `src/prompts/default.ts`); the text is the catalog message `core/memory.no-persistent-store` and the engine composes it. No alias is kept (a host composing it beside the engine would say it twice). Who breaks: a host that imports the constant — `sema-server` composes it per request today; and, with no import involved, a deployment that mounts an MCP memory server (writer tools with no `pathTarget` face), which is newly told it cannot store (Behavior change (2), remedies in (3): `memoryPersistenceCapable: true`, or, with local hands, the writer's `toolFaces.<tool>.pathTarget`). Migrate: delete the import and the judgment that decides when to compose it; the engine says it in exactly those runs.
341
+ - **How to migrate.** *server*: drop the import of `NO_PERSISTENT_MEMORY_NOTICE` and the per-request four-condition judge; a deployment whose memory channel is a custom tool sets `memoryPersistenceCapable: true` on the task; a deployment that mounts an MCP memory server either sets the same flag or, with local hands, declares the writer tool's `toolFaces.<tool>.pathTarget` (`access: "create"`) on that server's entry (Behavior change (3)). *cli*, *client-core*: nothing.
342
+ - **Pins.** `test/backlog1920-memory-not-mounted-arm.test.ts`; `test/prompt-catalog-m0.test.ts` (catalog digest refrozen), `test/tool-name-literal-gate.test.ts` (the notice's "remember" moved from `src/prompts/default.ts` to `src/prompt-assembly/catalog/memory.ts`), `test/memory-consolidation.test.ts`, `test/prompt-lock-text-survey.test.ts` re-pinned to the catalog text; `test/backlog1797-memory-advisory-per-runner.test.ts`, `test/backlog560-memory-advisory-once.test.ts`, `test/prepare-project-context-transcript.test.ts` re-pinned to the advisory's new reason wording; `test/resume-at.test.ts` and `test/prompt-prefix-stability.test.ts` recalibrated against the notice in the prompt (the reserve that applies at a 3000-token window is 900, declared as such; no fixture declares a channel only to keep its numbers).
343
+ ### `Artifact(action:<word>)` is a rule — deny, ask and allow, stored, listed, offered on the card; a path / name / host ALLOW carrying `<` or `>` is refused (#740, #520 merged; behavior: an Artifact card now carries a rule offer, open to veto; behavior narrowing ×5, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@cli @server)
344
+
345
+ - **Before.** The persisted rule store, the allow-layer lists and the card's "don't ask again" had no form for a rule on one action of the `Artifact` tool: `Artifact(action:read)` was `unsupported.tool` in the store, `content_unsupported` in a list, and an Artifact approval card carried neither `ruleOffers` nor `ruleOffersAbsence`. The caller policy refused it as a primary field unless `primaryFieldGeneric: "allow-lexical"`, and then read it as a lexical glob. Path rules took `<` and `>` (`Read(//tmp/o>/dev/null/**)`) while the cd mint floor refused them.
346
+ - **Now.** `Artifact(action:<word>)` is the name grammar's KEYED spelling (the name grammar = a rule on the value of one parameter; `Skill(<name>)` is its bare spelling): `<word>` is one of the tool's declared actions (`ARTIFACT_ACTIONS`), canonical text as written, `match: "name"`, `command: <word>`, every behavior. One reader everywhere: the store's lane (deny refuses with `deniedBy: "persisted_rule"`, ask is `askClass: "ask_rule"`, allow clears an ordinary ask for that action and never a mandated one), the allow-layer lists, the caller policy (now WITHOUT the lexical opt-in; the tool's other arguments, `Artifact(url:…)`, keep the generic `param:pattern` lane), and the card. CC 2.1.296 reads the same spelling (the key and the value each trimmed, as here: `Artifact( action: publish )` is `Artifact(action:publish)`) and mints `Artifact(action:reply)` itself, but reads a keyed allow for `reply` only. Against it ours is stricter on two points (exact word from the declared set, no `*` glob) and wider on one (an allow may name any of the five declared actions, `publish` included, and the card offers each of them; bounded: an allow clears the ordinary question for that one action and never a mandated one). All three are registered divergences. `RuleGrammar`, `PersistedRuleMatch`, `RuleRejectCode` and `PermissionRuleIssue.code` are unchanged. A call of a keyed tool that still names no `action` after the tool's own reading (`prepareArguments`) — an approver's edit, the `updatedInput` of a hook or of a caller `ToolPolicy` verdict, a preparer that leaves it out, or the model's call to a caller tool that has no preparer (the first-party tool fills its default before any judge) — would run an action nobody judged, so every `Artifact(action:…)` deny or ask refuses it on every path (`deniedBy: "persisted_rule"` / `"policy"`, no question is asked) and no action allow grants it; a judge handed a request that carries no stamped reading (a policy that rebuilds the request, a `runToolGate` host that passes no `event.readArguments`) refuses the same way (#1974). One byte table (`<` `>`) is refused in every path, name and host ALLOW (`invalid.grammar`) and is the base of the cd mint floor's screen; a deny / ask may name the bytes and keeps its meaning.
347
+ - **Behavior (open to veto) — the Artifact card speaks.** (1) **What changed:** an Artifact ask on a leg with an armed rule lane carries `ruleOffers: [{ kind: "single", rule: "Artifact(action:<word>)", match: "name", command: "<word>" }]`, or a `ruleOffersAbsence` word under the shell card's doors (`mandated`, `ask_rule_matched`, …); before, both seats were absent. `prepareCardApproval({ toolName: "Artifact", command: <the action word> })` mints the record. (2) **Who is hurt:** a surface that treated an Artifact card as offer-less. (3) **Compensation:** none needed beyond rendering the offer the shell and Skill cards already render.
348
+ - **Behavior (open to veto) — the byte screen's polarity.** (1) **What changed:** against 7.52.0, which screened no rule's bytes (a path rule took `<` / `>` under every behavior; only the cd mint floor refused them), the one change is on ALLOW rows — narrowing (a) below; the screen does not touch a deny / ask row, as 7.52.0 touched none. (2) **Who is hurt:** nobody beyond narrowing (a). (3) **Compensation:** none beyond narrowing (a).
349
+ - **Behavior narrowing ×5 (open to veto).** (1) **What changed:** (a) a path, Skill or host ALLOW carrying `<` or `>` is refused (`invalid.grammar`) — a stored one is dropped by the next read that re-parses it (the sync path; a plain durable read serves stored rows as stored); deny and ask rows are not screened, so no standing tightening is lost; (b) a caller policy `Artifact(action:<glob>)` / `Artifact(action:<word outside the set>)` is refused (`invalid.command_rule`) where `primaryFieldGeneric: "allow-lexical"` used to compile it as a lexical glob; (c) a call that names no `action` as the tool reads it — a caller's tool mounted as `Artifact`, or answering to it, with no argument preparation of its own (the built-in tool's reading names `publish`) — is refused by every `Artifact(action:…)` deny or ask rule, no question asked (`deniedBy: "persisted_rule"` / `"policy"`), and granted by no action allow, where 7.52.0 had no action rule that reached it (the keyless-call sentence of **Now** above; #1974); (d) the persisted lane now speaks for `Artifact`: when the person's rule partition cannot be read, EVERY Artifact call — the read-only `list` included — is a real-approval question (`askClass: "rule_unreadable"`, no rule clears it), and a deployment that answers every ordinary ask `allow` has it refused, where the tool ran before (the law `Skill` and `WebFetch` already follow); (e) an org row the layer cannot read — a row of another grammar than the shell's (`Skill(x)`, `Artifact(action:publish)`, `Read(…)`), or a shell row the rule validator refuses (`Bash(ls > x)`) — in a resolution a store hands over without screening it (the store interface is public) is no longer skipped, which let the published deny never fire: it stands at its strictest reading over the calls it may reach (a deny row refuses them, an ask row asks a person; the refusal names the row by place and code), every readable row holds as published — one law for every store, the in-tree partition included (the next entry, #1986). (2) **Who is hurt:** a person with an allow on a directory whose name contains `<` / `>`; a deployment with a globbed action rule; a caller's tool mounted as or answering to `Artifact` with no argument preparation that sends no `action` (the built-in tool's reading names `publish` for a missing `action`, so an approver's edit cannot remove it); anyone whose rule store cannot be read — a person at a prompt meets a question even on a `list`, and a headless `onAsk: "allow"` deployment has every Artifact call refused until the store reads; a deployment whose own org store hands over a row of another grammar (the calls that row may reach are then refused, or asked for an ask row). (3) **Compensation:** (a)–(c) the refusal names the reason; write the action rules out one word each, or the bare `Artifact` entry for the whole tool; an edit that keeps `action` is judged as before; (d) mend the store, or declare an `onAsk` that answers the question — there is no other way past it; (e) publish org rows in the shell's grammar only.
350
+ - **How to migrate.** *cli*: render the Artifact card's offer like the Skill card's; redeem with `command` = the offer's `command`. *server*: accept `Artifact(action:<word>)` in settings deny / ask / allow lists (no closed-set change); publish org rules in the shell's grammar only (`Bash(…)` — any other row stands at its strictest reading over the calls it may reach, on every store; #1986).
351
+ - **Pins.** `test/backlog740-artifact-action-rule.test.ts`, `test/backlog1982-org-lane-reads-one-grammar.test.ts`.
352
+
353
+ ### One org snapshot, one answer, whichever store delivers it: a row the org layer cannot read stands at its strictest reading over the calls it may reach — a deny row refuses them, an ask row asks a person — and every readable row holds; the snapshot is installed and served on its envelope, degraded and named, never refused whole for a row (#1986, #1983; P2; security — a published deny became an approvable question; behavior change — a narrowing on the calls an unreadable row reaches and a loosening on the calls none reaches, open to veto; hard-breaking: none; closed sets: none; exports: none on the package root; members: `orgRuleVerdictFor`'s call widens additively (`tool` may be the call's list of rule words, `command` optional, `face?` the face of its path target); `OrgGateVerdict.verdict.unreadable` may ride a `deny` too (additive meaning: the row is not one the org layer reads); `unenforceableOrgRules` also reports a row with no text or with a behavior other than `deny` / `ask`) (@server @cli @client-core @test)
354
+
355
+ - **Before.** Two readings of one fact. The in-tree org partition refused a whole snapshot that carried one row it could not read: with a last-known-good inside the bound the older snapshot kept serving (the newer one's denies never applied), and with none the resolution was unavailable — every terminal allow became a real-approval question a person could APPROVE, so under `[Bash(touch:*) deny, Skill(x) deny]` `touch` ran after one approval. A store of the host's own handing over the same rows got the lane's reading (7.53.0 #740 (e), #1982): the readable deny refused `touch`, every other call was unavailable. The lane also parsed every org row twice per call (its verdict and a separate screen) and named one row where the partition named twenty.
356
+ - **Now.** One reader of org rows (`readOrgRules`, `src/core/permission-rule-org.ts`), each row read once per adjudication, for the partition, the lane, `orgRuleVerdictFor`, `unenforceableOrgRules` and the shadow view alike. A row is read when its behavior is `deny` / `ask` and its text is a shell command rule under it; any other row (another grammar's rule, a shell rule the validator refuses, no text, a field that cannot be read, a behavior other than `deny` / `ask`) stands at its strictest reading — `ask` only for a row that says `ask`, else a refusal (`deniedBy: "org"`) — over the calls of the tool it names (a `Read` / `Edit` row also over the calls on that tool's path faces; a row naming no tool the layer can place, every call). The refusal and the question name the row: `…, which could not be read against it (row 2 of the organization snapshot is not a rule this layer reads (unsupported.tool); republish it in the shell tool's command grammar)`. A snapshot installs and serves on its envelope (revision, observation time, a rules list) and the anti-rollback fence; the last-known-good is the last snapshot whose envelope was sound, never kept in force because a newer one carries an unreadable row. A snapshot with unreadable rows is served DEGRADED (`fresh` / `last-known-good`, every row in `rules`) with one line — count, the first twenty by place and code, `and N more` — on the resolution's `disclosures` and, from the lane, on the run's `permission.rule_store_unreadable` trace on every adjudication. An unreadable deny row shadows every personal rule of a tool it reaches. Whether `rules` is a list is judged in one place for the partition's envelope and for a resolution a store of the host's own hands over: an array is a list (`Array.isArray` — an Array subclass's instance and another realm's array included, #2011; P2), and rules that are not one (`{}`, a string, `null`, nothing, a Proxy whose traps would answer for the list's length) are unavailable governance at the org lane too — the non-dismissable real-approval question — never a policy of zero rows that let every call run unasked (#2004; P1); `orgRuleVerdictFor` / `unenforceableOrgRules` / `orgRuleShadows`, which have no unavailable word, throw a `TypeError` on them instead of reading zero rows, and a store's `revision` that cannot be read drops the ask's revision evidence, never the verdict. The list is received in ONE receipt of the engine's (each accessor run once per reading; an object two rows share — one row's `metadata` standing as another row — is read once, where reading it per row let its getter answer `rm` to one copy and `echo` to the other and no rm deny stood, #2012; P1), and a row is held and judged as its policy — its own `rule` and `behavior` that hold a JSON scalar, nothing else (`ORG_ROW_POLICY_MEMBERS`): a member that cannot be received, or that holds a structure, is one the row does not have — no `behavior` ⇒ the row's strictest reading over the calls of the tool its text names (`invalid.grammar: no behavior`), no `rule` ⇒ every call, never part of a row applied —, any other member (a `Date`, a bigint, a typed array, an entity object) is never judged, held, served or persisted, and a class instance, another realm's row or a list carrying the members as its own reads the same on every seat (#2015; P2). The receipt reads a row's own members once and never walks a structure a member holds (a getter inside it never runs, its size costs nothing — #2049; P2), so a row whose other members fan out through shared references costs nothing and no non-policy member is serialized (#2043; P1) — and a list-shaped row is the same row on the lane and in the partition (#2044; P2) — while a list element whose read throws, or a row that cannot be received at all (a Proxy, a function), is a row with nothing in it (no rule text — a refusal over every call, named by its place), the readable rows beside it keeping their effect, on the lane and on the partition alike — the partition no longer refuses such a snapshot whole as a source failure, which with no last-known-good made a matching published deny an approvable question (#2005; P1). The partition holds a snapshot it fetched and one it reads back from its persistence in that one form — its envelope's `revision` / `fetchedAtMs` / `signature` holding a JSON scalar and each row its policy; nothing else the source or the persistence carries is read or persisted (#2049), and a persisted state whose snapshot is missing keeps its high-water mark and serves nothing — and "the same revision, the same content" is one test (`sameOrgPolicyContent`: the policy members, row for row) for its guard and for the file backend's install, so a source re-serving an installed revision with class-instance or `Date`-carrying rows renews instead of aging into the approvable unavailable question (#2014; P2) — except on the upgrade path: a row 7.52.0 persisted through a JSON backend from an array-shaped row (`rule` / `behavior` on a list, which only an in-process source hands over and JSON spells `[]`) holds no policy members, so the same revision re-fetched with the row carrying them reads as DIFFERENT policy content, the swap is refused, and the kept snapshot's empty row refuses every call while it stays inside the staleness bound (7.52.0: unavailable, an approvable question) — until the org revision moves (KL-1986-c, #2064).
357
+ - **Behavior (open to veto).** (1) **What changed:** a call an unreadable deny row reaches is refused (was: an approvable real-approval question on the in-tree seat; unavailable on the lane); a call an unreadable ask row reaches is a real-approval question (as before on both seats, now with the row named); a call no unreadable row reaches is judged by the readable rows alone (was: a real-approval question for every call on both seats — e.g. every `Read` under a `Skill(x)` row); a newer snapshot carrying an unreadable row replaces the last-known-good; an allow row in an org snapshot refuses the calls of its tool (was: the whole snapshot refused); rules handed over as an Array subclass's instance or another realm's array are read as a list (was: unavailable — an approvable question); a row whose `behavior` cannot be read refuses the calls of the tool its text names only, and a row carrying a member no judgment reads, or built as a class instance or in another realm, is read as published (was: every call refused), a list-shaped row carrying `rule` / `behavior` as its own members too in the partition (was: held as `[]`, every call refused there); the file backend installs a fresher observation of an installed revision whose rows differ only outside their policy members (was: refused as different content). (2) **Who is hurt:** a deployment whose org snapshot carries a row naming no tool the layer can place (`(((`, a misspelled tool) — every governed call is refused until it republishes (KL-1986-a); a consumer fixture that pins an org `deny` verdict without `unreadable`, or the org refusal sentence without its clause; a store of the host's own handing over its rules as a Proxy over a list carrying a deny — 7.52.0 refused the matching call by chance (the traps answered as a list), now the rules are unavailable governance, a real-approval question a person may approve (#2004, #2011); @server: org rows served by the in-tree partition (`effective().org.rules`, and the persisted state) carry only `rule` / `behavior` from 7.53.0, and a persisted snapshot only `revision` / `fetchedAtMs` / `signature` beside them — a consumer that read `id`, `note` or any other decoration off them takes it from its own store (the 7.52.0 `OrgPermissionRule` / `OrgRuleSnapshot` types already typed only these members; #2043, #2044, #2049). (3) **Compensation:** republish the named rows in the shell's grammar; the degraded line and every refusal name each row by place and code.
358
+ - **Behavior narrowing (open to veto) — the org rules list is bounded by its length (#2062).** (1) **What changed:** an org rules list longer than the rule compiler's own bound (`maxRules`, 256) is refused whole — by its `length`, before any place of it is read or any row built: a fetched snapshot is not installed (the previous one keeps serving; with none the resolution is unavailable), a persisted snapshot read back is unavailable, the org lane answers unavailable, and `unenforceableOrgRules`, `orgRuleVerdictFor`, `orgRuleShadows` and `compileOrgShadowPredicate` throw a `TypeError` in the same sentence (`rules list has N entries; the cap is 256 — refused whole (not truncated)`); nothing is truncated (was: any length was received and one policy object was built per place, a hole included, so `rules: new Array(1e8)` exhausted the process before any check — 1e6 places cost about 61 MiB; 7.52.0 refused at the first hole). (2) **Who is hurt:** a deployment whose org source serves more than 256 rows — the whole snapshot is refused: with a last-known-good the previous snapshot keeps serving (the newer denies never apply; the refusal line names the count), with none the lane reads unavailable (every terminal allow becomes a real-approval question; never zero rows); a caller of the four helpers above with more than 256 rows, which were read before. (3) **Compensation:** split or trim the org policy at its source to at most 256 rows; nothing in the engine truncates.
359
+ - **How to migrate.** *server*: an org snapshot with a row of another grammar now serves (degraded) instead of being refused — render `effective().org.disclosures` on an available resolution too (the degraded line rides there); a verdict reader treats `verdict.unreadable` as optional on either behavior. *cli* / *client-core*: an org refusal may carry `, which could not be read against it (…)` — render the sentence as given. *test*: black-box — one snapshot through the in-tree partition and through a host store gives one answer per call. an org source serving more than 256 rows is refused whole — trim at the source (the refusal rides the same `disclosures`)
360
+ - **Known limits.** KL-1986-a (a row naming no tool the layer can place reaches every call), KL-1986-b (the degraded state has no engine notice; a call no unreadable row reaches carries no signal of it), KL-1986-c (a 7.52.0 JSON-persisted array-shaped row blocks a same-revision renewal on the upgrade; #2064).
361
+ - **Pins.** `test/backlog1986-org-one-reading.test.ts`, `test/backlog1986-org-rows-parsed-once.test.ts`, `test/backlog2004-org-lane-rules-envelope.test.ts`, `test/backlog2005-org-row-read-guard.test.ts`, `test/backlog2011-org-rules-list-judgment.test.ts`, `test/backlog2012-org-one-receipt.test.ts`; re-pinned: `test/backlog1982-org-lane-reads-one-grammar.test.ts`, `test/permission-rule-org.test.ts` (six cells), `test/backlog1318-skill-rule-cc-form.test.ts` H2 (wording), `test/backlog2062-org-rules-list-bound.test.ts`.
362
+
363
+ ### What a judge reads is what runs: every rewrite of a call — an approver's edit, a hook's or a policy's `updatedInput`, a resumed decision's edit — is read the tool's way (its argument preparation, then the schema's coercions) before it is judged, and the loop runs exactly the gate's output (#1974; P1; security — a stored deny was bypassable; behavior narrowing, open to veto; hard-breaking: none for a Runner deployment — an embedder that drives the loop with its own `beforeToolCall` and returns an `updatedInput` the tool's reading would still move is now refused, open to veto; closed sets: none; exports: none; members: on the package root, `runToolGate`'s `ToolGateInput.event` +1 optional `readArguments` (the loop's reading of the call; a host driving the gate without it has rewrites judged as written, and keyed rules refuse one that drops the key); off the root, the engine's `BeforeToolCallContext` and the harness `ToolCallEvent` gain optional `readArguments`) (@server @cli)
364
+
365
+ - **Before.** The loop read the model's call through the tool's `prepareArguments` before the gate, but a rewrite was judged as written and prepared only at execution (`defineTool`'s `execute`, the tool body's own default). A tool whose preparation fills a default or folds an alias could be steered past a stored deny by dropping the key: from an `Artifact` `action: "list"` call, an approved edit to `{ file_path }` and an approved re-ask ran a publish under an `Artifact(action:publish)` deny (a persisted row or a caller policy alike); a hook rewrite did the same with no question at all; an edit to `subagent_type: "general-purpose"` was judged as a typed delegation and ran as the plain one. The previous entry's fail-closed reading of a keyless call only turned the deny into an approvable ask.
366
+ - **Now.** One reading of a call's arguments, owned by the loop: its preparation then the schema's coercions (`readToolCallArguments`). The loop hands it to the gate (`readArguments`), the gate stamps it on the call's face, and ONE reader (`argsAsTheToolReads`) takes every rewrite through it where it is adopted — the PreToolUse rewrite, the policy rewrite, each round of an approved edit (and the hook / re-check rewrites inside it), the gate's exit, the composition's rewrite cascade, the chain's final-form re-check, an inherited approver's edit — and `withArgsReading`, the one throat for "this request with these arguments", reads through it too. The resumed call's decided args are read through the live tool before its belts and its execution, and every face a belt request carries holds the same reading (a belt's composed policy reads each rewrite it cascades; a belt rewrite is compared as read). The loop takes `updatedInput` once (one snapshot of the member and its values), reads it as it reads the model's call (a throwing preparation refuses it, as it refuses a model call), and refuses one its reading would move (it was not judged in the form that runs); `defineTool`'s execute-time preparation is then identity on every engine path. A person's yes to an edit covers the edit as the tool reads it (the record keeps its settlement). The keyless-call arm of the previous entry stays, as a REFUSAL: a call that still names no `action` after the tool's reading — a caller's tool mounted as `Artifact`, or answering to it, with no argument preparation of its own, its default filled in inside `execute` — is refused by every `Artifact(action:…)` deny or ask rule (an ask nobody can read is not put to a person, and an approvable ask let a deny be approved past) and granted by no allow: one predicate (`keyedValueAbsentReason`) in the persisted walk, the caller policy's deny tier and the allow layer's lists. The predicate is on the value the judge holds, so it also covers every judge no reading reaches: a deployment policy that rebuilds or copies the request before its own composition (the stamped reading is lost, a layer's rewrite cascades as written), and a host driving `runToolGate` without `event.readArguments` (rewrites are judged as written) — a keyless value there is refused, never passed. A host driving `runToolGate` must pass `event.readArguments` (the loop's reading of the call) for rewrites to be judged in the form that runs; without it, keyed rules refuse a rewrite that drops the key. The built-in `Artifact` never meets the refusal through a Runner — its reading names `publish` — and the `Artifact` default lives in one function both seats read. A generic `param:value` rule of the caller policy meets a call through the same reading: a call (as read) without the parameter is reached by a rule whose exact value the tool's reading folds away — `Agent(subagent_type:general-purpose)` reaches the delegation tool's `general-purpose` call (read as the plain delegation), an approver's edit to it, and the plain delegation itself (the same call as read); a `*` pattern names no one value to read and matches spelled values only. On a resumed call a deny reads nothing; an approved call whose arguments the tool cannot read (the decision's edit, or the resumed PreToolUse re-screen's rewrite, makes its preparation throw) is not run and is SETTLED as the loop settles such a call — an error result naming the failure with the thrown text and marks, `notExecuted` on the frame and the row, the row resolved — so the model re-issues it and a fresh decision runs; a belt policy (the deployment's re-check, the narrowing rules) that fails while judging — a throw, or a rewrite its composition cascades that the tool cannot read — refuses in the policy-throw words the live gate uses (`failedJudgmentDeny`), and a belt rewrite the tool cannot read is not the approved value. Affected tools: `Artifact` (absent `action` ⇒ `publish`), the delegation tool (`general-purpose` ⇒ the plain delegation), `Write` (`path` / `file_text` / `file_content` ⇒ `file_path` / `content`), and any caller `ToolSpec.prepareArguments`.
367
+ - **Behavior narrowing (open to veto).** (1) **What changed:** a stored deny refuses a rewrite whose reading it reaches (where it was an approvable ask, or ran unasked from a hook); the re-ask card shows the read arguments (`action: "publish"` visible on an edit that dropped it); a Write edit spelled with an alias is repaired and judged, where it used to be refused by the schema after the approval; a hand-rolled `beforeToolCall` returning arguments in a form the tool would re-read is refused with "the arguments cleared for … are not the ones it would run"; a call naming no `action` of a caller's tool named or aliased `Artifact` is refused by an action deny or ask rule (it ran its default unasked), as is a bare request naming none judged outside a Runner (it was reached by no rule), a rewrite dropping `action` judged by a policy that rebuilt its request (it published unasked), and an edit dropping `action` through a `runToolGate` host passing no `readArguments` (it was cleared); `Agent(subagent_type:general-purpose)` refuses the `general-purpose` call in every form and the plain delegation (it reached none: the reading folds the value away); a resumed approval whose edit the tool cannot read settles as a not-executed result where it reopened the checkpoint as `env_failed` and refused every corrected retry (`checkpoint.reopen_revote`); a resumed call's belt policy that throws is that belt's refusal (`deniedBy: "policy"`, row resolved) where the throw escaped the leg into the same `env_failed` / `reopen_revote` trap (a defect the previous release shared, fixed here); a resumed edit that drops `action` is refused before the claim (`resume.constraint_rejected`, the row still decidable) where it was refused after it. (2) **Who is hurt:** an approver who relied on dropping a key to get past a deny (the point); an embedder of the bare loop whose gate rewrites without reading; a deployment that mounts its own `Artifact` without argument preparation and leans on its execute-time default under an action rule; a `runToolGate` host that does not pass `readArguments`; a deployment that denies the spelled `general-purpose` but wants plain delegations; an approver who, under a keyed deny or ask for ANOTHER action (`Artifact(action:read)`), edits a parked call's arguments without the key — refused before the claim, the row still decidable (KL-1974-a; #1988 has the pre-claim check read through the live tool). (3) **Compensation:** none for the first; the second reads its rewrite through `readArguments` (on the context it already receives); the third gives its tool a `prepareArguments` that fills the default (its rules then read the action it runs), or has the call name its action; the fourth passes `event.readArguments`; the fifth has nothing to tell apart — the two spellings run the same delegation; the sixth re-decides with the key spelled out (`action: "publish"`).
368
+ - **Known limit.** The pre-claim judgment of a resumed decision's edit has no tool objects yet and judges the edit as written; the post-claim belt judges, and execution runs, the read form — the belt is the authority (KL-1974-a). With no reading there, an edit naming no `action` is refused before the claim by any `Artifact(action:…)` deny or ask — also one for an action the tool's reading would not run (an over-refusal: the row stays pending, and re-deciding with the action spelled goes through; #1988).
369
+ - **How to migrate.** *server*: nothing to change; an approval edit that crosses a stored deny now ends as that deny's refusal (`deniedBy: "persisted_rule"` / `"policy"`), not a second card; a resumed approval the tool cannot read now ends as a resolved row with a `tool_end` that is `isError` + `notExecuted: true` under the decided `allowed` record (no reopen, no `resume.reopened_unstarted` terminal) — the run continues. *cli*: the post-edit refusal is the deny's own sentence; an edit's re-ask card may carry a key the person did not type (the tool's default) — render it as given; render the settled unreadable approval as not executed (its sentence says why).
370
+ - **Pins.** `test/backlog1974-judge-what-runs.test.ts`; `test/backlog1974b-keyless-and-unreadable-resume.test.ts` (the caller tool and its alias under stored / caller deny and ask, the built-in tool still asked, the three readers' one predicate, the resumed edit / deny / re-screen rewrite / belt cascade, a policy that rebuilds its request, a `runToolGate` host without `readArguments`, `Agent(subagent_type:general-purpose)` in model, edit and plain form); `test/backlog740-artifact-action-rule.test.ts` ⑧ (the bare keyless request: refused by the deny / ask rows, granted by no allow), `test/artifact-surface.test.ts` (the default-action cell: the bare keyless request is refused), `test/backlog1328-parked-skill-rescreen-swap.test.ts` ⑨ / ⑨b (a throwing belt policy: the belt's named refusal, no longer an interrupted run) and `test/backlog1059-unknown-tool-sentences.test.ts` follow — the #1059 cell "an approver's edit carrying a Write alias is refused" now reads: repaired before it is judged, re-put canonically, run as that.
371
+
372
+ ### A person's halt ends a running foreground shell command, armed for auto-backgrounding or not — documented against CC 2.1.296's four background causes (#745, C-R514; no behavior change; hard-breaking: none; closed sets: none; exports: none; members: none) (@cli)
373
+
374
+ - **Before.** The ticket read CC's `backgroundedByTurnAbort` as the person's halt and planned "halt + auto-background-armed ⇒ moved to the background".
375
+ - **Now.** CC 2.1.296 ends a running foreground command on the person's stop (Esc = `user-cancel`, the SDK's plain interrupt: "running tools are ended") and moves it to the background only for a person's detach, its own timeout, a queued message, or a plugin's turn abort / a permission stop that ends the turn. `halt()` is the person's stop: the command is ended, armed or not, the turn reads cut (`task.turn_interrupted{cause:"user_halt"}`), nothing is registered in the background. The map is in `docs/INTEGRATION-CORE.md` §4.6y; no seat exists here for the plugin turn-abort cause.
376
+ - **How to migrate.** *cli* (L-262): render a halted turn's shell command as interrupted, never as "moved to the background".
377
+ - **Pins.** `test/backlog745-halt-foreground-shell.test.ts`.
378
+ ### The binding is a prompt selector: an overlay of any class (center, deploy, task) applies to a scenario by `when.binding`, and `prompt.compose` carries the binding; the center-only `scope.binding` retires with center artifact `schemaVersion` 3 (#1919; minor; **hard-breaking, open to veto**: a v2 center artifact is refused, an overlay carrying `scope` is refused, an artifact pairing an unconditioned `replace` with a binding-conditioned `replace` of one target no longer verifies; behavior change, open to veto: `prompt.compose` pins `catalog` and `selectors` beside `model`, and every pinned key of every mods station is compared by value (a copy of the engine's fact is taken, and the handler below reads the engine's own member — for anything inside it too; a copy nested past 1 000 levels is refused as too deep to compare; 7.52.0 compared by reference) with `KERNEL_CONTRACT_VERSION` 1 → 2 (no host reads it today); behavior narrowing, open to veto: every value a handler gives the bus — a member it hands on, its answer, a `$` call's arguments — nested past those same 1 000 levels is refused by its receipt (7.52.0 took it up to the platform's stack); behavior loosening, open to veto: the engine's own event is received at any depth, so a model's tool input too deep for the platform's stack no longer denies the call at a PreToolUse screening, and an observe-only handler receives the same event whole (#2013, #2065); an artifact digest is over each overlay's `when` in normal form; every character bound of the artifact verifier counts Unicode code points (`PROMPT_BINDING_MAX_CHARS`, `sourceRevision`, `evidence.intent`, a reference; looser); a trait-conditioned deploy `drop` of an entry absent this run is one `superseded` record and one `onError` line `(absent this run)` on every such run (7.52.0 silent); a stored copy the file store cannot read is told apart from a missing one; closed sets: the `when` member set `traits, host, facts` +1 `binding`; exports: +2 constants `PROMPT_BINDING_MAX_CHARS` (128) and `PROMPT_BINDING_LIST_MAX` (64), +2 types `StoredArtifactRefusal` and `OnArtifactRefused`, −0; `PromptVariantWhen` gains `binding?`, `PromptOverlay` loses `scope`, `PromptArtifactPayload.schemaVersion` is `3`; members: `prompt.compose` `e.selectors.binding?`; `CenterPromptSource` option `onError?` (the `RunnerDeps.onError` shape); `PromptArtifactStore.get` / `PromptArtifactSource.get` take an optional second argument `onRefused` (`StoredArtifactRefusal.code`: a verifier code or `unreadable`); `KERNEL_CONTRACT_VERSION` 1 → 2) (@server @cli @client-core @web-admin)
379
+
380
+ - **Before.** One fact — which scenario this run is (`TaskSpec.promptBinding`) — had two readings. A center artifact's overlays were filtered by `scope.binding` exact equality before the composition; a deploy or task overlay could name no binding at all (`scope` outside the center was refused), and `prompt.compose`'s `e.selectors` was a second, hand-built view of the selector values without the binding. So a deployment's brand line that belongs to two scenarios (`default`, `code`) and not to a third with the same host role (`code-review`) had no way to say so on a Runner shared across scenarios.
381
+ - **Now.** The binding is the selector's fourth dimension: minted once with the others (`selectorsOf`), projected once to the plain record (`selectorRecordOf`) that the manifest's `selectors` shows, the identity's `binding` element digests, and `prompt.compose`'s `e.selectors` carries. `when.binding` is `{ anyOf: [...] }` or `{ noneOf: [...] }` — exactly one list, naming at least one and at most `PROMPT_BINDING_LIST_MAX` (64) bindings, none twice, each non-blank and at most `PROMPT_BINDING_MAX_CHARS` (128) characters — **Unicode code points** (`[...s].length`), not UTF-16 code units: the one unit of every character bound of the artifact verifier (a binding, an overlay id, `sourceRevision`, `evidence.intent`, a reference — the last three counted UTF-16 units in 7.52.0), so a character outside the BMP (an emoji, a rare ideograph) counts one, not two; this loosens each of those counts and nothing else (a name of 65–128 astral characters, refused before, is taken; so is a `sourceRevision` or a reference of 251–500 astral characters and an `evidence.intent` of 1 001–2 000; nothing taken before is refused; the overlay text budgets stay UTF-8 bytes) — the bound `TaskSpec.promptBinding` is held to; both are exported from the package root, the one binding the engine reads too, so a host screens with the engine's numbers and unit. Any other value refuses the overlay by name, the sentence naming the bound (`"when.binding.noneOf" names 65 bindings (at most 64)`). `anyOf` holds when the run's binding is listed; `noneOf` holds when the run has no binding or it is not listed — a run with no binding is outside every `anyOf` (fail-closed) and inside every `noneOf`. The composer's one `whenHolds` decides for every class; `centerOverlaysOf` returns every overlay of the adopted artifact (its filter is gone). `requires.bindings` is every binding a `when.binding` names, sorted.
382
+ - **`prompt.compose` pins every fact it carries (behavior change, open to veto).** (1) **What changed:** `model`, `catalog` and `selectors` are all pinned on the event (the rule `prompt.section` already follows for its three facts); a handler that passes `next` an event whose `catalog` or `selectors` differs in value from the one it received is skipped and named on `onError` (`next() changed the pinned key "selectors"`) — before, only `model` was pinned. **The pin compares by value, for every pinned key of every station** (`prompt.section`'s `mountedTools`, `classic.PreToolUse`'s and `tool.check`'s `input`, `tool.check`'s `ceiling`, `plugin.register`'s `uses`, …): a copy of the engine's fact — `{ ...e }`, `structuredClone(e)`, a JSON round trip, a rebuilt list — has changed nothing and is taken (7.52.0 compared by reference, so a handler that passed on a copy of `mountedTools` or `input` was skipped and named "changed the pinned key" though it had changed no value; a loosening, open to veto); a record's member order and an `undefined` member are no difference, an array's order is, an array is its elements and every other own member it carries (a method hung on a copied array — an own `includes` that answers no — is a difference), only populated members are walked (a sparse array of length 2^32 − 1 costs its members), and a function or an object that is not plain data is itself only. **The comparison decides only whether to refuse; what the handler below reads for a pinned key is the engine's own member** — the same object, whatever copy was passed — so the forms the comparison does not count (an `undefined` member, a member order, a −0) reach no handler below: under a forwarder that hands on `selectors: { ...e.selectors, binding: undefined }` on a run with no binding, the handler below still sees no `binding` member (`binding` is present exactly when the run has `TaskSpec.promptBinding`). The handed-on payload keeps the shape it was received in: a reference inside it to the payload itself is a reference to what is handed on (a cycle stays a cycle), and **one to any object inside a copy handed on under a pinned key — the copy itself, a member, a list element, at any depth — is the engine's object that copy was proven the same as**: the comparison records each container's pairing and the rebuild moves every alias by it, once each (a forwarder that hands on `input: structuredClone(e.input)` with `chosen: input.items[0]` gives the handler below `chosen === e.input.items[0]`, so `indexOf` reads 0, not −1; a shared reference stays shared; the engine's own objects are never moved — an object of the engine's event a forwarder points to, at any depth, is that object below, where 7.52.0 handed down a copy of it). **One depth for the bus, 1 000 levels**: what a handler hands on, its answer and a `$` call's arguments are received held to it (a shared subtree measured again wherever it sits), so a value nested deeper is refused by the first walk that meets it — under a pinned key in the comparison's words (`next() handed on a copy of the pinned key "input" that is too deep to compare (nested past 1000 levels); a copy is taken only when proven the same`), never "changed"; elsewhere naming the bound and the member (`next() takes the event object: the payload holds a value nested past 1000 levels under "extra"`, `answered a value nested past 1000 levels under "value"`) — the same sentence and the same handler at 1 001 levels and at 3 000, on every platform and however deep the caller already stands: the receipt, the comparison and the declaration vocabulary judge each keep their own stack, one frame per container (#2013; before, past a depth the platform's stack set — about 3 000 levels inside a handler on one platform, more on another, fewer under a caller already deep in its own — the receipt said "could not be copied whole" instead); the comparison is held to the same depth. "Could not be copied whole" (`uncopyable`) is left for two causes, neither of them depth: reading the value threw where none of its getters runs (a Proxy's trap, on a receipt given no Proxy probe — what it threw is never read, so a thrown value whose members throw, or one shaped like a refusal, is that member's fault and never escapes the receipt or forges another kind), or the value holds more objects than one receipt can index. **The engine's own event is received at any depth**: a model's tool input 8 000 levels deep reaches the PreToolUse screening whole, a screening that passes it on allows the call, and an observe-only handler receives the same event whole — its detached copy is made by the same receipt, so an audit mod sees every call that runs (#2065; the observer's copy was a structured clone with a recursive fallback, which ran out of stack near 2 500 levels and skipped the observer with one report line) (a loosening, open to veto — 7.52.0 failed that dispatch as "could not be copied whole" and the lane denied the call wherever a judging mod was mounted; a screening that answers a copy of such an input is refused naming the bound). **Its objects handed back are kept by reference and never walked, wherever they come back** — through `next`, in an answer, in a `$` call's arguments (a method's `enter` shaping of them included) (#2025; an answer echoing `e.input` carries the engine's own object, where 7.52.0 carried a copy of it) — and a container of the engine's is one level where it sits, whatever it holds. Those objects — the containers the bus received, never an object handed as it is (a class instance stays its caller's, and nothing it holds is taken for the engine's, even after its prototype moves) — are found once per dispatch, the first time a handler gives the bus a value of its own, and every receipt of the dispatch consults that one set (#2016: a large unchanged member forwarded through many handlers is not walked again at each). A leaf that is not a record or an array (a `Date`, a function) is no level. **A container is one level wherever it is met** — copied, met again as a shared reference, still being walked (a cycle), or the engine's own — in the receipt and in the comparison alike (#2024), so the verdict does not depend on member order for a value without a cycle (an engine container inside a shared copy container reaching the 1 001st level is refused in both orders), and a cycle closed at the 1 001st level is refused where it sits — under a pinned key as too deep to compare. Through a cycle the depth is still measured on the walk's way, so two member orders of one cyclic value can be judged apart (#2007; the integration guide's member-order sentences carry the same "for a value without a cycle", #2026). **`KERNEL_CONTRACT_VERSION` moves 1 → 2**: its law is "the chain semantics, the registration form, the event closed set (names and `side`) or the failure columns (failure, abort, pinned keys)", and how a pinned key is judged is the failure column's reading, so a kernel copy that judged by reference must not sit beside this one under the same number. **No host reads the number today**: server, cli and client-core load no kernel copy, and `@sema-agent/mods-kernel` is a private workspace package, not on the registry; the move records the change for a future copy (open to veto). (2) **Who is hurt:** a mod that changed the value of the selector record, the catalog view, or any other pinned fact for the handlers below it; nobody is known to. A mod that forwards a copy is not hurt — it was, by reference, before. Without the pin, a task-seat handler could forward a forged `binding` and a deployment handler below it branching on `e.selectors.binding` composed another scenario's text, with no line anywhere (base and this tree before the pin, a real Runner: `PRIVATE-TEXT.` composed under `promptBinding: "public"`). (3) **Compensation:** none needed — pass `next(e)` (or a spread or a copy of it); an overlay meant for some scenarios carries `when.binding`.
383
+ - **An observer's copy is the one receipt's (#2065).** An `observe: true` handler (and its `.catch`) is handed a detached copy made by the kernel's receipt, with its own stack and no depth bound: every record and ordinary array is copied unfrozen at any depth, cycles and shared references keep their shape; every other object the copy holds — a tool result's `Map`, `Date` or typed array in `classic.PostToolUse`'s `output.details` — is detached by a structured clone of that object alone (one clone per object, so a shared one stays shared), and one the clone refuses (a function, a handle, a class instance holding one) is handed as it is. The structured clone of the whole event with a recursive fallback is retired, and so is the unused `detachedArgsOf` (not exported from the package). Before, an observer on an event nested past about 2 500 levels (macOS; more elsewhere) was skipped with `an observe handler failed (Maximum call stack size exceeded)` while the event's dispatch went on. Pins: `test/backlog2013-iterative-receipt.test.ts` F (a PreToolUse event 8 000 levels deep — the observer runs on a complete detached copy, no report line, the screen reads the engine's event; 20 000 levels with a `.catch`; a PostToolUse `details` Map / Date / typed array edited by an observer and its `.catch` reaches neither the judging handler after them nor the original result).
384
+ - **`e.selectors` wire order.** `e.selectors.traits` is now sorted (`["fable_5_mitigations", "lean_prompt"]` for a fable-family model on the lean profile; it was insertion order, `["lean_prompt", "fable_5_mitigations"]`). Same members; a reader that compared the array by position sees the new order.
385
+ - **Unchanged bytes.** The manifest's `selectors` and the identity's `binding` element are byte-identical for the same inputs (base vs this tree, the same run: `sha256:c6e53aed…` with `promptBinding: "code"`, `sha256:c921d75c…` without), so no session records a `selection_change` for this release. One exception, by construction: a deploy or task overlay whose `when` was written with unsorted lists, a repeat or an empty conjunct is hashed in its normal form, so its layer's digest differs from 7.52.0's and a session records one `code_change` at its first run (the prompt text is the same).
386
+ - **Two replaces of one target.** At publish, two `replace`s of one target conflict (`overlay`) when the rest of their `when` is equal and their binding conditions can hold in one run: both absent, one absent, two `anyOf`s sharing a binding, an `anyOf` naming a binding the `noneOf` does not, any two `noneOf`s (a run with no binding satisfies both). The conditions are read in their **normal form** (`canonicalWhen`, the overlay door's output for every class): lists sorted and without repeats, a conjunct that asks nothing (`host: {}`, `traits: {}`, `facts: {}`, an empty list) left out, a condition left with none no condition — so an empty conjunct, a reordered `allOf` or a repeat does not make two conditions that hold in the same runs look different, and the verdict does not depend on which of the two overlays comes first. The artifact digest is over that form too (one meaning, one digest); `buildPromptArtifact` hashes it, and a payload a publisher hashed over another spelling is refused `digest`. v2 compared `scope.binding` for equality, so an unscoped `replace` beside a scoped one of the same target verified, and under that scenario the later one in artifact order won (named superseded at run time, never refused at publish). At run time the fold is unchanged and never silent: when two `replace`s of one target both apply (across classes — `task < center < deploy` — or within one), the later wins and the earlier is recorded in `superseded`, marked in the section's `chain` and named on `onError`. **A scenario override is written with exclusive conditions:** the general `replace` carries `when: { binding: { noneOf: ["x"] } }`, the scenario's `when: { binding: { anyOf: ["x"] } }`.
387
+ - **A deploy `drop` of a lower entry that is not there this run.** Before, a center `add` scoped to another binding was filtered out before the deploy answers were judged, so a deploy `drop` naming it made that whole deploy answer unacceptable under every other binding (skipped and named — the answer's other overlays lost with it). Now every center overlay is a lower entry by declaration (as a trait-conditioned one already was), the answer is taken, and the drop — whose target its `when` left out of this run — changes nothing and is recorded `superseded` `(absent this run)` and named on `onError`, like a `replace` of a message the selector left out (base vs this tree, a deploy answer `[drop acme/code, add DEPLOY-MARK]` under `chat`: the mark was missing; now it composes, and one line names the drop). **The trait-conditioned form is the same law** (it was silent in 7.52.0, and is now one record and one line on every run the condition leaves the entry out): a deploy `drop` of a center `add` carrying `when: { traits: { allOf: ["fable_5_mitigations"] } }`, on a run without that trait, changes nothing, lands on `superseded` as `(absent this run)` and is named on `onError` (base vs this tree: no record and no line before; one record and one line per run now; on a run the trait holds for, the drop removes the entry as before). The line is per run, not per process, on purpose: one rule — every overlay that changed nothing this run is named, in the same words — rather than a second, process-wide memory for this one case; the manifest row is per run in any event. A drop meant for some runs only carries the entry's own `when`: it is skipped where the entry is not, and says nothing there (pinned). The `(absent this run)` line's words name the causes alike — the selector chose no variant of the target (or of the message it takes its text from), or the lower entry it drops was never added (its own condition left it out, or the layer that added it is not in force) — where it used to say only that the selector left the target out.
388
+ - **BREAKING (hard; open to veto).** (1) **Who breaks:** a center publisher (web-admin) and the server's center lane holding v2 artifacts; any producer of an overlay carrying `scope`; an artifact pairing an unconditioned `replace` with a `scope`d one of the same target. (2) **How it breaks:** the engine reads `schemaVersion` 3 only — a v2 body is refused `schema` (`unsupported schemaVersion 2 (this engine reads schemaVersion 3 only; republish under schemaVersion 3 — an overlay's "scope": { "binding": b } becomes "when": { "binding": { "anyOf": [b] } })`): `applySnapshot` answers it, a store read misses (and tells an `onRefused` caller why), `restore()` boots `unavailable` and says so on the source's `onError` (`center prompt artifact sha256:… is refused by this engine (schema: … republish under schemaVersion 3 …) — the source boots unavailable…`; a copy the file store cannot read — a directory in its place, a file the process may not open — reads `is unreadable (the store could not read its file: EISDIR)` and reaches `onRefused` as `{ code: "unreadable" }`; no copy at all reads `has no stored copy`), and a session whose record pins a v2 digest fails `prompt.snapshot_unavailable` on its next run, the failure's sentence and one operator line saying the copy is refused and how to republish it (no converter, no dual read). An overlay carrying `scope` — any class — is refused (`an overlay carries "scope" (id, op, target, text, message, when only); an overlay names its binding as "when": { "binding": { "anyOf": [<binding>] } }`); on `prompt.compose` that handler's answer is skipped and named. (3) **How to fix:** republish every artifact as v3 with `scope: { binding: b }` rewritten to `when: { binding: { anyOf: [b] } }`; put a `noneOf` on the general `replace` beside a scenario one. Publish v3 only and never put a v2 artifact into production — a session pinned to a v2 digest has no way back. Pass the Runner's `onError` to `CenterPromptSource` so a boot restore can say what it found — the option takes the Runner's sink in its own shape (`RunnerDeps.onError`, `(error, context)`), the context `{ phase: "prompt-constitution", sessionId: "", classification: "center-source-restore" }` (a boot belongs to no session) — a sink that throws, or returns a rejecting promise, is contained by the engine's one host-callback guard and never fails a boot or a read.
389
+ - **Against Claude Code 2.1.296.** Not a Claude Code behaviour: the overlay / center layer and its selector are this engine's own.
390
+ - **Who breaks / how to migrate.** *server* (② the provider car): the brand line is one deploy `prompt.compose` overlay — `{ op: "add", target: "role", text, when: { binding: { anyOf: ["default", "code"] } } }` — with no per-request branch; a handler may read `e.selectors.binding`. *server* (③ the center artifact car): build and serve v3 only (`buildPromptArtifact` mints it); if your settings-schema `EffectivePromptsWire` major types the inner artifact payload (its `schemaVersion` literal or the overlay shape), it moves to `3` / `when.binding` with this release. *web-admin* (the publisher): publish v3; `scope.binding` → `when.binding.anyOf`; at most 64 bindings per list. *any mod author*: a `prompt.compose` handler that changes the value of `e.selectors` or `e.catalog` before `next` is now skipped — pass `next(e)` (or a copy of it: a copy that equals it is taken, for every pinned key of every station; the handlers below read the engine's own members); a mod that hands on, answers or passes to a `$` method a value nested past 1 000 levels is now refused there, by name. *server, cli, client-core*: nothing for `KERNEL_CONTRACT_VERSION` — none of them loads a kernel copy or reads the number; *a custom `PromptArtifactStore`*: call `onRefused` (typed `OnArtifactRefused`, from the package root) when a copy is there and cannot be used; *a publisher that hashes its own artifact*: hash the overlay door's normal form of each `when` (or call `buildPromptArtifact`); *a host that screens a binding*: count code points. *client-core*: the generated `PromptVariantWhen` / `PromptOverlay` / `PromptArtifactPayload` types follow. *cli*: nothing.
391
+ - **Pins.** `test/backlog1919-mods-pin-by-value.test.ts` (every station's every pinned key from the event table: copies of the whole event or of one fact pass and the handler below reads the engine's value, eleven kinds of difference (a method hung on a copied list among them) are a violation by the station's failure column, `plugin.register`'s judge, the boundaries — a cycle, an `undefined` member, order, a value that is not data, an array's own members, a sparse array, nesting past the walk said as too deep to compare), `test/backlog1984-handon-engine-members.test.ts` (K every station's every pinned key: an `undefined` member, a member order, a −0 and a whole-event clone are taken and the handler below reads the engine's object by identity, a key the engine does not carry stays absent and one it carries as `undefined` stays carried, what the forwarder changed outside the pinned keys travels, a self-reference / an alias of the pinned copy / a nested back-reference keep their shape, a different value is still a violation; R a real Runner's `prompt.compose` with no binding under a forwarder handing on `binding: undefined` — the deployment handler below sees no `binding` member and composes its default branch; L the one depth: 1 000 levels compared, 1 001 not, a value-equal copy at 1 001 and at 3 000 levels refused in the same sentence, by the forwarder, by every station's failure column, a non-pinned member, an answer and `$` arguments past it refused naming the bound at both depths, a shared subtree past the bound refused in both member orders, the engine's own 3 000-level member and a 999-level copy taken, a receipt mode with no `past` still hands a nested member as it is; L9 an engine container inside a shared copy container at the 1 001st level refused in both member orders, at the 1 000th taken in both, the same law in the receipt met directly or in what holds it; L10 the engine's own 50 000-level object kept by reference through `next`, in an answer and in `$` arguments, a value-equal copy refused), `test/backlog2013-iterative-receipt.test.ts` (A a 100 000-level chain of records and of lists in every mode copied whole or refused as too deep, never uncopyable; the L2 form from under a 6 000- and an 8 000-frame caller in the bound's sentence and the 1 000-level copy taken; the comparison at the bound from under 8 000 frames; a model's 8 000-level tool input through the real PreToolUse lane — passed on: allow, from under 6 000 frames too; answered as a copy: refused naming the bound; a 20 000-level event received whole; `uncopyable` for a Proxy trap that throws on a receipt with no probe, `proxy` with one; a thrown value whose members throw or that is shaped like a refusal neither escapes nor forges a kind; a held Proxy never reflected on; B one engine-object index per dispatch over ten handlers and a 10 000-record member, no copy of it into any receipt, none at all when every handler passes the engine's event on — counted, not timed; C a 5 000- and 50 000-level `toolAxes.Read` in one bounded sentence at the door and at the reader, the same from under 6 000 frames, the boundary on both seats, a vocabulary fault 990 levels down found with a bounded path; D a cycle closed at the 1 001st level too deep to compare, at the 1 000th the same value, in the comparison and through the bus; E a method whose `enter` hands the arguments on receives the engine's own object, small or 1 500 levels deep, and a caller's class instance whose prototype moves before the shaping is copied, never handed on by identity), `test/backlog1999-handon-alias-graph.test.ts` (A every station: `chosen: input.items[0]`, an alias to a list of the copy, list elements, a two-level nested alias beside the whole copy, a cycle beside an alias — each the engine's object below, the rebuilt containers frozen, a changed nested value still a violation; P a copy object standing for two of the engine's goes to the first the comparison met, the engine's own objects are never moved, one copy object shared across two pinned keys goes home once), `test/backlog1919-binding-selector.test.ts` (U the four character bounds of the verifier read an astral boundary alike; W a sink in the Runner's shape that reads `context.phase` gets the v2 restore line; F a directory in the copy's place is `unreadable` on the store read, on `restore()` and on a pinned run — the reason carries the error code, never the path — and a missing file still reads as no copy; the store seat's two types come from the package root; S the record: the event deep-equals the manifest's `selectors`, the identity element is its digest, traits sorted; D / T deploy and task overlays through real Runner lanes — `anyOf [default, code]` under code / default / code-review / no binding, `noneOf [code-review]`, the binding as one conjunct, the unconditioned control; C a v3 center overlay under code / chat / no binding and a child under the inherited binding; V thirteen bad values each refused by name (65 names in one list among them), the 128-character and 64-name controls, a skipped deploy answer named on `onError`; S5 / S6 the station row pins all three facts and a task-seat handler forwarding a forged record is skipped and named while the deployment handler below reads the run's own binding (control: a spread passes); X `scope` refused for each class, a v3 artifact carrying it, the v2 refusal sentence, a v2 body on `applySnapshot` / store read / `restore()` / a pinned child run; P twenty-eight pairs of `replace`s, conflict or not, each also run with the two overlays in the other order (the verdict is the same), the empty-conjunct, list-order, repeat and noneOf-first forms among them; N the normal form (the door's output, idempotent and frozen, never a change of which runs it holds for; one meaning one digest; a payload hashed over another spelling refused `digest`); S7 / S8 a task-seat handler forwarding a copy of the event (five forms) is taken and one forwarding a different catalog, record or model is skipped and named; X5–X10 a stored v2 told apart from a missing copy on `restore()`, on a store read and on a pinned run (the sentence, the one line, the generic control; a sink that throws or returns a rejecting promise changes nothing); V the code-point unit (astral, CJK, mixed, lone surrogates, a huge string) for `TaskSpec.promptBinding` and `when.binding`; K the catalog variant type refuses a binding (a compile-time gate) and no bundled variant has one; R4 the trait-conditioned drop; R the run-time fold of two `replace`s that both hold, its control, and a deploy `drop` of a center entry its binding leaves out — named, the rest of the answer applied; Q `requires.bindings`). Re-expressed: `test/prompt-center-m1b.test.ts`, `test/defectscan-center-mounted-surface.test.ts` (the center class's input seat returns every overlay; the binding selects at the composition), `test/hardening-prompt-assembly.test.ts`, `test/center-artifact-fixture.ts` (`behaviorAdd(id, text, when?)`).
392
+ - **Known limits.** KL-1919-a (the publish check reads the binding by satisfiability and the other dimensions by equality; the run-time fold names what it overrides), KL-1919-b (a session pinned to a v2 digest cannot resume), KL-1919-c (a custom store that does not call `onRefused` reads a refused or unreadable copy as a missing one; the engine's own Memory and File stores call it); #2007 (the bus's depth through a cycle is measured on the walk's way, so a cyclic value's verdict can depend on its member order; the walks stay within the depth either way).
393
+ ### A letter from another user of the organization lands in the recipient's principal inbox through one delivery gate, is judged on the principal's own settings (unset ⇒ held), and is read by the one consumer the deployment declares (#972; additive; behavior change on a record carrying `fromScope`, open to veto — see Who is affected; hard-breaking: none; closed sets: `CrossSessionHoldCause` +1 (`cross-principal-default`; 9 → 10), +1 set `OrgDeliveryRefusalCode` (4 words), +1 set `PrincipalInboxTriagePolicy` (3 words), +1 set `PrincipalInboxConsumer["kind"]` (3 words); codes: +1 `config.principal_inbox_consumer_invalid`; exports: +1 interface family `OrgDeliveryGate` / `OrgAuthenticatedSender` / `CrossPrincipalBody` / `OrgDeliveryOutcome` / `OrgDeliveryRefusalCode` / `PrincipalInboxTriagePolicy` / `PrincipalInboxConsumer`, +2 constants `PRINCIPAL_INBOX_HANDLE` / `CROSS_PRINCIPAL_MESSAGE_NOTICE`, +5 functions `mintCrossPrincipalRecord` / `isGateMintedRecord` / `principalInboxConsumerOf` / `principalInboxTriagePolicy` / `principalInboxSideEffectKey`, +1 `principalInboundLayers`, +4 constants `ORG_DELIVERY_REFUSAL_CODES` / `PRINCIPAL_INBOX_TRIAGE_POLICIES` / `PRINCIPAL_INBOX_CONSUMER_INVALID_CODE` / `PRINCIPAL_INBOX_UNMINTED_CAUSE`, +1 `isOrgDeliveryRefusalCode`; members: `RunnerDeps.principalInboxConsumer?`, `ReadInboxToolOptions.coversPrincipalInbox?`, `PeerSessionDrainOptions.principalInbox?` / `markExternalContent?`, `PeerSessionDrain.principalHeld?`, `peerHeldQueueFor` / `peerHeldQueueIfAny` / `peerHeldQueueKey` take an optional box; the mailbox contract's consumption chains 3 → 4) (@server @cli @client-core @test)
394
+
395
+ - **Before.** The core half of the cross-principal lane was not in the tree: a `fromScope` record was judged by mode parity like a same-principal peer's, no box address or record mint existed for a delivery gate, and nothing decided which leg reads a principal's inbox.
396
+ - **Now.** `OrgDeliveryGate.deliverCrossPrincipal(authedFrom, toPrincipal, body)` is the contract a deployment implements (gate-owns-append), minting with `mintCrossPrincipalRecord` (anchors `fromScope` + `gateReceiptId`, kind `peer_message`; no `fromSession` / `fromMode` / `hopChain`) into `(recipientScope, PRINCIPAL_INBOX_HANDLE)`. A record carrying `fromScope` is judged on the principal arm: the principal's layers only (managed, user — never a repository layer), explicit value wins, unset holds `cross-principal-default`, no parity. `RunnerDeps.principalInboxConsumer`: absent = no lane (unchanged); `{ assistant }` = no terminal claims the box; `"terminals"` (each terminal drains the principal inbox as a second lane — own lease / ledger / ack prefix / held queue, one pass budget; the drain machinery ships) is **refused at prepare for now** (`detail.reason: "terminals_unsupported"`: held decisions are not yet shared across terminals). A malformed value fails the prepare (`config.principal_inbox_consumer_invalid`, `detail.reason: "malformed"`); a gate minting in a deployment that declared no consumer is refused with the same code (`"no_consumer"`). `ReadInbox` declares `contentOrigin: "external"` exactly when its drain covers the principal inbox; an INJECTED letter marks the leg's memory session polluted (`observed`), so each reading path is accounted once. A letter's frame carries `CROSS_PRINCIPAL_MESSAGE_NOTICE` (untrusted content, never an authorization) in place of the same-principal teammate block. The principal lane serves only gate-minted records (anything else: `admission_refused`, cause `not_minted_by_delivery_gate`) and routes no receipt.
397
+ - **Who is affected.** With `principalInboxConsumer` absent every drain is still the single-box drain it was, but its judgment of one record changes (behavior change, open to veto): a record whose meta carries `fromScope`, in whichever box the drain reads it from, is judged on the principal arm — the managed and user layers only — and with no `crossSessionInbound` there it is held under `cross-principal-default`, where 7.52.0 judged it by mode parity like a same-principal peer's (a prompting recipient took it) with a repository layer's tightening applied; a repository layer's `hold` / `refuse` no longer reaches it. Who is hurt: a host that writes mailbox records carrying `fromScope` itself rather than through a delivery gate — such a letter now waits in the session's held queue, re-judged at every boundary, until the recipient's managed or user layer sets `crossSessionInbound`; a repository that tightened `crossSessionInbound` to hold such letters. Compensation: set `crossSessionInbound` on the managed or user layer, or leave `fromScope` off a same-principal record. A host that hand-builds `PeerSessionDrain` or reads `CROSS_SESSION_HOLD_CAUSES` as a closed list sees one more cause.
398
+ - **How to migrate.** *server*: implement `OrgDeliveryGate` (membership, SQL quota, mint, append), declare `principalInboxConsumer`, wake the assistant with `createAgentContinuationVerb`, read the settings `crossSessionInbound` managed + user layers for the principal arm. *cli*: a held card for `principalHeld` entries (the new cause sentence). *client-core*: the refusal words, the hold cause, the consumer kinds.
399
+ - **Pins.** `test/design385-slice7-principal-inbox.test.ts`; `test/design385-slice2a-store.test.ts` re-pinned three → four chains; the contract kit's T2 layer gains the principal-inbox case; `test/public-knob-liveness.test.ts` row.
400
+
401
+ ### Every station that asks what the tool valve leaves mounted reads the one valve predicate, `valveLeaves`: a run with `excludeAllTools: true` takes no tool back from an MCP re-dial, and "how many of a server's tools are mounted" is one count read off the roster (#1971, first slice and its close-out; #2000, #2001, #2020, #2054, #2035; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none; members: `materializeMcpTools` +1 optional trailing parameter `dialable`; `McpRefreshResult` +1 optional member `instructions`, present on a `refreshed` entry whose current connection carries instructions) (@server @cli)
402
+
403
+ - **Before.** `valveLeaves(spec, selector)` (the whole-face switch, else the compiled selector) answered six stations; nine more read the compiled selector alone and never saw `excludeAllTools`: the MCP re-dial / `RefreshMcpTools` push, the agent-listing announce, the shell's Monitor hint, the offload reader's reserved-name door, the `ReadInbox` and `ListAgents` mount judgments, the built-in `SendMessage` judgment, the `ToolSearch` admission, and the delegation pool that the list judgment, the policy name audit and the host ceiling read. The re-dial runs AFTER the valve, so on a run told to mount nothing a host's `reconnectMcpServer` put the server's tools back on the roster: the model saw them and, under `bypassPermissions`, called them.
404
+ - **Now.** All of them read `valveLeaves`. On an `excludeAllTools` run a re-dial succeeds as a connection (`outcome: "reconnected"`) and pushes nothing: the model's tools stay `[]`. The `mcp.server_redialed` notice counts what the roster took ("0 tools mounted"; under an `excludeTools` of one of two tools it now says "1 tool mounted" where it said "2"), and so does the host's result (see the next bullet). The same run no longer announces an agent roster behind a delegation tool the valve took out. A direct read of the selector is left in `src` only where no valve reading is owed (the roster is pinned): `valveLeaves` itself, the two single-layer list helpers, a mounted tool's recovery hint, a child's `admitsPeer` at the spawn.
405
+ - **One count, read off the roster (the close-out).** The re-dial notice counted the push taken BEFORE the deployment's `toolFaces` overlay refused a tool, so a roster of one was announced as "2 tools mounted"; `McpReconnectResult.toolCount` was the server's own listing; the `memory.content_class_declared` line and `wiring_manifest.mcp[].toolCount` counted the server's listing too — one run, up to four answers to one question. One reading now answers all of them: the server's tools the roster holds, read before and after the swap. The host re-dial's result (`toolCount`, `added`, `removed`), the `mcp.server_redialed` notice and the counts in its `detail`, the `RefreshMcpTools` receipt (its "refreshed — N tools", its added / removed names and its "excluded by deployment config (not mounted)" line, which now also names a tool the overlay refused) with its `details.results`, the `memory.content_class_declared` line and `wiring_manifest.mcp[].toolCount` all count it. A safety-axis fold refusal, a failed refresh and a refused re-dial (an operator's revocation, one already in flight) assert no roster, so their result counts what the roster still holds of the server — the refused re-dial said `0` while the tools stayed mounted. A name no server is declared under holds nothing: a re-dial of `valve_x` against a declared `valve.x` (one prefix) answers `not_declared` with `toolCount: 0`, where it counted that server's tools. A result that asserts a roster carries the roster's tools as its `tools` (and `axes` cut to them), so `toolCount === tools.length` — a re-dial under an `excludeTools` of one of two tools used to say `toolCount: 1` beside both listed tools. The same reading holds where there is no run: the materialization's own `reconnect` / `refresh` count what its handles hold (a revoked or dead server's mounted tools, where they said `0`), and `probeMcpServers` counts its own dials' tools, read through the same projection as a run's manifest.
406
+ - **The stations that teach a tool read the valve (the close-out).** The skills listing is announced only while the valve leaves the `Skill` tool: an `excludeTools` of it, an `allowTools` without it and `excludeAllTools` used to send the `<skills>` frame teaching a tool that was not there (a continuation of a session that was shown skills gets the removal projection instead). A server's instructions are taught only while the run's roster holds a tool of that server the valve leaves, or a cross-server resource tool the valve leaves (some connected server advertised resources; which one is not recorded, so every server's instructions stay with it — a server's own per-declaration `allowTools` is a separate intake filter this answer does not read), or — for a server that listed no tool — while any resource tool is left. Read off the roster, a tool the deployment's `toolFaces` overlay refused does not count: with no list set and both of a server's tools refused, the instructions used to be taught and the operator's ungated-write warning named the two tools; now neither. The ungated-write warning names only server tools the roster holds: a tool the tool lists (`allowTools` / `excludeTools`) or the overlay left off is no longer named. That answer decides what the model is TAUGHT: the initial block, and what every later roster change adds — a `RefreshMcpTools` refresh or a host re-dial that leaves something of a server the model has not been told queues its instructions once on the opt-in `attachments.mcpInstructions` delta lane, the same queue a re-dial's new text takes (before, a refresh that mounted an allowed tool the start listing lacked handed the model the tool with no guidance), and a change that leaves nothing of a server withdraws an unread add. What the model HOLDS is one ledger beside the lane — the block, plus delivered frames, minus retractions (a queued copy counts once it is delivered) — and it decides the other two questions. A server taught, untaught and taught again across refreshes is taught once (it was taught twice). The disconnect line, "The following MCP servers have disconnected. Their instructions above no longer apply", names exactly the servers whose instructions the model holds: an `excludeAllTools` run whose host re-dialed a server, a run excluding each of a server's tools by name, and a server the block did not teach that died while a sibling was still connecting used to get the line for instructions they were never shown; a server taught in the block whose last allowed tool a refresh then took away got no line when it disconnected, and now does; a queued copy withdrawn before delivery gets none. A refresh entry whose connection closed while the walk was still reading (another server, or a later page) reports `not_connected` and asserts nothing: before, its listing was mounted on the closed connection and, for a server the model had not been told, its instructions were taught with no disconnect line to follow. A stopped teaching while the server stays connected withdraws nothing. The materialization used without a run takes every entry it returns as taught, as before. And a server the valve can leave nothing of is not dialed at the run's start: `excludeAllTools`, or lists that reach none of its tools (`allowTools` naming none of them) AND none of the three cross-server resource tools (`ListMcpResourcesTool` / `ReadMcpResourceTool` / `ReadMcpResourceDirTool`, which aggregate the resources of every dialed server and so keep a connection worth having) — no connection, no stdio child, `wiring_manifest.mcp[]` reads it `skipped` with `toolCount: 0` — while a host re-dial still dials it and pushes it through the valve (a connection, no tool). An `excludeTools` of the whole server alone (`mcp__srv`, `mcp__srv__*`) still dials it while the valve leaves a cross-server resource tool, because whether the server advertises resources is not known before the dial (one that turns out to advertise none is dialed and mounts nothing), and a server whose tools are each excluded by name is dialed and not taught. Every dial decision is taken before any connection opens: a `dialable` seat that throws, or answers anything but a boolean, refuses the whole `materializeMcpTools` call with `config.mcp_dialable_failed` and dials nothing — asked inside the dial loop, a throw for a later server left an earlier server's connection open with no way to release it, and an earlier dial that failed ended the process as an unhandled rejection even though the caller caught the error; an `undefined` answer used to drop the server silently. The question tool's mount flag reads the valve (one fact with the mount; unreachable today).
407
+ - **Behavior narrowing (open to veto).** (1) **What changed:** a host re-dial (and a model refresh) on a run with `excludeAllTools: true` mounts no tool of the server; and, on any run, a server the valve can leave nothing of is no longer dialed at the start, and its instructions and the skills listing are no longer taught for tools the valve unmounts (nor for tools the deployment's overlay refused), nor retracted by a disconnect line for instructions the model never held (and a server whose instructions it holds is retracted when it disconnects, also after a refresh took its last tool away); a `dialable` seat that cannot decide refuses the materialization. (2) **Who:** a host that used a re-dial to put tools on a run it had switched fully off (none is known; the switch's own contract is "the leg mounts nothing; the request carries `tools: []`"); a host that read a `connected` status, or the instructions in the prompt, of a server whose every tool its lists excluded; a consumer that read `McpReconnectResult.toolCount` / `tools` / `wiring_manifest.mcp[].toolCount` as the server's listing, or a materialization's `0` on a revoked or dead server as "nothing mounted"; a library caller whose `dialable` seat answered a non-boolean (the server was dropped; the call now rejects). (3) **Compensation:** leave the switch (or the exclusion) off and bound the face with `allowTools`; read the listing from `probeMcpServers` or the materialization.
408
+ - **Also moved (loosening, open to veto).** On an `excludeAllTools` run, a caller tool named `ReadToolResult` is no longer refused at prepare (the reader it would shadow is not mounted, as under `excludeTools: ["ReadToolResult"]`); a list entry that is a near miss of a name only a delegation tool's pool carries is told as an unknown name instead of refused, as when the delegation tool itself is excluded; and a `toolPolicy` entry that is a case twin of a name only a delegation tool's pool carries is, under the same switch, told by the policy name audit ("matches no tool mounted this run") instead of refused with `config.legacy_tool_name` — an empty roster hands no pool to anyone, so there is no twin to refuse (a refusal became a notice).
409
+ - **Also moved (a side effect of the offload door).** An `excludeAllTools` run mints no tool-result offload store (the reader it feeds is not mounted), so the compaction lanes that persist a cleared tool result to that store — the micro-compaction's offload and the guard replay's — have nothing to persist to on a continued session, and a configured `compaction.staleToolResultOffload` reports itself inert on the operator lane. The pointer they would have written named a reader the run does not mount.
410
+ - **How to migrate.** *server*: `McpReconnectResult.toolCount` / `added` / `removed` of `reconnectMcpServer` are now the roster's reading (what was mounted, never the server's listing); the notice's `detail` carries the same counts, not names — the names are in the result, and its `tools` are the roster's; a refresh's `details.results[]` entry may carry `instructions` (the connection's entry, `author: "host"` when the host replaced the text); the coded refusal of a `dialable` seat that cannot decide is `config.mcp_dialable_failed`. *cli*: `wiring_manifest.mcp[].toolCount` (the "connected · N tools" row of the engine-hosted MCP view) is now the count of the server's tools on the roster, and a server the lists reach none of reads `skipped` with `0`. Other runs move too, in three ways: a tool the deployment's `toolFaces` overlay refuses is not counted, with no list set at all (both of two refused: `2` → `0`, and the server's instructions are no longer taught); an `excludeTools` of one of a server's tools counts one fewer (`2` → `1`); and what the model is taught moves with the valve — a server's instructions go when every one of its tools is excluded by name, the skills listing goes under an `excludeTools` of `Skill` or an `allowTools` without it, and the disconnect line names exactly the servers whose instructions the model holds.
411
+ - **Pins.** `test/backlog1971b-mounted-set-and-valve-readers.test.ts` (the roster reading of one domain — valve, overlay, fold refusal, withdrawal, another server's tools — and the same number on every surface of a real run; the skills listing under each way to take the tool out, on the wire; the instructions block and the first dial as a process fact across thirteen lists and servers — resources-capable, no-tool, excluded whole, every tool excluded, the resource tools alone; the policy-name audit under the switch; the question flag; the offload store); `test/backlog1971a-valve-readers.test.ts` (the re-dial on a real run, each station beside the valve's answer for the same tool, the syntax-tree guard with its negative controls — now also the nineteen spellings a review found it blind to); `test/rescan-a087-fixes.test.ts` D1 re-shaped to the `leaves` predicate; `test/backlog1971b-mounted-set-and-valve-readers.test.ts` ⑧ (the disconnect line under the switch with a host re-dial, under each tool excluded by name, the taught control, and a server that dies while a sibling is still connecting; the undeclared name; the re-dial result's `tools`; the overlay refusing every tool; the materialization's own counts) and the fixture labels made true (a server excluded whole is dialed while the resource tools are left — the fixture advertises no resources, so none mounts); `test/backlog2000-dialable-decisions-first.test.ts` (a throwing seat after an earlier dial that would succeed — no process started, no lease asked — and after one that fails — no unhandled rejection; non-boolean answers; controls); `test/backlog2001-refresh-teaches-once.test.ts` (on real runs: the refresh that first mounts an allowed tool teaches once, its taught control, the instructions-only server gaining a tool, the reverse direction, the block-taught server untaught by a refresh then disconnecting, the frame-taught server untaught and re-taught, the untaught server dying while a sibling connects, the materialization alone; the applier's and the ledger's cells, a queued copy withdrawn before delivery included); `test/backlog874-mcp-liveness.test.ts` / `test/backlog562-manifest-mcp-face.test.ts` hand the projection its roster names; `test/backlog857-mcp-redial.test.ts` (a dead server's refresh counts its two held tools) and `test/backlog1971a-valve-readers.test.ts` (the shared applier is the re-dial's one valve reading) re-pinned.
412
+
413
+ ### A resume runs prepare's config doors before the claim: a bad lock, protocol declaration, limit or deployment seat refuses the resume and the row stays `pending` on any store (#1950; behavior change — refusal moved before the claim and code changed on three arms, open to veto; hard-breaking: none; closed sets: `CHECKPOINT_CONFIG_REFUSAL_CODES` +30 (9 → 39), `CheckpointErrorReason` +2 (`unknown_model_ref`, `not_a_model_ref`), `detail.field` on `checkpoint.config_refused` +1 (`deps`); exports: none; members: none) (@server @cli @client-core @test)
414
+
415
+ - **Before.** The resume's screen before the claim carried the mode / output-style / approval-reach / invoked-skill / attachment doors only. Every other door of prepare's config-door phase — the lock layer, an MCP server's content class, the MCP and A2A `toolAxes`, the limits, the knobs, the deployment's own seats — refused in the resumed leg's prepare, after the claim: the leg ended `failed` with zero model turns, and on a checkpoint store without `reopen` the row stayed `resolved`, so the corrected retry was `checkpoint.already_resolved` and the suspended work was lost (with `reopen` the row came back as a system retry that replays the recorded decision). 7.53.0's A2A door (#1835) sat in the same place. Three of those facts also had a second reading before the claim with another shape: the deps' retired seats and the microCompact knob threw their raw codes, and a task-supplied `basePolicyForResumeEdit` under a locked `toolPolicy` answered `checkpoint.invalid_outcome` where prepare answers `config.locked_key`.
416
+ - **Now.** One rule: every door of the config-door phase that reads only the re-supplied config, the deployment's deps and the row runs before the claim, through the one function prepare calls (`configValueDoors`); the resume enters each through its one refusal channel — `CheckpointError` `checkpoint.config_refused`, `message` = prepare's sentence, `detail.code` = prepare's code, `detail.field` = `"taskConfig"` (correct the config) or `"deps"` (a deployment seat — correct the deployment) — and the row stays `pending` and byte-identical with or without `reopen`; the corrected resume redeems the same token. The MCP servers and A2A peers the leg dials are the frozen entries the screen judged (the caller's entries are read once). The three second readings are retired: one fact, one code. The skills-listing seats' refusals read no model and are judged with the other value doors (the window-dependent budget is still resolved against the model). The retention declaration (`config.retention_policy`, `config.retention_capability`) is judged here too, over the session store the Runner was constructed over (fixed for its life, so the admission rung is handed it) and the checkpoint and tool-result stores the leg would run on. Every one of these doors is total over plain JSON: a `null`, a primitive or a list where a record is declared is refused by the door's own code through the channel — `limits: null`, `resourceSuspend: null` and `retentionPolicy: null` were raw `TypeError`s before the claim (the row pending, but a caller's retry logic reads a non-`CheckpointError` as terminal), and the resume's own config, when it is not an object, is the first door (`config.invalid_argument`). The doors still refusing after the claim do so each for a stated reason (KL-1950-a; INTEGRATION-CORE "A resume's two doors"): the approval seat's value, the interaction posture, the per-task agents, the model doors, and prepare's own stretch after the phase.
417
+ - **Behavior change (open to veto).** (1) **What changed:** a resume whose config or deployment fails one of the moved doors rejects with `checkpoint.config_refused` (the row pending) where it resolved a `failed` stream (the row consumed or replayed); the three retired readings change code — a task-supplied `basePolicyForResumeEdit` under a locked `toolPolicy` from `checkpoint.invalid_outcome` to `checkpoint.config_refused` / `config.locked_key`, a retired deps seat or caller-policy member from a raw `config.deps_retired_key` throw to `checkpoint.config_refused` carrying it, a bad microCompact machine likewise with `config.microcompact_invalid`, and a retired member of a policy re-supplied as an inherited constraint likewise (`detail.field: "deps"`; it was the one place the word left as a raw throw); those refusals now come at admission, before the checkpoint screen, so a resume that is wrong on both counts reports the config first. A fresh run's codes and sentences are unchanged, with these exceptions, each an improvement on an uncoded `TypeError`: a missing or `null` `objective` ends `config.empty_objective`; `limits: null`, `resourceSuspend: null` and `retentionPolicy: null` end `config.limit_invalid`, `config.limit_invalid` and `config.retention_policy`; and the tool-face list refusal (`config.tool_face_control` on `allowTools` / `excludeTools` / `deferTools` / `alwaysLoadTools`) now carries `detail.reason` (`not_a_list` | `not_a_string`); a role seat or tier binding the engine cannot read ends `config.unknown_role` (`detail.reason` `not_a_model_ref` | `unknown_model_ref`) where it ended a raw `TypeError` or an uncoded `Unknown model ref`: a role bound to a number or a boolean (any run), a role's `model` that is a number, a boolean or a list or whose word the catalog does not hold, a `select` whose `needs` is no list, on the two side channels that read a role by its own key (`roles.summarize` for compaction, `roles.consolidate` / `roles.summarize` for the armed auto-consolidation — `autoRunOnRecommendation: true` reads them at the door), and a tier bound to a stale word or to a non-reference at `new Runner`; a catalog entry with nothing behind it is no `select` candidate (it threw a `TypeError`); a list as a role seat is still read as no preset, and `resolveModel` still answers every catalog it answered; a refusal sentence no longer coerces the value it rejects (a record shadowing `toString` made the sentence itself throw a `TypeError` at the limits, resource-suspend, retention, brain-call-guardrail, resume-at-mode and compliance-deny doors); and a `null` checkpoint store seat is refused as the store seat (`config.invalid_checkpoint_store`) rather than read through. On a fresh run that is wrong at two doors the limits / attachments / resource-suspend / governance-window / guardrail doors now answer before the retention, approval-seat and interaction-posture doors, and the skills-listing seats answer before the model doors; when a task-named checkpoint store and a deployment store both declare a retention nobody can parse, the deployment's is reported first (the task's store is judged in its own call after it). (2) **Who is hurt:** a host that read a resume's `failed` terminal as the config error, or branched on `checkpoint.invalid_outcome` for the locked resume-edit seat. (3) **Compensation:** the refusal is the same sentence and the same code (`detail.code`) a fresh run reports; the row waits for the correction.
418
+ - **Closed sets.** `CHECKPOINT_CONFIG_REFUSAL_CODES` +30, each prepare's own word and each one the screen can produce (a recipe for every word is pinned; `resume_at.invalid_mode` is a fresh run's word only — a resume carrying `resumeAt` is refused as `resume_at.conflicts_resume` before its mode is read — so it is not in the set): `config.deps_retired_key`, `config.peer_presence_invalid`, `config.tool_model_gate_invalid`, `config.microcompact_invalid`, `config.prompt_selector_invalid`, `resume_at.conflicts_resume`, `resume_at.mode_without_target`, `config.read_face_invalid`, `config.write_face_invalid`, `config.invalid_argument`, `config.memory_delegation_evidence`, `config.memory_provenance`, `config.memory_capture_policy`, `config.auto_consolidation`, `config.unknown_role`, `config.tool_materialize_invalid`, `config.memory_persistence_invalid`, `config.tool_materialize_unreachable`, `config.locked_registry`, `config.locked_key`, `config.mcp_content_class`, `config.skill_listing_budget_invalid`, `config.limit_unknown_key`, `config.limit_invalid`, `config.attachment_invalid`, `config.usage_window_invalid`, `config.brain_call_guardrail_invalid`, `config.retention_policy`, `config.retention_capability`, `config.invalid_checkpoint_store` (the checkpoint store seat itself — it left admission as a bare coded throw, outside the channel). `CheckpointError.detail.field` +1: `deps`. `CheckpointErrorReason` +2: `unknown_model_ref`, `not_a_model_ref` — the arms of `config.unknown_role` on a role seat or a tier binding the engine cannot read (below); the re-supplied config's tool-face lists carry the existing `not_a_list` / `not_a_string`.
419
+ - **Who breaks.** *server*: a consumer that pinned `CheckpointConfigRefusalCode` / `detail.field` as closed sets (each gains words; open to veto); a test that expected a resume to resolve `failed` with one of these codes, or `checkpoint.invalid_outcome` for the locked resume-edit seat, re-pins to `checkpoint.config_refused`; route `detail.field: "deps"` as the deployment's fault (the row pending, retriable after the fix — the retention declaration and the policies a caller re-supplies as inherited constraints included); a config that is not an object, or a `limits` / `resourceSuspend` / `retentionPolicy` of `null`, is now `checkpoint.config_refused` where it was a raw `TypeError` before the claim, so a resume leg that read a non-`CheckpointError` as terminal no longer strands the approval. *cli*: the rejection's `message` is prepare's sentence, shown as is. *client-core*: the two types widen. *test*: black box — on a store without `reopen`, a resume with an unreadable A2A or MCP declaration rejects `checkpoint.config_refused` and the row stays `pending`; the corrected retry completes.
420
+ - **How to migrate.** Read `checkpoint.config_refused` + `detail.code` for every config error a resume can meet before its claim; `detail.field` routes it.
421
+ - **Pins.** `test/backlog1950-resume-config-doors-preclaim.test.ts` (cells A – E as first written; G the totality sweep over every seat the screen reads × fifteen JSON shapes (a record shadowing `toString` among them) and the config container; H the retention declaration over the Runner's session store, a task-named checkpoint store judged as the task's, the store seat by the channel; I a recipe for each of the 39 words of the set; K the inherited-constraint shape; L the auto-consolidation role seats and tier bindings); `test/backlog1950b-locked-config-total.test.ts` (the lock declaration's totality, pinned beside its reader); `test/backlog1763-mcp-tool-axes-judge.test.ts` E and `test/backlog1835-tool-axes-one-judge.test.ts` F flipped from the known gap; `test/f012-inheritance-decision-chain.test.ts`, `test/retired-doors-7490.test.ts`, `test/retired-hooks-key-one-door.test.ts` re-pinned to the one channel.
422
+
423
+ ### A compaction pass announces its start: `compaction_started` (`trigger`, `pass`, `startedAt`), minted once at the pass's commit point by every lane that compacts; the frame that ends the pass carries the same `pass` (#2008; additive; behavior addition, open to veto — a pass the task's own end cuts at the turn boundary is now ended by a frame; hard-breaking: none; closed sets: `TaskEvent["type"]` +1 arm; exports: +1 type `CompactionPassStart`; members: `TaskEvent` arm `compaction_started` (`trigger`, `pass`, `startedAt`), +1 optional `compacted.pass`, +1 optional `compaction_outcome.pass`, +1 optional `MaybeCompactOptions.onStarted`, +1 optional `pass` on `maybeCompact`'s result) (@server @cli @client-core)
424
+
425
+ - **Before.** Compaction was announced when it ended: `compacted` for a landing, `compaction_outcome` for a pass that did not land. A summary call that waited out a provider's retry schedule (503 × 11, 173 s measured) was a silent wait on the stream: a shell could only spin, and a server could not say what the run was doing.
426
+ - **Now.** `compaction_started` = `{ trigger, pass, startedAt }`, **all always**, beside the identity envelope every content event carries. `trigger` is the vocabulary the pass already hands the `preCompact` hook and its ending frame — **`"auto"` \| `"manual"` \| `"forced"`** (three words, not two: the prompt-too-long recovery, the guard chain's arm B and a trim-pressure boundary pass run as `"forced"`). `pass` is an opaque engine-minted id (uuidv7), unique across runs. `startedAt` is the engine clock in epoch ms. It is minted at the one place a pass commits, inside `maybeCompact` — the trigger gate passed, a valid cut point exists, no `preCompact` handler blocked it — before the summary provider is consulted or the model is called, so the frame is on the stream for the whole of the wait; a retry inside the summary call is the same pass and mints nothing. The commit point is also a public seat: `MaybeCompactOptions.onStarted(start: CompactionPassStart)` (`CompactionPassStart` = `{ pass, trigger, startedAt }`, now exported from the package root like the sibling callbacks' payloads), observe-only — a throw, or the rejection of an `async` callback, is contained at the seat's site and never reaches the pass — and `maybeCompact`'s result names the pass on a landing (`pass`). Three lanes put the frame on the wire from that callback, one frame mint (`mintCompactionStartedEvent`, beside the landing's `mintCompactedEvent`): the turn boundary (`auto`; `manual` for `/compact`; `forced` under trim pressure), the prompt-too-long recovery and the guard chain's arm B (`forced`), and the end-of-task summary (`auto`).
427
+ - **Pairing.** Exactly one frame ends each started pass and carries its `pass`: `compacted` when it lands (the key is new and optional on the type — the engine sets it on every landing), `compaction_outcome{failed}` when it burned its attempt, `compaction_outcome{mooted}` when it was cut while it ran (`reason: "cancelled"` for a caller's stop or withdrawal, `"task_ending"` when the task's own end cut it). `compaction_outcome.pass` is present exactly when a start was announced for the pass the frame ends; an outcome of a pass that never committed — `blocked`, `suppressed`, `disabled`, `noop`, a manual request no boundary served (`task_ending`) — announces no start and carries no `pass`, byte-identical to 7.52.0; so does the rapid-refill `disabled` frame, which follows a landing and reports the state that landing caused. A run with no compaction has no new frame.
428
+ - **Behavior addition (open to veto).** (1) **What changed:** a turn-boundary pass the task's own end cuts while its summary call runs (the walltime limit, a budget or turn limit — an abort the caller did not raise) ended with no frame at all; it is now ended by `compaction_outcome{outcome: "mooted", reason: "task_ending", trigger, pass}` (with its trace pair, `compaction.mooted{reason: "task_ending"}`), so that no started pass is left open. A cut before the pass committed stays as quiet as it was. (2) **Who is hurt:** a consumer that read the absence of any outcome there as "nothing happened" (no such consumer is known; the neighbours' code was not read for this). (3) **Compensation:** none needed; the frame is the one the dying-loop arm of a manual request already used for the same fact. The prompt-too-long recovery lane's reading of the same cut (`failed`) is unchanged.
429
+ - **Against Claude Code 2.1.296.** CC announces the start of a compaction as a status message (`system` / `status`, `status: "compacting"`) and its progress as `compact_progress`; the end is `compact_boundary`. The fact announced is the same; the shape is this engine's own wire — a discrete frame with an id the ending carries, so that a stream that multiplexes several tasks pairs the two by id and not by order.
430
+ - **Who is hurt.** A consumer with an exhaustive switch over `TaskEvent["type"]` (as for `task_started`: server's two arm gates, `src/trace/core-keyset-guard.ts` and `src/trace/model-segment.ts`; client-core's event view) fails to compile until it handles `compaction_started`; a consumer that ignores unknown frames is unchanged. A consumer that keeps a "compacting" state must end it on a frame carrying the same `pass` (or when the stream ends), not on the next `compacted` it sees.
431
+ - **How to migrate.** *server* (S-1732): project the frame as it is — `{ type, trigger, pass, startedAt }` plus the identity keys; add no phase of its own; the run and session ids are the stream's, not keys of the frame. *cli* (L-1568): show "compacting" from `compaction_started` until a `compacted` or `compaction_outcome` with the same `pass` arrives; the elapsed time is `now − startedAt`. *client-core*: add the arm to the event view and the optional `pass` to the two ending frames.
432
+ - **Pins.** `test/backlog2008-compaction-started.test.ts` (each lane's start precedes its summary call and sits on the stream while the provider's three 503s are retried inside the one pass; the landing names it; closed key set; three lanes, three distinct passes, each paired in its own stream; a failed pass, a caller's stop, a withdrawn manual request and the task's own end each end by a frame carrying the pass; a pass that never commits — a `preCompact` block, a stop that lands while the hook runs, a noop, a run with no compaction — announces nothing and its outcome has no `pass` key (the task's own end during the hook leaves no compaction frame at all, as before); the rapid-refill trip carries none; `maybeCompact.onStarted` fires once after `preCompact` and before the first model call, never for a pass that ends before the commit, also on the provider-reuse and forced paths, and a throwing observer is contained); `test/defectscan-task-event-surface.test.ts` (arm table +1).
433
+
434
+ ### A delegation marks the session's memory externally exposed by what entered the transcript: a delegation refused before any child ran marks nothing, and a background launch's reason says it launched (#1940; P2; behavior change — a loosening on refused delegations, a reason sentence on launches, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@cli)
435
+
436
+ - **Before.** The delegation arm judged exposure when the delegation tool was CALLED: an `Agent` call the tool refused before any child ran (an unknown `subagent_type`, an empty prompt, worktree isolation unavailable, the entry caps, a launch the account could not record) still marked `memory.session_polluted` with the reason `tool "Agent" returned the output of a delegated agent whose tool face can reach external content …` — false on its face; the background launch receipt carried the same "returned the output" reason.
437
+ - **Now.** One reading of what the call put into the transcript. The delegation tool's own refusals before any child ran are minted by one helper (`notStartedResult` in `src/agents/subagent.ts`, 38 sites) that brands the result's `details` object; the memory wrap exempts exactly those (`isNotStartedDelegationResult`, the brand only, never the shape): no child ran, so nothing of a child's — no result, no message it sent up — entered: no mark, no evidence gap on the provenance chain, no unattestable account row. The launch receipt marks at launch, with the reason `tool "Agent" launched a background delegated agent whose tool face can reach external content (…) — its output enters this session when it finishes`. Everything else marks exactly as before (the reason byte-identical): a child's report (completed or failed), a parked child's report and a fork whose run threw (a child ran and may have sent a message up before it stopped), any shape the reading does not know. A refusal not routed through the helper, or a result whose `details` was copied, still marks — drift lands on the safe side (a tripwire pin names any unminted refusal site). What a branded refusal quotes to the model (#2045, #2051): never a thrown value — a failed worktree mint's git output, a session store's or task registry's error, the settlement account's — and never a name an extraTools factory produced outside its declaration (the engine reads an undeclared product as external content); it says what failed in a fixed sentence and hands that text to the host as `details.cause`; a worktree whose cleanup check failed is named in a fixed settle line and its error rides `details.worktreeCause` (a child's report, which marks, still appends it). What a branded refusal does still quote: the engine's own sentences, the deployment's configuration (its agent catalog, definition lists, the entry gate's numbers), the model's own call arguments (`subagent_type`, `name`, `model`) and the auto-mode classifier's reason — text already in the session or authored by the deployment. Model-facing wording changes on seven refusals (`isolation.failed`, `fork failed`, `register_failed` ×2, `settlement_write_ahead_failed` ×2, `config.extra_tool_names`) and on the settle line.
438
+ - **Behavior change (open to veto).** (1) a delegation refused before any child ran no longer marks the session; (2) who is hurt: a host that counted refused delegations as exposure; (3) compensation: none needed — nothing a child produced entered.
439
+ - **Who breaks.** *cli*: L-1539's repro (a refused delegation) no longer shows the notice; the launch notice reads "launched". Nobody pins the reason text. *test*: `memory-engine-pollution` re-pinned — an unknown `subagent_type` no longer marks.
440
+ - **Pins.** `test/backlog1940-delegation-exposure-delivered.test.ts` (R1–R3 refused / failed before a child, N1–N2 a report entered, L1 the launch, P1 a parked child marks, U the brand by `details` identity — a copy is not exempt, the shape alone buys nothing — T the tripwire: every refusal result in the delegation tool is minted through the helper, except the three a child ran for, each exactly once; T2 no branded refusal's content, and no settle line, quotes a thrown value; F1 / F2 a worktree mint / cleanup failure carrying outside output: fixed text to the model, the text in `details`; F3 a real non-repository root; F4 an undeclared extraTools product whose name carries an instruction).
441
+
442
+ ### `admitRouteRequest` — the route request gate the first-party brains send through — is exported from the package root, so a host's direct send binds and is refused by the same credential-origin table (#1962; P2; security — a per-model credential sent by a host to origin A then B was sent to both; additive export; hard-breaking: none; closed sets: none; exports: +1 function `admitRouteRequest` (the module-level `isNotStartedDelegationResult` of #1940 is not on the package root); members: none) (@server)
443
+
444
+ - **Before.** The gate that binds a per-model credential to the origin it is first sent to (and refuses it anywhere else) was module-internal; a host that sends a request itself (server's hook direct path) could only read `adjudicateModelRoute`, which binds nothing — so its sends to A and then B both went out, and the protection depended on who sent first.
445
+ - **Now.** The package root exports the brains' own `admitRouteRequest(model, credential, config)` (the same function, not a copy): resolve the credential with `resolveRouteCredential`, call the gate before building, run the admission it returns right before the request goes out; a refusal is the very `BrainError` a first-party brain throws for the same request (`[invalid_request] route.credential_origin_mismatch: …`). `adjudicateModelRoute` stays the read.
446
+ - **Who breaks.** Nobody. *server*: replace the direct path's `adjudicateModelRoute` + own `fetch` with the gate (its admission run immediately before `fetch`), leaving no second reader.
447
+ - **Pins.** `test/backlog1962-host-route-gate.test.ts` (E identity, G1 host A then B with the brain's sentence, G2 / G3 either sender first, V the read binds nothing).
448
+
449
+ ### `config.user_attachment_invalid` carries the field it names: `detail.path` (the field path the sentence opens with) and `detail.index` (the attachment's position, when the sentence names one attachment) (#1968; additive; hard-breaking: none; closed sets: none; exports: none; members: the refusal's `detail` `{ path, index? }`) (@server @sdk @client-core)
450
+
451
+ - **Now.** Every mint of the code — the screen (the seat, an entry, a member, a nested member), the image admission, the no-workspace path refusal, the window refusal — carries `detail: { path, index? }`: `path` is exactly the field the sentence opens with; `index` is 0-based (the `[i]` of `path`) and present exactly when the sentence names one attachment (the seat as a whole — not a list, too many, on a resume, over the window — has none). The sentence is unchanged. A fresh run's `terminal.detail` is `{ kind: "coded", detail: { path, index? } }`; a resume refuses attachments only as a seat (its channel carries `detail.code` and `detail.field` as before).
452
+ - **Who breaks.** Nobody. *server*: passes it through as it does the code; *sdk*: the type gains `detail`; *client-core*: read `index` to point at the attachment.
453
+ - **Pins.** `test/backlog1968-attachment-refusal-detail.test.ts` (every mint family, and the real lane's terminal).
454
+
455
+ ### The earlier-grammar announcement of a session's stored rules is one engine notice, `config.session_rules_earlier_grammar` (audience `user`), with the facts as detail; the `onError` phase it rode is retired (#1970; consumer-visible channel move, open to veto; hard-breaking: none for a deployment — the code word is unchanged, see Who breaks; closed sets: `EngineNoticeCode` +1 `config.session_rules_earlier_grammar`; exports: none; members: none) (@server @cli)
456
+
457
+ - **Before.** A session's stored rules holding entries their earlier rule grammar reads as nothing (C-R493: a `toolDeny` entry no grammar reads refuses every call until rewritten) were announced through `onError` (phase `config`) as an advisory carrying `code: "config.session_rules_earlier_grammar"` — not an `EngineNotice`, so a host's notice routing never reached the person whose calls those rules judge.
458
+ - **Now.** One engine notice per distinct entry set per bucket per rule layer: `message` is the advisory sentence unchanged; `detail: { bucket, storedGrammar, entries: [{ entry, reader }], rewrite: { by: "operator_write_without_rev", stampsGrammar }, rulesSessionId, sessionId }` — every entry (the sentence names the first five), each through the notice rule-text reading and with the reader's word (`no_rule` — this engine's grammar reads it as no rule; `named_no_tool` — a grammar-0 allow entry that named no tool there); `rulesSessionId` is whose rules (an ancestor's on a delegated child), `sessionId` the run that read them. Nothing rides `onError` any more.
459
+ - **Who breaks.** *server*: a consumer reading the code off `onError` (`runner_config_advisory`) now receives it on `onNotice` — register it by the notice derivation; a test that pinned the `onError` phase re-pins to the notice. *cli*: render the notice's `message` as is.
460
+ - **Pins.** `test/backlog1970-earlier-grammar-notice.test.ts` (N1 / N2 the four facts and zero `onError`, C the stamped control, K the registration and the rule-text cut); `test/backlog1761-rule-row-grammar-version.test.ts`, `test/backlog1790-grammar0-star.test.ts`, `test/backlog1866-session-deny-fail-closed.test.ts` re-pinned to the notice (each now also asserts the `onError` channel stays empty).
461
+
462
+ ### Four seats total at their own reader, before any resource: `TaskSpec.skills`, the policy seats and `RunnerDeps.hands` refuse a value they cannot read by name, and a resume refuses it before its claim (#2003; P2; behavior narrowing — open to veto; hard-breaking: none for a well-formed deployment, see Who breaks; closed sets: `CHECKPOINT_CONFIG_REFUSAL_CODES` +1 `config.skill_base_dir_invalid` (39 → 40); exports: none; members: none) (@server @cli @client-core @test)
463
+
464
+ - **Before.** `skills: [5]` / `[{ name: 5 }]` / `5` / `[null]` crashed with a bare `TypeError` (a resume left a non-`CheckpointError` with the row pending, which a caller reads as terminal; `files: 5` consumed the row and failed the leg); a skill's `content: 5` ran; a skill's `baseDir: 5` on a resume left as a raw coded error; `toolPolicy: 5` or `{ check: 5 }` ran with no policy installed; `RunnerDeps.hands` of `5`, `{ x: null }` or `{ commitCoAuthor: 5 }` ran as if absent.
465
+ - **Now.** The skills seat has one reading (`readSkillsSeat`, `src/core/runner/skills-seat.ts`; `normalizeSkills` is its sorted view): total — not a list, an entry not an object, a `name` / `description` / `content` that is not a string, or `files` that is not a list of `{ path, content }` strings refuses `config.invalid_argument` with `detail.field` the member's path (`TaskSpec.skills[0].name`); `baseDir` by its own reader (`config.skill_base_dir_invalid`) — and once: the config doors run it before any resource and hand its plain copy on as the leg's `skills` (fresh and resume) — an absent seat included, installed as the copy's own empty slot — so nothing after the door reads the caller's seat or skill objects again (an attachment's accessor is read once per leg; a `skills` accessor that answers nothing at the door is never read again). The ToolPolicy door every installed policy passes is total: `null` is no policy (the contract: `null` = no policy; any other value that is not an object with a callable `check` is refused), refusing `config.invalid_argument` (`detail.field: "ToolPolicy"`); the resolved seat is the one judged, as before (a task's policy shadows the deployment's, which is not wired). `RunnerDeps.hands` has a screen (`screenHandsBandOptions`, `src/core/hands-band-screen.ts`): not a plain record, a member it does not have, or a member with a value it does not take refuses `config.invalid_argument` (`detail.field` `RunnerDeps.hands` or `RunnerDeps.hands.<member>`; an explicit `undefined` member is absence). All run in the config doors, so a fresh run fails before any resource and a resume rejects `checkpoint.config_refused` (`detail.field` `taskConfig` / `deps`) with the row `pending`.
466
+ - **Behavior change (open to veto).** (1) the shapes above are refused where they crashed or ran; and, a behavior narrowing open to veto, `skills: null` — which 7.52.0 read as no skills (`spec.skills ?? []`) — is refused (`config.invalid_argument`, `detail.field: "TaskSpec.skills"`, "TaskSpec.skills must be a list (got null)"; a resume rejects `checkpoint.config_refused` with the row `pending`): only an absent seat is no skills, where `toolPolicy: null` stays no policy; (2) who is hurt: a deployment passing an extra key on `RunnerDeps.hands`, a policy object without `check`, a skill with a non-string member, or a host that writes `skills: null` for "no skills"; (3) compensation: the refusal names the member; leave `skills` out (or pass `[]`) for no skills.
467
+ - **Closed sets.** `CHECKPOINT_CONFIG_REFUSAL_CODES` +1: `config.skill_base_dir_invalid`.
468
+ - **Who breaks.** *server*: a consumer pinning `CheckpointConfigRefusalCode` gains a word; a settings path that hands `hands` extra keys is refused at the first leg. *test*: black box — a resume with `skills: [5]` rejects `checkpoint.config_refused` and the row stays `pending`.
469
+ - **Pins.** `test/backlog2003-seat-totality.test.ts` (eleven bad shapes × resume and fresh; the read-once cells — an attachment accessor read once on a fresh run and once on a resume, a seat accessor answering nothing at the door and `[null]` later read once; controls including `toolPolicy: null` and an explicit `undefined` hands member); `test/backlog1950-resume-config-doors-preclaim.test.ts` I gains the word's recipe.
470
+
1
471
  ## 7.52.0 — 2026-10-17
2
472
 
3
473
  ### Another spelling of a mounted tool's name in a tool list refuses the run before it starts: a deny list that names a case twin or a retired name hid nothing, and the tool it was written for ran (#1930, #1932, #1935, #1937, #1947, #1951, #1953; P1; security — fail-open; behavior narrowing, the reversal of one arm of the 7.51.0 "kept and told" entry — open to veto; **hard-breaking**: a run whose agent definition, delegation tool, `TaskSpec` or policy name lists (a permission rule's tool name included), or a session-rule write, or a host-written workflow `toolCeiling`, name a tool by another spelling of a mounted tool's name is refused where 7.51.x started it, and so is a run whose delegation tool is mounted under another name than the one it declares (`{ ...tool, name: "Delegate" }` on a tool built as `createSubagentTool({ name: "FENCE" })`); codes: `config.agent.unknown_tool` and `config.tool_face_control` gain the `detail.reason` value `near_miss`, `config.tool_face_control` also gains `renamed_mount`, `invalid_record` and `registry_occupied`; `checkpoint.config_refused` carries `config.tool_face_control` as `detail.code` (the row's recorded tool names and a renamed delegation mount, refused before the claim — they were `resume.parent_constraint_mismatch` for the names, in this unreleased window), `config.legacy_tool_name` answers a case twin; closed sets: `REQUIREMENT_WORDS` +1 (`own_tool_lists`; 28 → 29 — an older worker refuses a row carrying it as `version_newer`, the row stays pending), `CHECKPOINT_CONFIG_REFUSAL_CODES` +1 (`config.tool_face_control`; 8 → 9), `CheckpointErrorReason` +7 (`not_a_list`, `not_a_string`, `unknown_name`, `unsupported_pattern`, `invalid_record`, `renamed_mount`, `registry_occupied`; 18 → 25); `detail.reason` is an open string on those codes (new values: `near_miss`, `renamed_mount`, `invalid_record`); exports: none; members: `WorkflowInternals` +1 optional `toolCeiling` (the launching run's ceiling, on the trusted fourth argument of `startWorkflow` / `runWorkflow`; `RunWorkflowOptions.toolCeiling` stays, now the host-written form that is judged); `CheckpointState` +1 (`ownToolLists`, requirement word `own_tool_lists`: optional `{ toolName, definition?, lists }` — the delegation tool that wrote a leg's own lists, the definition it was spawned as and the digest of exactly the lists it wrote, which the record holds for; absent on a row parked before it); `RunInternals` −1 +1 (`ownToolListsWhere` → `ownToolLists`: the engine-minted string label of whose lists a leg's own are becomes the structure `{ toolName, definition?, lists }`; the sentence is minted where it is used)) (@cli @server @client-core @test)