approval-md 0.0.1 → 0.1.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 (522) hide show
  1. package/LICENSE +176 -0
  2. package/NOTICE +5 -0
  3. package/README.md +909 -4
  4. package/SPEC.md +445 -32
  5. package/cli.js +29 -3
  6. package/dist/src/adapters/agentmail.js +1200 -0
  7. package/dist/src/adapters/agentmail.js.map +1 -0
  8. package/dist/src/adapters/conformance.js +461 -0
  9. package/dist/src/adapters/conformance.js.map +1 -0
  10. package/dist/src/adapters/contract.js +941 -0
  11. package/dist/src/adapters/contract.js.map +1 -0
  12. package/dist/src/adapters/email.js +749 -0
  13. package/dist/src/adapters/email.js.map +1 -0
  14. package/dist/src/adapters/env-passphrase.js +132 -0
  15. package/dist/src/adapters/env-passphrase.js.map +1 -0
  16. package/dist/src/adapters/registry.js +76 -0
  17. package/dist/src/adapters/registry.js.map +1 -0
  18. package/dist/src/adapters/smtp.js +499 -0
  19. package/dist/src/adapters/smtp.js.map +1 -0
  20. package/dist/src/adapters/vault-provider.js +161 -0
  21. package/dist/src/adapters/vault-provider.js.map +1 -0
  22. package/dist/src/channels/batch.js +121 -0
  23. package/dist/src/channels/batch.js.map +1 -0
  24. package/dist/src/channels/cli.js +468 -0
  25. package/dist/src/channels/cli.js.map +1 -0
  26. package/dist/src/channels/conformance.js +445 -0
  27. package/dist/src/channels/conformance.js.map +1 -0
  28. package/dist/src/channels/contract.js +494 -0
  29. package/dist/src/channels/contract.js.map +1 -0
  30. package/dist/src/channels/payload-view.js +43 -0
  31. package/dist/src/channels/payload-view.js.map +1 -0
  32. package/dist/src/channels/render-queue.js +564 -0
  33. package/dist/src/channels/render-queue.js.map +1 -0
  34. package/dist/src/channels/tagging.js +723 -0
  35. package/dist/src/channels/tagging.js.map +1 -0
  36. package/dist/src/channels/telegram.js +3190 -0
  37. package/dist/src/channels/telegram.js.map +1 -0
  38. package/dist/src/channels/web.js +903 -0
  39. package/dist/src/channels/web.js.map +1 -0
  40. package/dist/src/cli/adapter.js +278 -0
  41. package/dist/src/cli/adapter.js.map +1 -0
  42. package/dist/src/cli/amend.js +2171 -0
  43. package/dist/src/cli/amend.js.map +1 -0
  44. package/dist/src/cli/args.js +86 -0
  45. package/dist/src/cli/args.js.map +1 -0
  46. package/dist/src/cli/attest.js +307 -0
  47. package/dist/src/cli/attest.js.map +1 -0
  48. package/dist/src/cli/audit-card.js +201 -0
  49. package/dist/src/cli/audit-card.js.map +1 -0
  50. package/dist/src/cli/audit.js +460 -0
  51. package/dist/src/cli/audit.js.map +1 -0
  52. package/dist/src/cli/channel-telegram.js +2063 -0
  53. package/dist/src/cli/channel-telegram.js.map +1 -0
  54. package/dist/src/cli/channel-web.js +357 -0
  55. package/dist/src/cli/channel-web.js.map +1 -0
  56. package/dist/src/cli/channel.js +438 -0
  57. package/dist/src/cli/channel.js.map +1 -0
  58. package/dist/src/cli/checkpoint-tap.js +238 -0
  59. package/dist/src/cli/checkpoint-tap.js.map +1 -0
  60. package/dist/src/cli/coverage.js +343 -0
  61. package/dist/src/cli/coverage.js.map +1 -0
  62. package/dist/src/cli/daemon.js +631 -0
  63. package/dist/src/cli/daemon.js.map +1 -0
  64. package/dist/src/cli/doctor.js +2648 -0
  65. package/dist/src/cli/doctor.js.map +1 -0
  66. package/dist/src/cli/env.js +302 -0
  67. package/dist/src/cli/env.js.map +1 -0
  68. package/dist/src/cli/execute.js +1682 -0
  69. package/dist/src/cli/execute.js.map +1 -0
  70. package/dist/src/cli/exit-codes.js +82 -0
  71. package/dist/src/cli/exit-codes.js.map +1 -0
  72. package/dist/src/cli/feedback.js +205 -0
  73. package/dist/src/cli/feedback.js.map +1 -0
  74. package/dist/src/cli/gate-window.js +294 -0
  75. package/dist/src/cli/gate-window.js.map +1 -0
  76. package/dist/src/cli/gate.js +557 -0
  77. package/dist/src/cli/gate.js.map +1 -0
  78. package/dist/src/cli/git-scope.js +295 -0
  79. package/dist/src/cli/git-scope.js.map +1 -0
  80. package/dist/src/cli/gloss-attach.js +107 -0
  81. package/dist/src/cli/gloss-attach.js.map +1 -0
  82. package/dist/src/cli/gloss-codex-child.js +149 -0
  83. package/dist/src/cli/gloss-codex-child.js.map +1 -0
  84. package/dist/src/cli/gloss-codex.js +255 -0
  85. package/dist/src/cli/gloss-codex.js.map +1 -0
  86. package/dist/src/cli/gloss-options.js +79 -0
  87. package/dist/src/cli/gloss-options.js.map +1 -0
  88. package/dist/src/cli/gloss.js +362 -0
  89. package/dist/src/cli/gloss.js.map +1 -0
  90. package/dist/src/cli/help.js +2217 -0
  91. package/dist/src/cli/help.js.map +1 -0
  92. package/dist/src/cli/hook.js +2743 -0
  93. package/dist/src/cli/hook.js.map +1 -0
  94. package/dist/src/cli/import.js +175 -0
  95. package/dist/src/cli/import.js.map +1 -0
  96. package/dist/src/cli/init.js +336 -0
  97. package/dist/src/cli/init.js.map +1 -0
  98. package/dist/src/cli/instructions.js +262 -0
  99. package/dist/src/cli/instructions.js.map +1 -0
  100. package/dist/src/cli/journal.js +238 -0
  101. package/dist/src/cli/journal.js.map +1 -0
  102. package/dist/src/cli/log-advance.js +749 -0
  103. package/dist/src/cli/log-advance.js.map +1 -0
  104. package/dist/src/cli/log-anchor.js +387 -0
  105. package/dist/src/cli/log-anchor.js.map +1 -0
  106. package/dist/src/cli/log-checkpoint.js +128 -0
  107. package/dist/src/cli/log-checkpoint.js.map +1 -0
  108. package/dist/src/cli/log-sync.js +849 -0
  109. package/dist/src/cli/log-sync.js.map +1 -0
  110. package/dist/src/cli/log-verbs.js +354 -0
  111. package/dist/src/cli/log-verbs.js.map +1 -0
  112. package/dist/src/cli/long-help.js +148 -0
  113. package/dist/src/cli/long-help.js.map +1 -0
  114. package/dist/src/cli/main.js +1056 -0
  115. package/dist/src/cli/main.js.map +1 -0
  116. package/dist/src/cli/mcp.js +306 -0
  117. package/dist/src/cli/mcp.js.map +1 -0
  118. package/dist/src/cli/paths.js +79 -0
  119. package/dist/src/cli/paths.js.map +1 -0
  120. package/dist/src/cli/payload.js +253 -0
  121. package/dist/src/cli/payload.js.map +1 -0
  122. package/dist/src/cli/policy.js +229 -0
  123. package/dist/src/cli/policy.js.map +1 -0
  124. package/dist/src/cli/preflight.js +888 -0
  125. package/dist/src/cli/preflight.js.map +1 -0
  126. package/dist/src/cli/progress.js +112 -0
  127. package/dist/src/cli/progress.js.map +1 -0
  128. package/dist/src/cli/prompt.js +312 -0
  129. package/dist/src/cli/prompt.js.map +1 -0
  130. package/dist/src/cli/records.js +66 -0
  131. package/dist/src/cli/records.js.map +1 -0
  132. package/dist/src/cli/render.js +132 -0
  133. package/dist/src/cli/render.js.map +1 -0
  134. package/dist/src/cli/sandbox.js +150 -0
  135. package/dist/src/cli/sandbox.js.map +1 -0
  136. package/dist/src/cli/scaffold.js +137 -0
  137. package/dist/src/cli/scaffold.js.map +1 -0
  138. package/dist/src/cli/setup-adapter.js +475 -0
  139. package/dist/src/cli/setup-adapter.js.map +1 -0
  140. package/dist/src/cli/setup-channel.js +635 -0
  141. package/dist/src/cli/setup-channel.js.map +1 -0
  142. package/dist/src/cli/setup-checkpoint.js +196 -0
  143. package/dist/src/cli/setup-checkpoint.js.map +1 -0
  144. package/dist/src/cli/setup-common.js +376 -0
  145. package/dist/src/cli/setup-common.js.map +1 -0
  146. package/dist/src/cli/setup-flow.js +476 -0
  147. package/dist/src/cli/setup-flow.js.map +1 -0
  148. package/dist/src/cli/setup-service.js +308 -0
  149. package/dist/src/cli/setup-service.js.map +1 -0
  150. package/dist/src/cli/setup.js +473 -0
  151. package/dist/src/cli/setup.js.map +1 -0
  152. package/dist/src/cli/style.js +469 -0
  153. package/dist/src/cli/style.js.map +1 -0
  154. package/dist/src/cli/token.js +274 -0
  155. package/dist/src/cli/token.js.map +1 -0
  156. package/dist/src/cli/up.js +847 -0
  157. package/dist/src/cli/up.js.map +1 -0
  158. package/dist/src/cli/usage.js +91 -0
  159. package/dist/src/cli/usage.js.map +1 -0
  160. package/dist/src/cli/values.js +189 -0
  161. package/dist/src/cli/values.js.map +1 -0
  162. package/dist/src/cli/vault.js +362 -0
  163. package/dist/src/cli/vault.js.map +1 -0
  164. package/dist/src/cli/verb-registry.js +2173 -0
  165. package/dist/src/cli/verb-registry.js.map +1 -0
  166. package/dist/src/cli/wordmark.js +52 -0
  167. package/dist/src/cli/wordmark.js.map +1 -0
  168. package/dist/src/core/advance-cycle.js +200 -0
  169. package/dist/src/core/advance-cycle.js.map +1 -0
  170. package/dist/src/core/agents-md.js +747 -0
  171. package/dist/src/core/agents-md.js.map +1 -0
  172. package/dist/src/core/attest.js +577 -0
  173. package/dist/src/core/attest.js.map +1 -0
  174. package/dist/src/core/audit.js +882 -0
  175. package/dist/src/core/audit.js.map +1 -0
  176. package/dist/src/core/budgets.js +449 -0
  177. package/dist/src/core/budgets.js.map +1 -0
  178. package/dist/src/core/checkpoint.js +738 -0
  179. package/dist/src/core/checkpoint.js.map +1 -0
  180. package/dist/src/core/child-env.js +86 -0
  181. package/dist/src/core/child-env.js.map +1 -0
  182. package/dist/src/core/clock.js +43 -0
  183. package/dist/src/core/clock.js.map +1 -0
  184. package/dist/src/core/command-class.js +2321 -0
  185. package/dist/src/core/command-class.js.map +1 -0
  186. package/dist/src/core/coverage-sources/adapter.js +71 -0
  187. package/dist/src/core/coverage-sources/adapter.js.map +1 -0
  188. package/dist/src/core/coverage-sources/gh.js +136 -0
  189. package/dist/src/core/coverage-sources/gh.js.map +1 -0
  190. package/dist/src/core/coverage-sources/git.js +269 -0
  191. package/dist/src/core/coverage-sources/git.js.map +1 -0
  192. package/dist/src/core/coverage.js +337 -0
  193. package/dist/src/core/coverage.js.map +1 -0
  194. package/dist/src/core/credential-spec.js +23 -0
  195. package/dist/src/core/credential-spec.js.map +1 -0
  196. package/dist/src/core/dark-session.js +714 -0
  197. package/dist/src/core/dark-session.js.map +1 -0
  198. package/dist/src/core/decision-refusal.js +265 -0
  199. package/dist/src/core/decision-refusal.js.map +1 -0
  200. package/dist/src/core/env-file.js +837 -0
  201. package/dist/src/core/env-file.js.map +1 -0
  202. package/dist/src/core/execute.js +1233 -0
  203. package/dist/src/core/execute.js.map +1 -0
  204. package/dist/src/core/frontmatter.js +100 -0
  205. package/dist/src/core/frontmatter.js.map +1 -0
  206. package/dist/src/core/gate-window.js +506 -0
  207. package/dist/src/core/gate-window.js.map +1 -0
  208. package/dist/src/core/gate.js +2947 -0
  209. package/dist/src/core/gate.js.map +1 -0
  210. package/dist/src/core/git-run.js +93 -0
  211. package/dist/src/core/git-run.js.map +1 -0
  212. package/dist/src/core/harness-version.js +210 -0
  213. package/dist/src/core/harness-version.js.map +1 -0
  214. package/dist/src/core/harness-wait.js +58 -0
  215. package/dist/src/core/harness-wait.js.map +1 -0
  216. package/dist/src/core/head-retry.js +121 -0
  217. package/dist/src/core/head-retry.js.map +1 -0
  218. package/dist/src/core/instance.js +319 -0
  219. package/dist/src/core/instance.js.map +1 -0
  220. package/dist/src/core/intake-limits.js +350 -0
  221. package/dist/src/core/intake-limits.js.map +1 -0
  222. package/dist/src/core/jcs.js +132 -0
  223. package/dist/src/core/jcs.js.map +1 -0
  224. package/dist/src/core/journal.js +200 -0
  225. package/dist/src/core/journal.js.map +1 -0
  226. package/dist/src/core/live-draw.js +703 -0
  227. package/dist/src/core/live-draw.js.map +1 -0
  228. package/dist/src/core/log-reconcile.js +136 -0
  229. package/dist/src/core/log-reconcile.js.map +1 -0
  230. package/dist/src/core/log.js +546 -0
  231. package/dist/src/core/log.js.map +1 -0
  232. package/dist/src/core/loop.js +476 -0
  233. package/dist/src/core/loop.js.map +1 -0
  234. package/dist/src/core/md-fence.js +74 -0
  235. package/dist/src/core/md-fence.js.map +1 -0
  236. package/dist/src/core/money.js +195 -0
  237. package/dist/src/core/money.js.map +1 -0
  238. package/dist/src/core/payload-census.js +146 -0
  239. package/dist/src/core/payload-census.js.map +1 -0
  240. package/dist/src/core/payload-store.js +340 -0
  241. package/dist/src/core/payload-store.js.map +1 -0
  242. package/dist/src/core/payload.js +80 -0
  243. package/dist/src/core/payload.js.map +1 -0
  244. package/dist/src/core/policy-diff.js +565 -0
  245. package/dist/src/core/policy-diff.js.map +1 -0
  246. package/dist/src/core/policy-expectations.js +394 -0
  247. package/dist/src/core/policy-expectations.js.map +1 -0
  248. package/dist/src/core/policy-explain.js +230 -0
  249. package/dist/src/core/policy-explain.js.map +1 -0
  250. package/dist/src/core/policy-load.js +524 -0
  251. package/dist/src/core/policy-load.js.map +1 -0
  252. package/dist/src/core/policy-match.js +467 -0
  253. package/dist/src/core/policy-match.js.map +1 -0
  254. package/dist/src/core/policy-proposal.js +458 -0
  255. package/dist/src/core/policy-proposal.js.map +1 -0
  256. package/dist/src/core/prompt-layout.js +422 -0
  257. package/dist/src/core/prompt-layout.js.map +1 -0
  258. package/dist/src/core/protected-path-guard.js +1087 -0
  259. package/dist/src/core/protected-path-guard.js.map +1 -0
  260. package/dist/src/core/registration.js +39 -0
  261. package/dist/src/core/registration.js.map +1 -0
  262. package/dist/src/core/reindex.js +336 -0
  263. package/dist/src/core/reindex.js.map +1 -0
  264. package/dist/src/core/sampler.js +388 -0
  265. package/dist/src/core/sampler.js.map +1 -0
  266. package/dist/src/core/sandbox.js +424 -0
  267. package/dist/src/core/sandbox.js.map +1 -0
  268. package/dist/src/core/seal.js +290 -0
  269. package/dist/src/core/seal.js.map +1 -0
  270. package/dist/src/core/state.js +1009 -0
  271. package/dist/src/core/state.js.map +1 -0
  272. package/dist/src/core/task-file.js +464 -0
  273. package/dist/src/core/task-file.js.map +1 -0
  274. package/dist/src/core/telegram-config.js +114 -0
  275. package/dist/src/core/telegram-config.js.map +1 -0
  276. package/dist/src/core/token.js +578 -0
  277. package/dist/src/core/token.js.map +1 -0
  278. package/dist/src/core/validate.js +0 -0
  279. package/dist/src/core/validate.js.map +1 -0
  280. package/dist/src/core/values.js +153 -0
  281. package/dist/src/core/values.js.map +1 -0
  282. package/dist/src/core/vault.js +612 -0
  283. package/dist/src/core/vault.js.map +1 -0
  284. package/dist/src/core/verified-snapshot.js +506 -0
  285. package/dist/src/core/verified-snapshot.js.map +1 -0
  286. package/dist/src/core/verify.js +549 -0
  287. package/dist/src/core/verify.js.map +1 -0
  288. package/dist/src/core/version.js +9 -0
  289. package/dist/src/core/version.js.map +1 -0
  290. package/dist/src/core/wysiwys.js +728 -0
  291. package/dist/src/core/wysiwys.js.map +1 -0
  292. package/dist/src/daemon/advance-child.js +78 -0
  293. package/dist/src/daemon/advance-child.js.map +1 -0
  294. package/dist/src/daemon/advance.js +849 -0
  295. package/dist/src/daemon/advance.js.map +1 -0
  296. package/dist/src/daemon/audit.js +90 -0
  297. package/dist/src/daemon/audit.js.map +1 -0
  298. package/dist/src/daemon/daemon.js +1988 -0
  299. package/dist/src/daemon/daemon.js.map +1 -0
  300. package/dist/src/daemon/dark-session.js +119 -0
  301. package/dist/src/daemon/dark-session.js.map +1 -0
  302. package/dist/src/daemon/draw-child.js +132 -0
  303. package/dist/src/daemon/draw-child.js.map +1 -0
  304. package/dist/src/daemon/draw.js +458 -0
  305. package/dist/src/daemon/draw.js.map +1 -0
  306. package/dist/src/daemon/git-evidence.js +345 -0
  307. package/dist/src/daemon/git-evidence.js.map +1 -0
  308. package/dist/src/daemon/projection.js +233 -0
  309. package/dist/src/daemon/projection.js.map +1 -0
  310. package/dist/src/daemon/prune.js +376 -0
  311. package/dist/src/daemon/prune.js.map +1 -0
  312. package/dist/src/mcp/http.js +343 -0
  313. package/dist/src/mcp/http.js.map +1 -0
  314. package/dist/src/mcp/server.js +594 -0
  315. package/dist/src/mcp/server.js.map +1 -0
  316. package/docs/cli-reference.md +5363 -0
  317. package/package.json +43 -4
  318. package/schema/.gitkeep +0 -0
  319. package/schema/LICENSE +117 -0
  320. package/schema/envelope.schema.json +137 -0
  321. package/schema/event.schema.json +1810 -0
  322. package/schema/fixtures/envelope/invalid/action-missing-idempotency-key.json +15 -0
  323. package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
  324. package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
  325. package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
  326. package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
  327. package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
  328. package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
  329. package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
  330. package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
  331. package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
  332. package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
  333. package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
  334. package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
  335. package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
  336. package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
  337. package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
  338. package/schema/fixtures/envelope/valid/canonical.json +25 -0
  339. package/schema/fixtures/envelope/valid/minimal.json +7 -0
  340. package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
  341. package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
  342. package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
  343. package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
  344. package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
  345. package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
  346. package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
  347. package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
  348. package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
  349. package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
  350. package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
  351. package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
  352. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
  353. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
  354. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
  355. package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
  356. package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
  357. package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
  358. package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
  359. package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
  360. package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
  361. package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
  362. package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
  363. package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
  364. package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
  365. package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
  366. package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
  367. package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
  368. package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
  369. package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
  370. package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
  371. package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
  372. package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
  373. package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
  374. package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
  375. package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
  376. package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
  377. package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
  378. package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
  379. package/schema/fixtures/event/invalid/missing-alg.json +14 -0
  380. package/schema/fixtures/event/invalid/missing-hash.json +14 -0
  381. package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
  382. package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
  383. package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
  384. package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
  385. package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
  386. package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
  387. package/schema/fixtures/event/invalid/short-hash.json +15 -0
  388. package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
  389. package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
  390. package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
  391. package/schema/fixtures/event/valid/approval-expired.json +15 -0
  392. package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
  393. package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
  394. package/schema/fixtures/event/valid/approval-granted.json +15 -0
  395. package/schema/fixtures/event/valid/approval-rejected.json +15 -0
  396. package/schema/fixtures/event/valid/approval-requested.json +19 -0
  397. package/schema/fixtures/event/valid/approval-revoked.json +15 -0
  398. package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
  399. package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
  400. package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
  401. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
  402. package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
  403. package/schema/fixtures/event/valid/audit-sampled.json +14 -0
  404. package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
  405. package/schema/fixtures/event/valid/envelope-drift.json +16 -0
  406. package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
  407. package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
  408. package/schema/fixtures/event/valid/execution-completed.json +15 -0
  409. package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
  410. package/schema/fixtures/event/valid/execution-failed.json +15 -0
  411. package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
  412. package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
  413. package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
  414. package/schema/fixtures/event/valid/execution-started.json +14 -0
  415. package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
  416. package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
  417. package/schema/fixtures/event/valid/gate-closed.json +14 -0
  418. package/schema/fixtures/event/valid/gate-opened.json +16 -0
  419. package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
  420. package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
  421. package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
  422. package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
  423. package/schema/fixtures/event/valid/payload-pruned.json +17 -0
  424. package/schema/fixtures/event/valid/policy-declined.json +16 -0
  425. package/schema/fixtures/event/valid/policy-proposed.json +35 -0
  426. package/schema/fixtures/event/valid/policy-updated.json +14 -0
  427. package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
  428. package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
  429. package/schema/fixtures/event/valid/route-accepted.json +15 -0
  430. package/schema/fixtures/event/valid/route-proposed.json +16 -0
  431. package/schema/fixtures/event/valid/spec-example.json +15 -0
  432. package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
  433. package/schema/fixtures/event/valid/task-registered.json +14 -0
  434. package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
  435. package/schema/fixtures/hash/known-answer.json +74 -0
  436. package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
  437. package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
  438. package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
  439. package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
  440. package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
  441. package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
  442. package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
  443. package/schema/fixtures/policy/invalid/missing-version.json +8 -0
  444. package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
  445. package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
  446. package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
  447. package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
  448. package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
  449. package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
  450. package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
  451. package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
  452. package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
  453. package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
  454. package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
  455. package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
  456. package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
  457. package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
  458. package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
  459. package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
  460. package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
  461. package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
  462. package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
  463. package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
  464. package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
  465. package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
  466. package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
  467. package/schema/fixtures/policy/valid/canonical.json +47 -0
  468. package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
  469. package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
  470. package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
  471. package/schema/fixtures/policy/valid/global-budgets.json +19 -0
  472. package/schema/fixtures/policy/valid/human-only.json +9 -0
  473. package/schema/fixtures/policy/valid/minimal.json +6 -0
  474. package/schema/fixtures/policy/valid/protected-paths.json +10 -0
  475. package/schema/fixtures/policy/valid/record-namespace.json +13 -0
  476. package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
  477. package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
  478. package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
  479. package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
  480. package/schema/fixtures/policy/valid/wildcards.json +15 -0
  481. package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
  482. package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
  483. package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
  484. package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
  485. package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
  486. package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
  487. package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
  488. package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
  489. package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
  490. package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
  491. package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
  492. package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
  493. package/schema/fixtures/policy-md/valid/canonical.md +50 -0
  494. package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
  495. package/schema/fixtures/policy-md/valid/minimal.md +3 -0
  496. package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
  497. package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
  498. package/schema/fixtures/policy-md/valid/with-values.md +79 -0
  499. package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
  500. package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
  501. package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
  502. package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
  503. package/schema/fixtures/sample-record/valid/minimal.json +4 -0
  504. package/schema/fixtures/sample-record/valid/with-note.json +5 -0
  505. package/schema/fixtures/values/invalid/class-shaped.json +9 -0
  506. package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
  507. package/schema/fixtures/values/invalid/non-string-item.json +4 -0
  508. package/schema/fixtures/values/invalid/over-cap.json +26 -0
  509. package/schema/fixtures/values/invalid/unknown-key.json +5 -0
  510. package/schema/fixtures/values/invalid/version-string.json +1 -0
  511. package/schema/fixtures/values/valid/empty-lists.json +7 -0
  512. package/schema/fixtures/values/valid/full.json +20 -0
  513. package/schema/fixtures/values/valid/minimal.json +1 -0
  514. package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
  515. package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
  516. package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
  517. package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
  518. package/schema/fixtures/values-md/valid/absent.md +50 -0
  519. package/schema/fixtures/values-md/valid/with-values.md +79 -0
  520. package/schema/policy.schema.json +481 -0
  521. package/schema/sample-record.schema.json +26 -0
  522. package/schema/values.schema.json +55 -0
@@ -0,0 +1,2947 @@
1
+ /**
2
+ * The gate: request lifecycle and write-boundary transition enforcement
3
+ * (SPEC.md §6.3, §7, §10.1).
4
+ *
5
+ * This is the module that decides whether a side effect may be authorized, and
6
+ * it is the only module that appends approval lifecycle events. Everything it
7
+ * knows it derives from the append-only log; everything it decides it decides
8
+ * before a byte is written.
9
+ *
10
+ * ## Four rules this module exists to enforce
11
+ *
12
+ * 1. **State is derived, never stored.** {@link requestState} rebuilds one
13
+ * action's approval state from the log alone. There is no status field, no
14
+ * cache, no in-memory session. The envelope's `state:` key is a projection
15
+ * written by the daemon *after* the event lands (SPEC.md §6.3), never a
16
+ * source this module reads.
17
+ * 2. **Illegal transitions are refused before append.** A second grant, a grant
18
+ * on a rejected request, a revoke of an executed action, a decision after the
19
+ * TTL — each is refused with its own machine-readable code and **nothing is
20
+ * appended**. The one deliberate exception is a failed budget check, which
21
+ * appends `budget.exceeded` *and then* refuses: a budget refusal is a fact
22
+ * about the world that an operator must be able to see afterwards, and a
23
+ * refusal nobody can audit is how quiet budget creep starts.
24
+ * 3. **No approval events off the manual path** (amended SPEC.md §6.3). An
25
+ * action whose class resolves to `supervised` or `autonomous` produces *no*
26
+ * `approval.*` record at all — {@link request} returns `proceed: true` and
27
+ * appends nothing. Its authorization is recorded by `execution.started`,
28
+ * which APRV-18 appends, and which is also where its budget is charged (see
29
+ * the consumption contract in `core/budgets.ts`).
30
+ * 4. **Time is assigned by the runtime, not by the caller** (amended SPEC.md
31
+ * §8, A2). No public function here takes a `ts`. TTL lapse, budget windows,
32
+ * and the timestamp stamped on every append all come from one read of
33
+ * {@link GateOptions.clock} — the real clock unless a caller injects one —
34
+ * made once per operation, so a gate decision is still replayable from its
35
+ * inputs while the party being judged no longer authors the clock it is
36
+ * judged by. Tests inject a fixed clock; production passes none.
37
+ *
38
+ * ## Lazy expiry — the named requirement
39
+ *
40
+ * A request expires when `ts > requestTs + defaults.approval_ttl`, **whether or
41
+ * not** an `approval.expired` event exists. Nothing may depend on a daemon
42
+ * having run: if the expiry sweep is asleep, a late grant must still be refused.
43
+ * {@link requestState} therefore computes expiry two ways — from the event, and
44
+ * lazily from the arithmetic — and treats them as equivalent.
45
+ *
46
+ * When {@link decide} refuses a decision because the TTL has lapsed and no
47
+ * `approval.expired` event exists yet, it **first appends that event** (actor
48
+ * {@link EXPIRY_ACTOR}) and then refuses. The alternative — refuse silently and
49
+ * leave the log claiming the request is still live — was rejected: the log is
50
+ * the truth, and a state every reader can derive but no reader can see recorded
51
+ * makes the log disagree with itself. The append is the same one
52
+ * {@link expire} would have made, so a later sweep is a no-op rather than a
53
+ * duplicate.
54
+ *
55
+ * ## `defaults.on_expiry`
56
+ *
57
+ * SPEC.md §5 defines exactly one value, `reject`. An expired request is
58
+ * terminal here under either setting: no grant, no reject, no revoke ever
59
+ * follows it. `on_expiry` is recorded in the `approval.expired` payload so the
60
+ * projection layer (M5) can render the envelope's `state:` as `rejected` rather
61
+ * than `expired` when the policy asks for it. Re-requesting the same action key
62
+ * after expiry is a *new* request and is allowed — the key has not executed, and
63
+ * refusing forever would make a lapsed TTL more punishing than a human's "no".
64
+ *
65
+ * ## The budgets contract (`core/budgets.ts`)
66
+ *
67
+ * That module obligates this one: every `approval.granted` this module appends
68
+ * carries `payload.est_cost_usd` (number, USD) and `payload.class` (the dotted
69
+ * class). `approval.requested` carries them too, so the grant can copy them from
70
+ * the request rather than re-derive them from a file that may have changed. An
71
+ * action that declared no cost is recorded as `0` — an authorization with no
72
+ * declared cost is still an authorization, and still counts as one action.
73
+ *
74
+ * ## Reads are verified, writes are compare-and-append (APRV-20)
75
+ *
76
+ * The gate no longer trusts the bytes it reads. {@link readGateRecords}
77
+ * delegates to `core/state.ts`, which runs the *same* chain verification
78
+ * `approval log verify` runs — one walk, one vocabulary — and refuses
79
+ * `log-corrupt` on anything that does not verify. The gate still does not
80
+ * *diagnose* corruption: it reports that the log is untrustworthy and points at
81
+ * `approval log verify` for the detail, because two modules with two opinions
82
+ * about what "corrupt" means is worse than one.
83
+ *
84
+ * Every append this module makes is authorized by something it read, so every
85
+ * append passes `expectedHead` — the `(seq, hash)` observed at that read. If any
86
+ * record landed in between, `appendEvent` refuses `head-moved` under its lock
87
+ * and nothing is written.
88
+ *
89
+ * Every writer of this module then re-derives and tries again, bounded
90
+ * (APRV-150 for the two harness writers, APRV-236 for {@link register},
91
+ * {@link request}, {@link decide}, {@link withdraw} and
92
+ * {@link finishHarnessExecution}): see {@link withHeadMovedRetry} and
93
+ * `core/head-retry.ts` for why a lost race is not a verdict, and why the retry
94
+ * is a new read plus new checks plus a new compare-and-append rather than a
95
+ * second attempt at the same write. {@link expire} is the one exception, and it
96
+ * needs none: it is materialisation the daemon's next tick performs again.
97
+ *
98
+ * It does not define execution tokens — `core/token.ts` does. {@link decide}'s
99
+ * grant path calls that module's `mintToken` at the seam APRV-17 documented,
100
+ * records only the digest in the `approval.granted` payload, and returns the raw
101
+ * token to its caller. {@link decide} still appends no `execution.*` event:
102
+ * spending a token is `core/token.ts`'s `consumeToken`.
103
+ *
104
+ * The one place this module writes an execution event is
105
+ * {@link consumeHarnessGrant} (APRV-117), and it is the exception that proves
106
+ * the rule: a harness grant mints no token, so nothing else in the system could
107
+ * record that it had been spent, and an authorization with no record of its
108
+ * spending is an authorization that never runs out. See that function for why
109
+ * the marker is `execution.started` and why no completion ever follows it.
110
+ */
111
+ import { existsSync, readFileSync } from "node:fs";
112
+ import { basename, join } from "node:path";
113
+ import { ATTESTATION_REFUSAL, attestationRefusal, checkAttestationOfBytes, isPolicySha256, POLICY_HASH_FIELD, unreadablePolicyStatus, } from "./attest.js";
114
+ import { evaluateBudgetsWithTask } from "./budgets.js";
115
+ import { evaluateIntakeLimits, intakeRefusalOf, } from "./intake-limits.js";
116
+ import { tick } from "./clock.js";
117
+ import { readTaskFile } from "./frontmatter.js";
118
+ import {} from "./harness-version.js";
119
+ import { attemptsOf, withHeadRetry } from "./head-retry.js";
120
+ import { appendEvent, } from "./log.js";
121
+ import { HARNESS_TASK_PREFIX, harnessLoopFloor, isLoopEscalated, isSideEffectingClass, loopClearance, } from "./loop.js";
122
+ import { normalizeUsd, usdOrZero } from "./money.js";
123
+ import { isPayloadHash, payloadHash as hashOfPayload } from "./payload.js";
124
+ import { loadPayload, payloadStoreDirFor, storePayload } from "./payload-store.js";
125
+ import { loadPolicyText, policyUnreadable, POLICY_FILENAMES, tokenDeliveryOf, } from "./policy-load.js";
126
+ import { humanOnlyRefusal, resolve } from "./policy-match.js";
127
+ import { DRAW_PROTOCOL_VERSION, askDaemonDraw, } from "./live-draw.js";
128
+ import { LIVE_SELECTION, resolveLiveSelector, } from "./sampler.js";
129
+ import { forgetPrivateKey, isRecipientKey, keyStoreDirFor, mintRecipientKeypair, RECIPIENT_KEY_FIELD, sealToken, SEALED_TOKEN_FIELD, SELF_DELIVERY_FIELD, writePrivateKey, } from "./seal.js";
130
+ import { payloadOf, readVerifiedRecords, requestState, } from "./state.js";
131
+ import { mintToken, tokenHash, TOKEN_HASH_FIELD } from "./token.js";
132
+ import { validate } from "./validate.js";
133
+ import { displayHashOf, DISPLAY_HASH_FIELD } from "./wysiwys.js";
134
+ /**
135
+ * The approval-state derivation moved to `core/state.ts` in APRV-20 (finding
136
+ * S4: `gate.ts` and `token.ts` imported each other). It is re-exported here, its
137
+ * documented home, so every existing importer — the CLI, the tests — is
138
+ * unaffected by the move.
139
+ */
140
+ export { requestState, WITHDRAW_REASONS, isWithdrawReason, } from "./state.js";
141
+ /** Actor stamped on runtime-originated expiry events (SPEC.md §8 `system:`). */
142
+ export const EXPIRY_ACTOR = "system:gate";
143
+ /** Actors permitted to request or register: a person or an agent, never the runtime. */
144
+ const PRINCIPAL_ACTOR = /^(human|agent):.+/u;
145
+ /** Actors permitted to decide. Human-only, in code (SPEC.md §10.1). */
146
+ const HUMAN_ACTOR = /^human:.+/u;
147
+ /**
148
+ * Does an `approvers` list name this actor (APRV-137, amended SPEC.md §5.2)?
149
+ *
150
+ * The spelling a valid policy uses is the bare id (`alice`), which is what
151
+ * `policy.schema.json` admits and what the keys of the top-level `approvers`
152
+ * map are: its `identifier` pattern is lowercase alphanumerics with `_` and
153
+ * `-`, so a `human:` prefix inside a roster is a schema violation and never
154
+ * reaches here. The whole actor string is compared as well, which can only ever
155
+ * match a loader more permissive than the shipped schema; it widens nothing for
156
+ * a valid policy, because `alice` and `human:alice` are the same person under
157
+ * either comparison.
158
+ *
159
+ * Comparison is exact and case-sensitive. An identity that matched under
160
+ * folding would let `human:Alice` and `human:alice` be one approver on one host
161
+ * and two on another, and a roster is a list of people rather than a pattern
162
+ * language. An empty list names nobody and therefore matches nobody;
163
+ * `approvers` carries `minItems: 1`, so a valid policy cannot produce one, and
164
+ * that branch stays a fail-closed backstop rather than a reachable path.
165
+ */
166
+ function namesApprover(approvers, actor) {
167
+ const bare = actor.startsWith("human:") ? actor.slice("human:".length) : actor;
168
+ return approvers.some((name) => name === actor || name === bare);
169
+ }
170
+ /**
171
+ * The closed set of gate refusal codes. Agents branch on these, so the union is
172
+ * frozen public API in the same sense the exit codes are: adding a code is a
173
+ * spec change, redefining one is a breaking change.
174
+ */
175
+ export const GATE_REFUSAL_CODES = [
176
+ /** Policy is unattested or its bytes changed (`core/attest.ts`). */
177
+ "policy-not-attested",
178
+ /**
179
+ * The policy attested now is not the policy the request was routed under
180
+ * (APRV-118, amended SPEC.md §5.2): the hash pinned on `approval.requested`
181
+ * differs from the hash in force at the moment of the grant.
182
+ *
183
+ * Distinct from `policy-not-attested`, and the distinction is the whole point.
184
+ * That code says the live file is unverified; this one says the file is
185
+ * perfectly verified and is a DIFFERENT file from the one that decided this
186
+ * action's autonomy, its limits, and its TTL. A human re-attested in between,
187
+ * so the routing that put the question in front of an approver was computed
188
+ * from rules nobody is enforcing any more, and a grant recorded here would
189
+ * claim a decision under rules the approver never saw. The pending request is
190
+ * void: nothing is appended, and the action is requested again so that it is
191
+ * routed, budgeted, and displayed under the policy actually in force.
192
+ */
193
+ "policy-drift",
194
+ /** The envelope failed `envelope.schema.json`, or the task file has none. */
195
+ "envelope-invalid",
196
+ /** The task file could not be read. */
197
+ "task-file-unreadable",
198
+ /** This task id already has a `task.registered` record. */
199
+ "task-already-registered",
200
+ /**
201
+ * The task has log history and the file no longer carries an envelope
202
+ * (APRV-63).
203
+ *
204
+ * Observed live in APRV-60: a third-party rewrite of a task file dropped the
205
+ * `approval:` key it did not recognize. Without this code the file reads as an
206
+ * ordinary envelope-less task, and a re-registration from a stripped file
207
+ * would narrow the record silently — declaring fewer actions, or none, for a
208
+ * task the log already says declared them. The loss is named instead, and the
209
+ * envelope is restored by a human from the log; nothing here repairs a file.
210
+ */
211
+ "envelope-missing",
212
+ /** No `task.registered` record for this task id. */
213
+ "not-registered",
214
+ /** The task is registered but declares no action with this key (SPEC.md §7). */
215
+ "action-not-registered",
216
+ /** A live `approval.requested` for this action key already exists. */
217
+ "duplicate-request",
218
+ /** The action key already has an `execution.*` record (idempotency). */
219
+ "already-executed",
220
+ /**
221
+ * APRV-14 verdicts failed; a `budget.exceeded` event was appended. Covers
222
+ * class limits, `policy.budgets`, and — since S2 — the registered envelope's
223
+ * own `budget.max_cost_usd`, which appears as a `task`-scoped verdict in
224
+ * `verdicts` and in the appended event's payload.
225
+ */
226
+ "budget-exceeded",
227
+ /**
228
+ * The approver's queue is at the ceiling the policy declared (SPEC.md §5.2's
229
+ * `limits.max_pending`, per class or on a `budgets` scope; APRV-173).
230
+ *
231
+ * A limit on ATTENTION rather than on money, which is why it is its own code
232
+ * and why it fires where it does: after the legality checks that say whether
233
+ * this request may exist at all, and before budgets, which are about the
234
+ * world's exposure rather than the human's. An agent that floods the queue
235
+ * with cheap in-budget requests spends nothing and still defeats the gate,
236
+ * because an approver facing two hundred prompts stops reading them and
237
+ * starts clearing them.
238
+ *
239
+ * Nothing is appended, deliberately, and this is the one refusal shaped
240
+ * differently from `budget-exceeded` on purpose (Carter's approved reading,
241
+ * 2026-08-31). A `budget.exceeded` record exists because a budget refusal is
242
+ * a fact about a commitment audit must be able to reconstruct; a record per
243
+ * refused flood request would hand the flooder the log growth it was refused
244
+ * the queue for. `error.limits` carries the failing verdicts, and the
245
+ * requests that WERE admitted are all in the log to count from.
246
+ *
247
+ * Transient in the sense that matters to a caller: the queue drains when a
248
+ * human decides, a requester withdraws, or a TTL lapses. Retrying at once
249
+ * gets the same answer.
250
+ */
251
+ "queue-full",
252
+ /**
253
+ * This origin created more requests in the last hour than the policy's
254
+ * `limits.requests_per_hour` allows (SPEC.md §5.2, APRV-173).
255
+ *
256
+ * Distinct from `queue-full`, and the distinction is the repair. That code
257
+ * says the queue is full whoever is asking, so the caller waits for an
258
+ * approver; this one says the caller's own recent volume is the problem, so
259
+ * it slows down. Origin is the requesting actor at v0.1, which the runtime
260
+ * assigns rather than the caller (see `core/intake-limits.ts`), so a
261
+ * requester cannot re-label itself into a fresh hour.
262
+ *
263
+ * Counted over request CREATION, not over live requests: a request that was
264
+ * answered a minute after it was made still spent the origin's share of the
265
+ * hour. A ceiling that forgot each request as it was answered could be
266
+ * cleared by withdrawing every request as fast as it was made.
267
+ *
268
+ * Nothing is appended, for the same reason `queue-full` appends nothing.
269
+ */
270
+ "rate-limited",
271
+ /**
272
+ * The action resolves to `manual` and its registered declaration carries no
273
+ * `payload_hash` (amended SPEC.md §6.2: MUST for `manual` actions).
274
+ *
275
+ * Enforced here rather than in `envelope.schema.json` because the schema
276
+ * cannot know an action's resolved autonomy — that answer depends on the
277
+ * policy, the irreversibility floor, and the class, none of which the
278
+ * envelope alone determines. A manual action with nothing to bind to would
279
+ * give a human a decision about bytes nobody committed to, so intake refuses
280
+ * and nothing is appended.
281
+ *
282
+ * Since APRV-146 the same code answers the same fact at the harness write
283
+ * boundary: {@link startHarnessExecution} refuses a start that names no
284
+ * payload hash, and {@link consumeHarnessGrant} refuses a spend that presents
285
+ * none (or a grant whose request recorded none). The fact is identical at both
286
+ * ends — a binding is required here and there is none — and the repair is the
287
+ * same shape: state the bytes, or request the action again so the record does.
288
+ * `payload-mismatch` stays the code for bytes that are stated and wrong.
289
+ */
290
+ "payload-hash-required",
291
+ /**
292
+ * Payload material was supplied at intake and does not hash to the
293
+ * `payload_hash` the registration declared (APRV-28).
294
+ *
295
+ * The same code, and the same reason, as `core/token.ts`'s refusal at spend
296
+ * time: a grant approves specific bytes, so material that hashes to something
297
+ * else is not the payload this request is about. Refused before anything is
298
+ * stored and before anything is appended.
299
+ */
300
+ "payload-mismatch",
301
+ /**
302
+ * The declared payload material could not be stored (APRV-28): it cannot be
303
+ * canonicalized, or the store directory could not be written.
304
+ *
305
+ * Fails closed rather than requesting anyway. A manual request whose bytes no
306
+ * channel can display is a request no human can answer — SPEC.md §10.4 —
307
+ * so intake refuses and the log is left untouched.
308
+ */
309
+ "payload-store-failed",
310
+ /**
311
+ * A grant was attempted on a request whose payload carries no usable `class`.
312
+ *
313
+ * Its own code since APRV-20 pass two: the previous behavior substituted the
314
+ * empty string and granted anyway, which recorded an authorization that no
315
+ * class-scoped budget could ever charge and no policy rule could ever match.
316
+ * Fail closed and say which fact was missing.
317
+ */
318
+ "grant-classless-request",
319
+ /**
320
+ * The action's class resolves to `human-only` (APRV-185, amended SPEC.md
321
+ * §5.2): the policy reserves it to human hands, and a person performs it
322
+ * outside agent execution entirely.
323
+ *
324
+ * Its own code, and distinct from every rejection, because nobody decided
325
+ * anything. A `reject` is a human's answer to a question that was legitimately
326
+ * asked; this is the policy answering that the question does not arise — there
327
+ * is no approval to seek, no approver to ask, and no grant that could be
328
+ * recorded. An agent that read a rejection would sensibly try again with a
329
+ * better summary; an agent that reads this must stop asking and hand the
330
+ * action to a person.
331
+ *
332
+ * Every verb of this module that could mint or withdraw authority returns it:
333
+ * {@link request}, {@link decide} in all three of its decisions, and
334
+ * {@link consumeHarnessGrant}. Grant is the obvious one. Reject and revoke are
335
+ * refused too, and the reason is stated plainly rather than assumed: those
336
+ * verbs WITHDRAW authority, and withdrawing authority that cannot exist would
337
+ * write a decision record about a human-only class into the log, which reads
338
+ * afterwards as a class the gate transacts in. A pending request that a policy
339
+ * amendment has since raised to `human-only` is not stranded by that: it
340
+ * authorizes nothing, no token can be minted for it and no run can spend it,
341
+ * and its requester withdraws it (`withdraw`) or its TTL lapses (`expire`).
342
+ * Neither of those verbs is refused here, deliberately — they are the exits
343
+ * from a question nobody may answer.
344
+ *
345
+ * Evaluated immediately after the check that establishes a request exists at
346
+ * all, and before every other check on the path, on all three verbs. A class
347
+ * that cannot be transacted in is answered before any question about who may
348
+ * decide it, under which policy hash, or against which budget.
349
+ */
350
+ "class-human-only",
351
+ /**
352
+ * Loop safety escalated the task to manual (SPEC.md §10.2, APRV-18): three
353
+ * consecutive `execution.failed` events. Only the non-manual paths are
354
+ * refused — see {@link request}.
355
+ */
356
+ "loop-escalated",
357
+ /**
358
+ * A harness outcome was reported for an action key whose `execution.started`
359
+ * carries no `execution: "harness"` marker (APRV-145).
360
+ *
361
+ * The mirror image of `core/execute.ts`'s `execution-delegated`, and the pair
362
+ * is what keeps the two write surfaces from overlapping by one record. That
363
+ * code refuses a HUMAN recovery verb over a harness start; this one refuses a
364
+ * HARNESS report over a start this runtime is watching itself. An untrusted
365
+ * report that could close an `approval run` execution would be reporting an
366
+ * exit code the runtime was about to observe for itself, and the outcome the
367
+ * log kept would be whichever one landed first.
368
+ */
369
+ "not-delegated",
370
+ /**
371
+ * Every harness-marked start the reported tool call opened already carries an
372
+ * outcome (APRV-145). An execution has exactly one, and a second report would
373
+ * be a second answer about one command — including a `completed` written over
374
+ * a `failed`, which is a streak cleared by repetition rather than by recovery.
375
+ *
376
+ * Named for the fact rather than for the reporter, and spelled exactly as
377
+ * `core/execute.ts` spells the same fact, so a reader who has met one has met
378
+ * both.
379
+ */
380
+ "already-finished",
381
+ /** No request to decide. */
382
+ "not-requested",
383
+ /** The request already has a terminal decision. */
384
+ "already-decided",
385
+ /** Revoke was attempted on a request that is not granted. */
386
+ "not-granted",
387
+ /**
388
+ * A decision was attempted on a request the requester had already withdrawn
389
+ * (APRV-106, amended SPEC.md §6.3).
390
+ *
391
+ * Distinct from `already-decided` because the facts and the repairs are
392
+ * distinct. `already-decided` says a human answered and the answer stands;
393
+ * this one says nobody answered and nobody can — the party that asked has
394
+ * stopped listening, so a grant here would authorize an action no process is
395
+ * waiting to perform. The repair is to request the action again, which is a
396
+ * new request with a new decision, not to try the decision a second time.
397
+ */
398
+ "request-withdrawn",
399
+ /**
400
+ * A withdrawal was attempted by an actor other than the one that appended the
401
+ * matching `approval.requested` (APRV-106).
402
+ *
403
+ * Withdrawal is the requester's own retraction, and nothing more. If any
404
+ * actor could withdraw, then any actor could clear an approver's queue — the
405
+ * queue would become deniable by whoever reached the log first, which is the
406
+ * one property the gate exists to deny. A human who wants a pending request
407
+ * gone rejects it, on the record, as themselves.
408
+ */
409
+ "not-requester",
410
+ /** The TTL lapsed — judged from the request's own ts, event or no event. */
411
+ "expired",
412
+ /** `expire` was called on a request whose TTL has not lapsed. */
413
+ "not-expired",
414
+ /** The actor is not a well-formed `human:`/`agent:` identity. */
415
+ "actor-invalid",
416
+ /** A human-only verb was attempted by a non-human actor. */
417
+ "actor-not-human",
418
+ /**
419
+ * A grant was recorded by a person the resolved rule's `approvers` list does
420
+ * not name (APRV-137, amended SPEC.md §5.2).
421
+ *
422
+ * Distinct from `actor-not-human`, and the distinction is the repair. That
423
+ * code says the actor is not a person at all, and the fix is to run the verb
424
+ * as one. This one says the actor IS a person and is not one the policy
425
+ * named for this class, so the fix is to ask a named approver. Before this
426
+ * code the list was parsed, surfaced by `policy explain`, and enforced
427
+ * nowhere: a policy writing `approvers: [alice]` on `financial.spend` bound
428
+ * nothing while its author believed it bound the class.
429
+ *
430
+ * Scope, and its limits. The check is defense in depth inside the trust
431
+ * boundary §11 states plainly: human identity in v0.1 is config-declared, so
432
+ * anyone who can set that configuration can present any name on this list.
433
+ * What it defends is the honest mistake and the wrong-approver routing, not
434
+ * an actor choosing whose name to wear. The check binds `grant` alone;
435
+ * reject and revoke withdraw authority rather than confer it, and
436
+ * restricting them would leave a request standing, or an authorization live,
437
+ * because the wrong person tried to end it.
438
+ */
439
+ "actor-not-approver",
440
+ /** The log could not be read, or holds a line that is not a record. */
441
+ "log-unreadable",
442
+ /** The log's final line is unterminated (a crashed write). */
443
+ "log-torn-tail",
444
+ /**
445
+ * The chain does not verify (APRV-20 finding S1). Distinct from
446
+ * `log-unreadable`, which is a filesystem fact: this one says the log's own
447
+ * contents contradict each other, so nothing may be authorized from it.
448
+ */
449
+ "log-corrupt",
450
+ /**
451
+ * The rendered semantic diff of a proposed policy is larger than a channel
452
+ * prompt can show whole (APRV-109, amended SPEC.md §10.3).
453
+ *
454
+ * A refusal rather than a truncation, and its own code so a caller can tell
455
+ * "this amendment is too big for a phone" from every other reason a proposal
456
+ * fails. A prompt that showed two thirds of a policy change would collect a
457
+ * signature for the third it did not show; the repair is to read the diff at
458
+ * a terminal and attest there, which the message names.
459
+ */
460
+ "diff-too-large",
461
+ /** No `policy.proposed` record at the named seq (APRV-109). */
462
+ "proposal-not-found",
463
+ /**
464
+ * The policy bytes changed after the attestation prompt was rendered
465
+ * (APRV-109).
466
+ *
467
+ * Distinct from `policy-drift`, which is about a pending approval routed
468
+ * under superseded rules. This one says the human is looking at a hash the
469
+ * file no longer has, so attesting would name bytes the approver was never
470
+ * shown. Nothing is appended and the amendment is proposed again.
471
+ */
472
+ "proposal-stale",
473
+ /**
474
+ * An attestation was proposed for a policy file that already matches its
475
+ * attestation (APRV-109). There is no amendment to sign, and a prompt for one
476
+ * would ask a human to re-attest bytes already in force.
477
+ */
478
+ "policy-already-attested",
479
+ /**
480
+ * A grant carrying `reaction: loved` or `reaction: disliked` and no non-blank
481
+ * note (APRV-239, amended SPEC.md §5.2).
482
+ *
483
+ * Grant only. Evaluated with the other checks that read nothing, and nothing
484
+ * is appended. `reject` and `revoke` accept no reaction at all, which is a
485
+ * usage error at the verb rather than a member of this union: their reason IS
486
+ * their note, and there is no second field for a grade to sit in.
487
+ *
488
+ * Its own code rather than the audit path's `note-required` because a caller
489
+ * branching on a gate refusal is branching on this union, and the two verbs
490
+ * are answered by two different modules. The message names `--note`, which is
491
+ * the whole of the fix.
492
+ */
493
+ "reaction-note-required",
494
+ /**
495
+ * The append itself failed; `append` carries the underlying error. Its
496
+ * `code` is `head-moved` when the log grew between this module's read and its
497
+ * append: every check that authorized the write was made against an older log,
498
+ * so nothing was written. Since APRV-236 this code reaches a caller only after
499
+ * the bounded read-check-append retry is spent (`core/head-retry.ts`), and its
500
+ * message says how many attempts were made. A single lost race is no longer
501
+ * reported at all: it is re-derived, and the answer the fresh log supports is
502
+ * what the caller receives.
503
+ */
504
+ "append-failed",
505
+ /**
506
+ * A `delivery: "self"` request could not publish a delivery address (APRV-211):
507
+ * the ephemeral private key could not be written beside the log.
508
+ *
509
+ * Fail closed, and unlike APRV-105's ordinary sealed path, which drops the
510
+ * convenience and leaves the paste path standing. There is no paste path
511
+ * here — the requester is a process, not a terminal — so a request admitted
512
+ * without an address would spend a human's decision on an authorization
513
+ * nothing can ever open. Nothing is appended; the next attempt asks again.
514
+ */
515
+ "token-delivery-unavailable",
516
+ ];
517
+ function refuse(code, message, extra = {}) {
518
+ return { ok: false, code, message, ...extra };
519
+ }
520
+ /** A read refusal is already one of this module's codes; widen it in place. */
521
+ function fromReadRefusal(refusal) {
522
+ return refuse(refusal.code, refusal.message);
523
+ }
524
+ /**
525
+ * Read the log's records, refusing unless the whole chain verifies.
526
+ *
527
+ * Delegates to `core/state.ts`'s {@link readVerifiedRecords}: since APRV-20
528
+ * (finding S1) the gate does not merely parse the log, it verifies it. A
529
+ * corrupt log refuses `log-corrupt` and authorizes nothing; a torn tail refuses
530
+ * `log-torn-tail`, unchanged, because the repair is a human decision and never a
531
+ * gate's; an unopenable file refuses `log-unreadable`, an I/O fact rather than an
532
+ * accusation.
533
+ *
534
+ * The returned `head` is what every append site here passes as `expectedHead`,
535
+ * so a decision derived from these records cannot land on a log that moved
536
+ * underneath it.
537
+ */
538
+ export function readGateRecords(logPath, schemaDir) {
539
+ const read = readVerifiedRecords(logPath, schemaDir === undefined ? {} : { schemaDir });
540
+ return read.ok ? read : fromReadRefusal(read);
541
+ }
542
+ // ---------------------------------------------------------------------------
543
+ // Policy plumbing
544
+ // ---------------------------------------------------------------------------
545
+ /**
546
+ * The policy file the gate will hash for attestation.
547
+ *
548
+ * `file` wins; otherwise discovery walks `POLICY_FILENAMES` in `dir` exactly as
549
+ * `loadPolicy` does, so the attested file and the enforced file are the same
550
+ * file. When neither exists the first candidate is returned anyway, so
551
+ * `checkAttestation` reports `unreadable` and the gate refuses — a missing
552
+ * policy is never a pass.
553
+ */
554
+ function policyPathOf(options) {
555
+ const policy = options.policy ?? {};
556
+ if (policy.file !== undefined)
557
+ return policy.file;
558
+ const dir = policy.dir ?? process.cwd();
559
+ for (const filename of POLICY_FILENAMES) {
560
+ const candidate = join(dir, filename);
561
+ if (existsSync(candidate))
562
+ return candidate;
563
+ }
564
+ return join(dir, POLICY_FILENAMES[0] ?? "APPROVAL.md");
565
+ }
566
+ /** Read the policy file once, through {@link GateOptions.policy}'s seam. */
567
+ function readPolicyOnce(options) {
568
+ const path = policyPathOf(options);
569
+ const read = options.policy?.read ?? readFileSync;
570
+ try {
571
+ return { path, bytes: read(path), cause: null };
572
+ }
573
+ catch (cause) {
574
+ return {
575
+ path,
576
+ bytes: null,
577
+ cause: cause instanceof Error ? cause.message : String(cause),
578
+ };
579
+ }
580
+ }
581
+ /**
582
+ * Parse the bytes already read, without touching the filesystem again.
583
+ *
584
+ * Fails closed on an unreadable read, exactly as `loadPolicy` would have: the
585
+ * result is a `file-missing` failure, and `resolve` reads that as all-manual.
586
+ */
587
+ function parsePolicy(read, options) {
588
+ if (read.bytes === null) {
589
+ return policyUnreadable(read.path, read.cause ?? "unknown error");
590
+ }
591
+ return loadPolicyText(read.path, Buffer.from(read.bytes).toString("utf8"), options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
592
+ }
593
+ function appendOptionsOf(options) {
594
+ const append = { ...options.append };
595
+ if (options.schemaDir !== undefined)
596
+ append.schemaDir = options.schemaDir;
597
+ return append;
598
+ }
599
+ /**
600
+ * Refuse unless the live policy bytes match the latest attestation, and return
601
+ * the hash they matched (APRV-118).
602
+ *
603
+ * The hash is the same value `approval policy attest` recorded and, since
604
+ * APRV-142, provably the same bytes {@link parsePolicy} parses: both take the
605
+ * one {@link PolicyRead} the operation performed. It names the exact rules this
606
+ * operation is being decided under. Callers pin it onto the event they write:
607
+ * an operation that could not be authorized without an attested policy should
608
+ * say, on the record, which attested policy authorized it.
609
+ */
610
+ function requireAttestation(records, read) {
611
+ const status = read.bytes === null
612
+ ? unreadablePolicyStatus(read.path, read.cause ?? "unknown error")
613
+ : checkAttestationOfBytes(records, read.bytes);
614
+ const refusal = attestationRefusal(status);
615
+ if (refusal !== null) {
616
+ return refuse(ATTESTATION_REFUSAL, refusal.message, { detail: refusal.detail });
617
+ }
618
+ // `attestationRefusal` returns null for exactly one status, and that status
619
+ // is the one carrying the hash.
620
+ return { ok: true, sha256: status.sha256 };
621
+ }
622
+ /** The TTL in force, or `null` when the policy declares (or can declare) none. */
623
+ function ttlOf(load) {
624
+ return load.ok ? load.durations.approvalTtlMs : null;
625
+ }
626
+ function budgetScopeOf(load, resolution) {
627
+ return {
628
+ classLimits: resolution.limits,
629
+ classPattern: resolution.matched === null ? null : resolution.matched.pattern,
630
+ globalBudgets: load.ok ? load.policy.budgets ?? null : null,
631
+ };
632
+ }
633
+ /**
634
+ * The request-volume scope (APRV-173): the same three fields the budget scope
635
+ * carries, read from the same resolution and the same load.
636
+ *
637
+ * Identical by construction rather than by coincidence. A queue ceiling written
638
+ * on a rule must be attributed by that rule's pattern for the reason SPEC.md
639
+ * §5.2 gives budgets: one `financial.*` rule is one ceiling shared by every
640
+ * class it governs, and a limit taken from a rule that did not win would be
641
+ * compared against a window it does not scope. A policy that fails to load
642
+ * offers no limits at all here, exactly as it offers no budgets: everything is
643
+ * `manual` in that case, and the human gate is the ceiling.
644
+ */
645
+ function intakeScopeOf(load, resolution) {
646
+ return {
647
+ classLimits: resolution.limits,
648
+ classPattern: resolution.matched === null ? null : resolution.matched.pattern,
649
+ globalBudgets: load.ok ? load.policy.budgets ?? null : null,
650
+ };
651
+ }
652
+ /**
653
+ * Append one event, with the compare-and-append precondition (APRV-20).
654
+ *
655
+ * `expectedHead` is the head observed at the read that authorized this write.
656
+ * Passing it is not optional at any site here: every gate append is authorized
657
+ * by something read from the log, and an append that skipped the precondition
658
+ * would be exactly the check-then-act race the option exists to close.
659
+ */
660
+ function append(logPath, input, options, expectedHead) {
661
+ const result = appendEvent(logPath, input, { ...appendOptionsOf(options), expectedHead });
662
+ if (result.ok)
663
+ return { ok: true, record: result.record };
664
+ return refuse("append-failed", `${input.event} could not be appended: ${result.error.message}`, { append: result.error });
665
+ }
666
+ // ---------------------------------------------------------------------------
667
+ // The bounded head-moved retry (APRV-150, APRV-236)
668
+ // ---------------------------------------------------------------------------
669
+ /**
670
+ * Run one whole gate operation, and re-run it from the top on `head-moved`.
671
+ *
672
+ * The mechanism, the bound and the reasoning all live in `core/head-retry.ts`
673
+ * and are shared with `core/execute.ts` and `core/gate-window.ts`. There is one
674
+ * implementation of this in the runtime; this is the adapter that reads the
675
+ * ceiling a caller asked for out of {@link GateOptions}.
676
+ *
677
+ * `attempt` is the ENTIRE operation, from `readGateRecords` to the append: a new
678
+ * read of the verified log, a new read of the policy, a fresh attestation check,
679
+ * a fresh derivation, fresh escalation, single-use, intake and budget checks, and
680
+ * a new append against the head that the new read observed. Nothing is carried
681
+ * across an attempt except the caller's inputs, so nothing stale can authorize a
682
+ * write, and a verdict the interleaved record changed is the verdict enforced.
683
+ */
684
+ function withHeadMovedRetry(options, attempt) {
685
+ return withHeadRetry(attemptsOf(options.retryOnHeadMoved), attempt);
686
+ }
687
+ function actionsOf(envelope) {
688
+ const value = envelope.actions;
689
+ if (!Array.isArray(value))
690
+ return [];
691
+ const actions = [];
692
+ for (const entry of value) {
693
+ if (typeof entry !== "object" || entry === null)
694
+ continue;
695
+ const item = entry;
696
+ const cls = item["class"];
697
+ const key = item["idempotency_key"];
698
+ if (typeof cls !== "string" || typeof key !== "string")
699
+ continue;
700
+ const action = { class: cls, idempotency_key: key };
701
+ if (typeof item["summary"] === "string")
702
+ action.summary = item["summary"];
703
+ if (typeof item["reversible"] === "boolean")
704
+ action.reversible = item["reversible"];
705
+ const declaredCost = normalizeUsd(item["est_cost_usd"]);
706
+ if (declaredCost !== null)
707
+ action.est_cost_usd = declaredCost;
708
+ if (isPayloadHash(item["payload_hash"]))
709
+ action.payload_hash = item["payload_hash"];
710
+ actions.push(action);
711
+ }
712
+ return actions;
713
+ }
714
+ /**
715
+ * The envelope's own `budget` block (SPEC.md §6.2), as registered.
716
+ *
717
+ * Copied into the `task.registered` payload so the task cap is enforced from
718
+ * the log rather than from a file an agent can edit after the fact (S2; see
719
+ * `core/budgets.ts`'s `taskMaxCostUsd`). Only `max_cost_usd` is enforced at
720
+ * v0.1 — `max_latency` is recorded and does nothing yet — so the whole block is
721
+ * copied verbatim rather than a single field cherry-picked, and the enforcement
722
+ * that arrives later reads a log that already carries what it needs.
723
+ */
724
+ function budgetOf(envelope) {
725
+ const value = envelope.budget;
726
+ if (typeof value !== "object" || value === null || Array.isArray(value))
727
+ return null;
728
+ return value;
729
+ }
730
+ /**
731
+ * The Backlog.md board key a task file's name begins with (`task-3 - Slug.md`).
732
+ *
733
+ * A hint and nothing more: it is used only to *ask the log a question*, and the
734
+ * answer, when there is one, comes from the log's own record.
735
+ */
736
+ function taskIdFromFileName(path) {
737
+ const match = /^([A-Za-z][A-Za-z0-9_]*-\d+)/u.exec(basename(path));
738
+ return match?.[1] ?? null;
739
+ }
740
+ function resolveSource(source) {
741
+ if (!("file" in source)) {
742
+ if (typeof source.task !== "string" || source.task.length === 0) {
743
+ return { ok: false, refusal: refuse("envelope-invalid", "register requires a non-empty task id") };
744
+ }
745
+ return { ok: true, task: source.task, envelope: source.envelope };
746
+ }
747
+ return readTaskFileSource(source.file);
748
+ }
749
+ function readTaskFileSource(path) {
750
+ const read = readTaskFile(path);
751
+ if (!read.ok) {
752
+ if (read.code === "io") {
753
+ return { ok: false, refusal: refuse("task-file-unreadable", read.message) };
754
+ }
755
+ const refusal = refuse("envelope-invalid", `${path}: ${read.message}`);
756
+ // A file with no frontmatter at all has lost more than the envelope, and
757
+ // leaves no id behind. Its name is the only handle; whether it means
758
+ // anything is the log's answer, not this file's.
759
+ const hint = read.code === "no-frontmatter" ? taskIdFromFileName(path) : null;
760
+ if (hint === null)
761
+ return { ok: false, refusal };
762
+ return {
763
+ ok: false,
764
+ refusal,
765
+ missing: { task: hint, kind: "no-frontmatter", loose: true },
766
+ };
767
+ }
768
+ const id = read.data["id"];
769
+ if (typeof id !== "string" || id.length === 0) {
770
+ return {
771
+ ok: false,
772
+ refusal: refuse("envelope-invalid", `${path}: frontmatter has no usable \`id\`; the task id is a Backlog.md board key and the gate needs it to key the registration`),
773
+ };
774
+ }
775
+ const envelope = read.data["approval"];
776
+ if (envelope === undefined) {
777
+ return {
778
+ ok: false,
779
+ refusal: refuse("envelope-invalid", `${path}: frontmatter has no \`approval:\` key. SPEC.md §6 tolerates a task with no envelope — it simply cannot request side-effecting execution — so there is nothing to register.`),
780
+ missing: { task: id, kind: "no-approval-key", loose: false },
781
+ };
782
+ }
783
+ return { ok: true, task: id, envelope };
784
+ }
785
+ /**
786
+ * Was this envelope-less file's task registered? Then the envelope was lost
787
+ * (APRV-63), and saying so is the whole job.
788
+ *
789
+ * Log-derived on both sides: the question is asked of the verified records, the
790
+ * task id in the answer is the log's, and the file's own (absent) claim is
791
+ * trusted for nothing. Returns `null` when the log has never heard of the task,
792
+ * which is the ordinary "a task with no envelope" case SPEC.md §6 tolerates and
793
+ * this function must leave exactly as it found it.
794
+ */
795
+ function envelopeLost(logPath, path, missing, options) {
796
+ const read = readGateRecords(logPath, options.schemaDir);
797
+ // The log could not be read or does not verify. That refusal outranks any
798
+ // reading of the file: nothing is concluded from a log nobody can trust.
799
+ if (!read.ok)
800
+ return read;
801
+ const wanted = missing.loose ? missing.task.toLowerCase() : missing.task;
802
+ let registration = null;
803
+ for (const record of read.records) {
804
+ if (record.event !== "task.registered")
805
+ continue;
806
+ const id = record.task;
807
+ if (typeof id !== "string")
808
+ continue;
809
+ if ((missing.loose ? id.toLowerCase() : id) !== wanted)
810
+ continue;
811
+ registration = record;
812
+ }
813
+ if (registration === null)
814
+ return null;
815
+ const declared = payloadOf(registration)["actions"];
816
+ const count = Array.isArray(declared) ? declared.length : 0;
817
+ const shape = missing.kind === "no-frontmatter"
818
+ ? "has no frontmatter at all"
819
+ : "has frontmatter but no `approval:` key";
820
+ return refuse("envelope-missing", `${path} ${shape}, yet task ${String(registration.task)} was registered at seq ${String(registration.seq)} with ${String(count)} declared action(s). The envelope was removed after registration — an external rewrite is the observed cause (APRV-60) — and re-registering a stripped file would silently narrow the record to what survives in the file. Nothing was appended: restore the \`approval:\` block by hand from the log (\`approval log tail\`), then re-run. The runtime never rewrites a task file to repair this.`);
821
+ }
822
+ /**
823
+ * Validate an envelope and append `task.registered`.
824
+ *
825
+ * Fail closed: the envelope is validated against `envelope.schema.json` **before
826
+ * anything is read from it and before any byte is written**. A schema-invalid
827
+ * envelope leaves the log untouched.
828
+ *
829
+ * Double registration is refused. Re-registering a task id would give the same
830
+ * id two different declared action sets in one log, and every later lookup
831
+ * ("what class is this key?") would have to pick one — silently. Envelope
832
+ * *changes* are `envelope.drift` (SPEC.md §6.3, M5), not a second registration.
833
+ *
834
+ * `actor` is a `human:` or `agent:` identity; registration is an ordinary
835
+ * proposal, not a privileged act, so an agent may perform it. `system:` is
836
+ * refused: the runtime does not author tasks.
837
+ *
838
+ * The registration payload carries the envelope's `actions` and — since S2 —
839
+ * its `budget` block, so the task's own `max_cost_usd` cap is enforced from the
840
+ * log rather than from a task file that may be edited afterwards.
841
+ */
842
+ export function register(logPath, source, actor, options = {}) {
843
+ return withHeadMovedRetry(options, () => attemptRegister(logPath, source, actor, options));
844
+ }
845
+ /**
846
+ * One whole registration: resolve the source, validate the envelope, read the
847
+ * log, check for a prior registration and a cross-task key collision, append.
848
+ *
849
+ * The body is APRV-236's only change to it: every line was here before, and the
850
+ * retry re-enters at the top, so the double-registration and key-collision scans
851
+ * are re-run against the fresh head rather than replayed from the stale one. A
852
+ * task someone else registered in the window is refused `task-already-registered`
853
+ * by the fresh read, which is the answer the log now supports.
854
+ */
855
+ function attemptRegister(logPath, source, actor, options) {
856
+ if (!PRINCIPAL_ACTOR.test(actor)) {
857
+ return refuse("actor-invalid", `register requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
858
+ }
859
+ const resolved = resolveSource(source);
860
+ if (!resolved.ok) {
861
+ // A file with no envelope is ordinary (SPEC.md §6) unless the log says this
862
+ // task once had one. That question is asked here, of the log, and only when
863
+ // the file gave the gate nothing to register (APRV-63).
864
+ if (resolved.missing !== undefined && "file" in source) {
865
+ const lost = envelopeLost(logPath, source.file, resolved.missing, options);
866
+ if (lost !== null)
867
+ return lost;
868
+ }
869
+ return resolved.refusal;
870
+ }
871
+ const validation = validate("envelope", resolved.envelope, options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
872
+ if (!validation.ok) {
873
+ return refuse("envelope-invalid", `the envelope failed schema validation; nothing was appended`, { errors: validation.errors });
874
+ }
875
+ const read = readGateRecords(logPath);
876
+ if (!read.ok)
877
+ return read;
878
+ const envelope = resolved.envelope;
879
+ const actions = actionsOf(resolved.envelope);
880
+ const incomingKeys = new Set(actions.map((action) => action.idempotency_key));
881
+ for (const record of read.records) {
882
+ if (record.event !== "task.registered")
883
+ continue;
884
+ if (record.task === resolved.task) {
885
+ return refuse("task-already-registered", `task ${resolved.task} was already registered at seq ${record.seq}; an envelope change is envelope.drift, not a second registration`);
886
+ }
887
+ // Cross-task idempotency_key collision (APRV-138). An idempotency_key is the
888
+ // global identity of one side effect (SPEC.md §7); it is owned by exactly one
889
+ // task. A second declaration under a different task would let a later, weaker
890
+ // registration shadow the first at execute time — `findDeclaration` resolves
891
+ // by key alone — disabling the irreversibility floor. Refuse at the write
892
+ // boundary before anything is appended.
893
+ const declaredActions = payloadOf(record)["actions"];
894
+ if (!Array.isArray(declaredActions))
895
+ continue;
896
+ for (const entry of declaredActions) {
897
+ if (typeof entry !== "object" || entry === null)
898
+ continue;
899
+ const key = entry["idempotency_key"];
900
+ if (typeof key === "string" && incomingKeys.has(key)) {
901
+ return refuse("task-already-registered", `action key ${JSON.stringify(key)} was already registered under task ${record.task} at seq ${record.seq}; an idempotency key is the global identity of one side effect and cannot be re-declared under a second task`);
902
+ }
903
+ }
904
+ }
905
+ const payload = { actions };
906
+ if (typeof envelope.state === "string")
907
+ payload["state"] = envelope.state;
908
+ const budget = budgetOf(resolved.envelope);
909
+ if (budget !== null)
910
+ payload["budget"] = budget;
911
+ // APRV-227. Both halves or neither, and only from the caller's option — see
912
+ // {@link RegisterOptions.harness}. A CLI registration passes none and the
913
+ // record looks exactly as it did before the field existed.
914
+ if (options.harness !== undefined) {
915
+ payload["harness"] = options.harness.harness;
916
+ payload["harness_version"] = options.harness.harness_version;
917
+ }
918
+ const appended = append(logPath, { ts: tick(options), event: "task.registered", actor, task: resolved.task, payload }, options,
919
+ // The head read above, when the double-registration check was made.
920
+ read.head);
921
+ if (!appended.ok)
922
+ return appended;
923
+ return { ok: true, record: appended.record, task: resolved.task, actions };
924
+ }
925
+ /**
926
+ * The declared action for `(task, actionKey)`, as registered in the log.
927
+ *
928
+ * SPEC.md §7: "an action's class MUST be declared before an execution token can
929
+ * be requested for it". The declaration lives in `task.registered`, so the log —
930
+ * not the file, which may have been edited since — is what the gate reads back.
931
+ */
932
+ export function registeredAction(records, task, actionKey) {
933
+ let registration = null;
934
+ for (const record of records) {
935
+ if (record.event === "task.registered" && record.task === task)
936
+ registration = record;
937
+ }
938
+ if (registration === null) {
939
+ return refuse("not-registered", `task ${task} has no task.registered record; run \`approval register <task-file>\` first`);
940
+ }
941
+ const declared = payloadOf(registration)["actions"];
942
+ const actions = Array.isArray(declared) ? declared : [];
943
+ for (const entry of actions) {
944
+ if (typeof entry !== "object" || entry === null)
945
+ continue;
946
+ const item = entry;
947
+ if (item["idempotency_key"] !== actionKey)
948
+ continue;
949
+ const cls = item["class"];
950
+ if (typeof cls !== "string")
951
+ break;
952
+ const action = { class: cls, idempotency_key: actionKey };
953
+ if (typeof item["summary"] === "string")
954
+ action.summary = item["summary"];
955
+ if (typeof item["reversible"] === "boolean")
956
+ action.reversible = item["reversible"];
957
+ const declaredCost = normalizeUsd(item["est_cost_usd"]);
958
+ if (declaredCost !== null)
959
+ action.est_cost_usd = declaredCost;
960
+ if (isPayloadHash(item["payload_hash"]))
961
+ action.payload_hash = item["payload_hash"];
962
+ return { ok: true, action };
963
+ }
964
+ return refuse("action-not-registered", `task ${task} declares no action with idempotency_key ${JSON.stringify(actionKey)}; SPEC.md §7 requires a class to be declared before it can be requested`);
965
+ }
966
+ /**
967
+ * The `payload_hash` the log says was declared for `(task, actionKey)`, or
968
+ * `null`.
969
+ *
970
+ * Deliberately narrower than {@link registeredAction}: this answers one
971
+ * question and refuses nothing, so {@link request} can distinguish "declared no
972
+ * hash" from "declared no action" and report each in its own words. The last
973
+ * registration wins, matching every other declaration read in this codebase.
974
+ */
975
+ function declaredPayloadHash(records, task, actionKey) {
976
+ let found = null;
977
+ for (const record of records) {
978
+ if (record.event !== "task.registered" || record.task !== task)
979
+ continue;
980
+ const declared = payloadOf(record)["actions"];
981
+ if (!Array.isArray(declared))
982
+ continue;
983
+ for (const entry of declared) {
984
+ if (typeof entry !== "object" || entry === null)
985
+ continue;
986
+ const item = entry;
987
+ if (item["idempotency_key"] !== actionKey)
988
+ continue;
989
+ found = isPayloadHash(item["payload_hash"]) ? item["payload_hash"] : null;
990
+ }
991
+ }
992
+ return found;
993
+ }
994
+ /**
995
+ * The LATEST `approval.requested` for an action key, or an empty stand-in.
996
+ *
997
+ * Latest, because an action key may be requested again after a rejection or an
998
+ * expiry, and the grant being recorded answers the live cycle. Returns a bare
999
+ * object rather than `null` so the one caller can read a field off it without a
1000
+ * branch; there is nothing on it to mistake for a real value.
1001
+ */
1002
+ function requestRecord(records, actionKey) {
1003
+ let found = null;
1004
+ for (const record of records) {
1005
+ if (record.event === "approval.requested" && record.action_key === actionKey)
1006
+ found = record;
1007
+ }
1008
+ return found ?? { payload: {} };
1009
+ }
1010
+ /**
1011
+ * Decide whether one `supervised-live` action stops at the human gate.
1012
+ *
1013
+ * Selected **iff** `HMAC-SHA-256(operator secret, payload_hash)`, read as a
1014
+ * uniform value in [0, 1), falls below the class's `live_rate` — the identical
1015
+ * construction `core/sampler.ts` already uses for retrospective sampling, reused
1016
+ * rather than reinvented so there is one selection mechanism in this runtime and
1017
+ * one thing to get right.
1018
+ *
1019
+ * The four properties the design asks for, and where each comes from:
1020
+ *
1021
+ * - **Deterministic.** HMAC over the payload hash is a pure function. Same
1022
+ * secret, same bytes, same rate, same verdict — on every machine, forever.
1023
+ * - **Agent-unpredictable.** The secret is operator-held and named only by
1024
+ * `audit.sampling_secret_env`. An agent that cannot read it cannot compute its
1025
+ * own luck, and seeding from content alone is forbidden by SPEC.md §5.2 for
1026
+ * exactly that reason.
1027
+ * - **No re-roll.** The input is the payload hash, not a nonce, a timestamp, or
1028
+ * an attempt counter. A refused, retried, or duplicated request over identical
1029
+ * bytes gets identical treatment, so "ask again until it comes up unsampled"
1030
+ * is not a strategy — there is nothing to re-roll. Changing the bytes changes
1031
+ * the verdict, but bytes are the action, so that is a different request.
1032
+ * - **Post-hoc verifiable.** An operator holding the secret can recompute the
1033
+ * verdict for any request in the log from its `payload_hash` and the `live_rate`
1034
+ * of the policy the request pinned in `policy_sha256`.
1035
+ *
1036
+ * ## Nothing about the selection is written to the log
1037
+ *
1038
+ * A sampled action's `approval.requested` is byte-for-byte what a `manual`
1039
+ * class's would have been: same fields, same values, same order. That is
1040
+ * deliberate, and it is what "follows the manual path bit for bit" means at the
1041
+ * level of the record.
1042
+ *
1043
+ * Two reasons. First, the selection VALUE in the log would be a free oracle: an
1044
+ * agent that could read a corpus of (payload hash, value) pairs learns about the
1045
+ * secret it is not supposed to hold. `core/audit.ts` omits it from `audit.sampled`
1046
+ * for the same reason. Second, a "this one was sampled live" marker would be a
1047
+ * distinction a channel, an approver, or a later reader could act on — treating
1048
+ * the sampled request as a lesser question than a manual one — and the whole
1049
+ * value of the mechanism is that a sampled action is answered exactly as a
1050
+ * manual one is. The verdict travels to the CALLER, which needs it to explain
1051
+ * itself on stderr, and stops there.
1052
+ *
1053
+ * Nothing is lost to audit: the verdict is recomputable from the request's own
1054
+ * `payload_hash` plus the secret, which is strictly better evidence than a
1055
+ * self-reported field, because it cannot be forged by whoever wrote the record.
1056
+ */
1057
+ /**
1058
+ * Ask the operator's daemon for the draw this process cannot make (APRV-208).
1059
+ *
1060
+ * ## Why this exists, in one paragraph
1061
+ *
1062
+ * `supervised-live` selects with a secret that must never be readable by an
1063
+ * agent, and the process that decides is usually a child of an agent session.
1064
+ * Before this, that combination meant the draw failed closed on every action —
1065
+ * measured on this repository, 15 of 15 supervised-live actions gated after the
1066
+ * amendment that turned sampling on (APRV-184). Safe, and the setting never once
1067
+ * being live. The daemon holds the secret legitimately, so the draw moves there
1068
+ * and this process asks over an owner-only socket under the approval home.
1069
+ *
1070
+ * ## What "asking" is allowed to buy
1071
+ *
1072
+ * Exactly one thing: the right to NOT gate, evidenced. Every failure — no
1073
+ * socket, a socket that will not answer, an answer this process cannot match to
1074
+ * its own question — gates the action with its own machine-readable reason, so
1075
+ * the worst a broken, absent, or hostile daemon can do is put a human in the
1076
+ * loop, which is where the action was going before APRV-208 existed.
1077
+ *
1078
+ * The answer is never believed on its own terms. It carries a MAC over the
1079
+ * question and the verdict under the operator's secret; this process cannot
1080
+ * check it (it holds no secret, which is the point) so it RECORDS it, and the
1081
+ * operator recomputes it later from the request's own fields. That is what keeps
1082
+ * SPEC.md §11's "self-reported fields never reduce scrutiny" true: the only
1083
+ * self-report that reduces scrutiny here is one accompanied by a proof its
1084
+ * author could not forge.
1085
+ */
1086
+ function delegatedVerdict(rate, payloadHash, secretEnv, delegation) {
1087
+ const question = {
1088
+ v: DRAW_PROTOCOL_VERSION,
1089
+ action_key: delegation.actionKey,
1090
+ payload_hash: payloadHash,
1091
+ policy_hash: delegation.policyHash,
1092
+ live_rate: rate,
1093
+ };
1094
+ const outcome = delegation.ask(delegation.logPath, question);
1095
+ if (!outcome.ok) {
1096
+ return {
1097
+ rate,
1098
+ gated: true,
1099
+ reason: outcome.reason,
1100
+ selection: LIVE_SELECTION,
1101
+ secretEnv,
1102
+ draw: { v: DRAW_PROTOCOL_VERSION, source: "unavailable", reason: outcome.reason, live_rate: rate },
1103
+ };
1104
+ }
1105
+ const { answer } = outcome;
1106
+ const verdict = {
1107
+ rate,
1108
+ gated: answer.selected,
1109
+ reason: answer.selected ? "selected" : "not-selected",
1110
+ selection: LIVE_SELECTION,
1111
+ secretEnv,
1112
+ };
1113
+ // Carried only for a SELECTED action, because that is the only delegated
1114
+ // verdict that ever reaches a record: an unsampled action appends no
1115
+ // `approval.requested` at all (amended SPEC.md §6.3), so there is nothing for
1116
+ // the field to ride on and a `live_draw` describing a "not-selected" outcome
1117
+ // could only ever be a shape nobody reads. The unsampled delegation is
1118
+ // evidenced the way every unsampled action already is: by its absence from
1119
+ // the queue, and by an operator recomputing the draw from the registration's
1120
+ // payload hash. Keeping the two in step here is what makes the schema's
1121
+ // `reason: "selected"` an honest constant rather than an assumption.
1122
+ if (!answer.selected)
1123
+ return verdict;
1124
+ return {
1125
+ ...verdict,
1126
+ draw: {
1127
+ v: DRAW_PROTOCOL_VERSION,
1128
+ source: "daemon",
1129
+ reason: "selected",
1130
+ live_rate: rate,
1131
+ selected: answer.selected,
1132
+ mac: answer.mac,
1133
+ daemon_pid: answer.daemon_pid,
1134
+ },
1135
+ };
1136
+ }
1137
+ function liveVerdict(load, resolution, payloadHash, env, delegation) {
1138
+ const rate = resolution.liveRate ?? 1;
1139
+ const selector = resolveLiveSelector(load, env ?? process.env);
1140
+ if (!selector.available) {
1141
+ // APRV-208. `secret-unset` is not the end of the question any more: it says
1142
+ // only that THIS process cannot draw, and the process that can is the
1143
+ // operator's daemon. The other two reasons are unchanged, because there is
1144
+ // nothing to delegate — a policy that cannot be loaded or that names no
1145
+ // secret variable leaves no draw for anyone to make.
1146
+ if (selector.reason === "secret-unset" && payloadHash !== null) {
1147
+ return delegatedVerdict(rate, payloadHash, selector.secretEnv, delegation);
1148
+ }
1149
+ return {
1150
+ rate,
1151
+ gated: true,
1152
+ reason: selector.reason,
1153
+ selection: LIVE_SELECTION,
1154
+ secretEnv: selector.secretEnv,
1155
+ };
1156
+ }
1157
+ if (payloadHash === null) {
1158
+ return {
1159
+ rate,
1160
+ gated: true,
1161
+ reason: "payload-hash-absent",
1162
+ selection: LIVE_SELECTION,
1163
+ secretEnv: selector.secretEnv,
1164
+ };
1165
+ }
1166
+ const selected = selector.selects(payloadHash, rate);
1167
+ return {
1168
+ rate,
1169
+ gated: selected,
1170
+ reason: selected ? "selected" : "not-selected",
1171
+ selection: LIVE_SELECTION,
1172
+ secretEnv: selector.secretEnv,
1173
+ };
1174
+ }
1175
+ /**
1176
+ * `est_cost_usd` as the budgets contract wants it recorded: always a canonical
1177
+ * decimal USD string (APRV-121), `"0"` when the caller declared nothing.
1178
+ *
1179
+ * A caller may hand in either form — the string this runtime writes, or the
1180
+ * JSON number a pre-APRV-121 caller (and a historical record) carries — and
1181
+ * both normalize to the one spelling that enters hashed material.
1182
+ */
1183
+ function costOf(value) {
1184
+ return usdOrZero(value);
1185
+ }
1186
+ /**
1187
+ * `{ display_hash }` for the material this runtime holds, or `{}` (APRV-119).
1188
+ *
1189
+ * The material is the caller's, when it supplied any, and otherwise whatever the
1190
+ * payload store holds under the declared binding — the same two sources
1191
+ * `channels/tagging.ts` renders from, in the same order, so the hash recorded
1192
+ * here names the rendering a channel will actually produce. The store is
1193
+ * content-addressed and re-verified on every read, so a tampered file answers
1194
+ * nothing rather than a rendering of the wrong bytes.
1195
+ *
1196
+ * Never fatal. A payload that cannot be canonicalized, a store that cannot be
1197
+ * read, a file that does not verify: each costs a reader one cross-check, and
1198
+ * none of them is a reason to refuse a request that has passed every check that
1199
+ * governs authority.
1200
+ */
1201
+ function displayHashField(input, options, logPath, boundHash, cls) {
1202
+ let material;
1203
+ if (input.payload !== undefined) {
1204
+ material = input.payload.value;
1205
+ }
1206
+ else {
1207
+ const loaded = loadPayload(options.payloadStoreDir ?? payloadStoreDirFor(logPath), boundHash);
1208
+ if (!loaded.ok)
1209
+ return {};
1210
+ material = loaded.value;
1211
+ }
1212
+ const hash = displayHashOf(material, cls);
1213
+ return hash === null ? {} : { [DISPLAY_HASH_FIELD]: hash };
1214
+ }
1215
+ /**
1216
+ * Gate intake.
1217
+ *
1218
+ * Check order, and why it is this order:
1219
+ *
1220
+ * 1. **Actor.** A malformed identity is a bad call, not a policy question.
1221
+ * 2. **Attestation.** An unverified policy cannot answer anything, so it is
1222
+ * checked before the policy is consulted rather than after.
1223
+ * 3. **Policy resolution** (`loadPolicy` + `resolve`, including the §7
1224
+ * irreversibility floor). A failed load resolves everything to `manual` —
1225
+ * that is `policy-match.ts`'s contract, and this module does not soften it.
1226
+ * 3b. **Declaration** (SPEC.md §7, APRV-147), for a `manual` resolution and for
1227
+ * a `supervised-live` one. The log must carry a `task.registered` for the
1228
+ * task and an action with this idempotency key, or the request is refused
1229
+ * `not-registered` / `action-not-registered` and nothing is appended. Before
1230
+ * the live draw and before the binding below, so an undeclared action never
1231
+ * reaches a human's queue, never has the live fraction drawn over a hash it
1232
+ * chose for itself, and hears the real reason rather than
1233
+ * `payload-hash-required`.
1234
+ * 4. **Off the manual path, stop — unless the live fraction says otherwise.**
1235
+ * `supervised`/`autonomous` append **no event** (amended SPEC.md §6.3) and
1236
+ * return `proceed: true`. Their budget is charged at `execution.started`,
1237
+ * which APRV-18 appends — checking budgets here as well would charge them
1238
+ * twice or, worse, pass here and fail there. A `supervised-live` class
1239
+ * (APRV-127) draws its declared fraction here: an action the draw selects
1240
+ * falls through into everything below and is treated as `manual` from this
1241
+ * line on, and an action it does not proceeds exactly as before.
1242
+ * 5. **Content binding** (amended SPEC.md §6.2, A1). A manual action whose
1243
+ * registered declaration carries no `payload_hash` is refused
1244
+ * `payload-hash-required` and nothing is appended. This is the first check
1245
+ * after the manual path is known, because a request with nothing to bind to
1246
+ * should never reach a human's queue at all.
1247
+ * 5b. **Payload material**, when the caller supplied any (APRV-28). Its hash is
1248
+ * checked against the declaration here — before legality, before budgets,
1249
+ * before any file — and the bytes are written to the payload store in the
1250
+ * step immediately before the append, so a refused request stores nothing.
1251
+ * See the two comments in the body for the ordering and the one orphan it
1252
+ * permits.
1253
+ * 6. **Request legality**, then **budgets**, then the append. Legality first
1254
+ * because a duplicate request is a caller bug that no budget outcome should
1255
+ * obscure, and because refusing it must leave the log untouched.
1256
+ *
1257
+ * The `approval.requested` payload carries `class`, `est_cost_usd`, and (on the
1258
+ * manual path, always) `payload_hash` — the budgets contract requires the first
1259
+ * two on the grant and the token binding requires the third, and the grant
1260
+ * copies all of them from here rather than re-deriving them from a file that
1261
+ * may have changed.
1262
+ */
1263
+ export function request(logPath, input, actor, options = {}) {
1264
+ return withHeadMovedRetry(options, () => attemptRequest(logPath, input, actor, options));
1265
+ }
1266
+ /**
1267
+ * One whole intake, from the clock read to the append.
1268
+ *
1269
+ * Re-entered from the top on a moved head (APRV-236), which re-runs every check
1270
+ * above against the fresh log: attestation, the human-only class test, the §7
1271
+ * declaration, the live draw, the duplicate-request and single-use scans, the
1272
+ * §5.2 intake limits and the budgets. A key someone else requested or started in
1273
+ * the window is refused `duplicate-request` or `already-executed` rather than
1274
+ * `append-failed`, and the live draw is re-run over the same registered hash, so
1275
+ * it selects identically and no attempt can shop for a different answer.
1276
+ *
1277
+ * Two side effects sit inside the retried cycle and are safe there. The payload
1278
+ * store is content-addressed, so a second write of the same bytes is the same
1279
+ * file. The recipient keypair is minted per attempt and overwrites the previous
1280
+ * attempt's private half at the same path, so the key that survives is always
1281
+ * the one whose public half the appended record carries.
1282
+ */
1283
+ function attemptRequest(logPath, input, actor, options) {
1284
+ const ts = tick(options);
1285
+ if (!PRINCIPAL_ACTOR.test(actor)) {
1286
+ return refuse("actor-invalid", `request requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
1287
+ }
1288
+ const read = readGateRecords(logPath);
1289
+ if (!read.ok)
1290
+ return read;
1291
+ // One read of the policy file for the whole operation (APRV-142): the same
1292
+ // bytes are hashed for attestation and parsed for the decision.
1293
+ const policyRead = readPolicyOnce(options);
1294
+ const attested = requireAttestation(read.records, policyRead);
1295
+ if (!attested.ok)
1296
+ return attested;
1297
+ const load = parsePolicy(policyRead, options);
1298
+ const resolution = resolve(load, input.cls, input.reversible === undefined ? {} : { reversible: input.reversible });
1299
+ // APRV-185, amended SPEC.md §5.2, and the first thing intake asks once the
1300
+ // class has an autonomy: a `human-only` class is not requestable. The policy
1301
+ // itself answers, so nothing is put in front of a human, nothing is drawn,
1302
+ // and nothing is appended — a `human-only` class must never acquire an
1303
+ // `approval.requested` record, because such a record is a question in a
1304
+ // queue that no approver may answer.
1305
+ //
1306
+ // Placed above the §7 declaration check deliberately. An unregistered action
1307
+ // in a human-only class is refused for the class rather than for the missing
1308
+ // registration: registering it would not help, and `not-registered` would
1309
+ // send the caller to fix the one thing that cannot make this request valid.
1310
+ if (resolution.autonomy === "human-only") {
1311
+ return refuse("class-human-only", humanOnlyRefusal(input.cls, `action ${input.actionKey} cannot be requested and no approval.requested was written`));
1312
+ }
1313
+ // SPEC.md §7's first invariant, enforced at intake since APRV-147: "an
1314
+ // action's class MUST be declared before an execution token can be requested
1315
+ // for it". Asked of the LOG, before the live draw, before the binding is
1316
+ // derived, and before anything is appended, on every path that can put a
1317
+ // question in front of a human or select one to put there.
1318
+ //
1319
+ // Three things the check buys, in the order they bite:
1320
+ //
1321
+ // - A request for an action nobody registered can no longer reach a human's
1322
+ // queue. Without it, a caller supplying its own `payload_hash` recorded an
1323
+ // `approval.requested` for a class the log never saw declared, and the
1324
+ // approver was shown a prompt whose class, cost, and summary came from the
1325
+ // requester alone.
1326
+ // - The refusal a caller hits is the real one. The registration failure used
1327
+ // to surface as `payload-hash-required`, which names the second-order
1328
+ // symptom and sends the reader to fix the wrong thing. `registeredAction`
1329
+ // answers `not-registered` before `action-not-registered`, and both land
1330
+ // before the binding check below.
1331
+ // - The live fraction is drawn over a declared hash or not at all. §5.2's
1332
+ // no-re-roll property rests on the selection input being the registration's
1333
+ // own bytes; over a caller-supplied hash there is nothing to lose, so an
1334
+ // agent could vary what it presents until the draw came up unsampled. An
1335
+ // unregistered action is now refused before `liveVerdict` runs at all.
1336
+ //
1337
+ // Deliberately not on the plain `supervised`/`autonomous` proceed path: those
1338
+ // answers record nothing and mint nothing, and SPEC.md §7 is enforced for them
1339
+ // where they acquire consequence, in `core/execute.ts` at start time.
1340
+ if (resolution.autonomy === "manual" ||
1341
+ resolution.supervision === "live" ||
1342
+ input.loopFloor === true) {
1343
+ const declared = registeredAction(read.records, input.task, input.actionKey);
1344
+ if (!declared.ok)
1345
+ return declared;
1346
+ }
1347
+ // Amended SPEC.md §6.2/§10 (A1): a manual grant binds to bytes. The log's
1348
+ // declaration wins over anything the caller passed — `register` wrote it from
1349
+ // the envelope, and a request that could name its own hash could approve one
1350
+ // payload and execute another, which is the property this exists to remove.
1351
+ //
1352
+ // Read BEFORE the autonomy branch since APRV-127, because a `supervised-live`
1353
+ // class selects over exactly this value. A caller-supplied fallback is
1354
+ // accepted here on the same terms the manual path always accepted it, and it
1355
+ // cannot be used to steer the selection: an agent that changes the hash it
1356
+ // presents changes which bytes it is asking to have approved, and the
1357
+ // registration's own declaration wins whenever there is one. Since APRV-147
1358
+ // the fallback is reachable only for a REGISTERED action whose declaration
1359
+ // carries no hash — the check above has already refused the unregistered
1360
+ // case, which is where "the declaration wins" used to have no declaration to
1361
+ // win with.
1362
+ const payloadHash = declaredPayloadHash(read.records, input.task, input.actionKey) ??
1363
+ (isPayloadHash(input.payload_hash) ? input.payload_hash : null);
1364
+ let live = null;
1365
+ // APRV-145: the loop floor's door into the manual path. It is checked here
1366
+ // rather than inside `resolve` for the reason §7's irreversibility floor is
1367
+ // applied after class resolution: `resolve` is pure over policy text, and a
1368
+ // failure streak is a projection over the log. A floored action skips this
1369
+ // whole branch — the per-task `loop-escalated` refusal below included, which
1370
+ // would otherwise refuse the very question the floor exists to ask.
1371
+ if (resolution.autonomy !== "manual" && input.loopFloor !== true) {
1372
+ // SPEC.md §10.2 loop safety, the gate's half (APRV-18). Three consecutive
1373
+ // execution.failed events for a task escalate it to manual "regardless of
1374
+ // policy", so an escalated task may not be told to proceed unsupervised.
1375
+ // The refusal is deliberately narrow: it fires only where the answer would
1376
+ // otherwise have been `proceed: true`. A class that resolves manual anyway
1377
+ // is unaffected, because escalation escalates TO manual — putting a human in
1378
+ // the loop is the remedy, and refusing the manual request too would leave an
1379
+ // escalated task with no way back. `core/execute.ts` enforces the matching
1380
+ // half at start time, for an executor that never asks the gate first.
1381
+ if (isLoopEscalated(read.records, input.task)) {
1382
+ return refuse("loop-escalated", `loop-escalated: task ${input.task} has three consecutive failed side-effecting executions and is escalated to manual (amended SPEC.md §10.2); its ${resolution.autonomy} action ${input.actionKey} may not proceed unsupervised. The task's manual actions are unaffected — escalation puts a human in the loop, it does not close the task — and ${loopClearance("task", input.task)}.`);
1383
+ }
1384
+ // APRV-127. A `supervised-live` class puts a declared fraction of its
1385
+ // actions through the human gate before they run. This is where that
1386
+ // fraction is drawn, and the drawing is the LAST check on the non-manual
1387
+ // path: loop escalation above already refuses to let an escalated task
1388
+ // proceed unsupervised, and asking whether an action is in the live
1389
+ // fraction only matters once it would otherwise have been allowed through.
1390
+ if (resolution.supervision === "live") {
1391
+ live = liveVerdict(load, resolution, payloadHash, options.env, {
1392
+ logPath,
1393
+ policyHash: attested.sha256,
1394
+ actionKey: input.actionKey,
1395
+ ask: options.drawAsk ?? askDaemonDraw,
1396
+ });
1397
+ }
1398
+ if (live === null || !live.gated) {
1399
+ // Amended SPEC.md §6.3: no approval.* event exists off the manual path.
1400
+ // An UNSAMPLED supervised-live action leaves by exactly this door, so it
1401
+ // proceeds as a supervised action always has and enters the retrospective
1402
+ // pool on its `execution.started` like any other.
1403
+ return {
1404
+ ok: true,
1405
+ autonomy: resolution.autonomy,
1406
+ proceed: true,
1407
+ resolution,
1408
+ record: null,
1409
+ ...(live === null ? {} : { live }),
1410
+ };
1411
+ }
1412
+ // Sampled. Fall through into the manual path — the same code, in the same
1413
+ // order, producing the same record. Nothing below this line knows or asks
1414
+ // how the action got here.
1415
+ }
1416
+ if (payloadHash === null) {
1417
+ return refuse("payload-hash-required", live === null
1418
+ ? `action ${input.actionKey} resolves to manual and its registered declaration carries no payload_hash. Amended SPEC.md §6.2 makes the hash MUST for manual actions: an approval binds to the exact bytes it approves, so a request with nothing to bind to would ask a human to authorize a payload that could still change afterwards. Declare payload_hash (SHA-256 over the RFC 8785 canonical serialization of the concrete payload) on the action and register the task again.`
1419
+ : `action ${input.actionKey} resolves to supervised-live at rate ${String(live.rate)} and its registered declaration carries no payload_hash, so there is nothing to draw the live fraction over and nothing an approval could bind to. Amended SPEC.md §5.2 selects the live fraction by HMAC over the payload hash precisely so that identical bytes always select identically; an action with no declared bytes is gated rather than waved through, because a sample nobody can reproduce is not a sample. Declare payload_hash (SHA-256 over the RFC 8785 canonical serialization of the concrete payload) on the action and register the task again.`);
1420
+ }
1421
+ // APRV-28, phase one of two: the material is *checked* here, cheaply and
1422
+ // purely, and written later. Checking early means a request whose bytes do
1423
+ // not match its declaration is refused before a duplicate-request or budget
1424
+ // outcome can obscure why, and before any file exists.
1425
+ if (input.payload !== undefined) {
1426
+ let materialHash;
1427
+ try {
1428
+ materialHash = hashOfPayload(input.payload.value);
1429
+ }
1430
+ catch (cause) {
1431
+ return refuse("payload-store-failed", `the payload material for ${input.actionKey} could not be canonicalized: ${cause instanceof Error ? cause.message : String(cause)}. A payload that cannot be serialized cannot be bound to, so nothing was stored and nothing was appended.`);
1432
+ }
1433
+ if (materialHash !== payloadHash) {
1434
+ return refuse("payload-mismatch", `the payload material supplied for ${input.actionKey} hashes to ${materialHash} but the action declares ${payloadHash} (amended SPEC.md §6.2/§10). A grant approves specific bytes, so material that hashes to something else is not this request's payload: nothing was stored and nothing was appended.`);
1435
+ }
1436
+ }
1437
+ const derivation = requestState(read.records, input.actionKey, ts, ttlOf(load));
1438
+ if (derivation.state === "requested") {
1439
+ return refuse("duplicate-request", `action ${input.actionKey} already has a live request at seq ${String(derivation.requestSeq)} awaiting a decision`, { state: derivation.state });
1440
+ }
1441
+ if (derivation.execution.started !== null) {
1442
+ return refuse("already-executed", `action ${input.actionKey} already executed (execution.started at seq ${String(derivation.execution.started)}); an idempotency key is single-use`, { state: derivation.state });
1443
+ }
1444
+ // APRV-173, SPEC.md §5.2's request-volume limits, enforced here and nowhere
1445
+ // else. Placed AFTER the legality checks above and BEFORE budgets, and both
1446
+ // halves of that placement are deliberate.
1447
+ //
1448
+ // After `duplicate-request` and `already-executed`, because those say the
1449
+ // request may not exist at all: a second request for a live action key is
1450
+ // refused for being a duplicate rather than for the queue it would have
1451
+ // joined, and a caller told `queue-full` about an action that already
1452
+ // executed would be sent to wait for a queue to drain instead of to stop.
1453
+ //
1454
+ // Before budgets, because these limits protect the approver's attention and
1455
+ // budgets protect the world's exposure. The cheaper measurement guards the
1456
+ // scarcer resource: a flood of in-budget requests passes every budget verdict
1457
+ // and still empties the one thing this system cannot refill, which is a
1458
+ // human's willingness to read a prompt.
1459
+ //
1460
+ // Off the manual path this code is unreachable, and correctly so: the
1461
+ // `proceed: true` return above happens first. An autonomous or unsampled
1462
+ // supervised action appends no `approval.requested` (§6.3), joins no queue,
1463
+ // and puts nothing in front of anyone, so a queue ceiling has nothing to
1464
+ // measure it against.
1465
+ //
1466
+ // A refusal here appends NOTHING: no event, no payload file, no recipient
1467
+ // key. So a refused request consumes no budget (nothing was authorized), no
1468
+ // window (the window counts `approval.requested` records and none was
1469
+ // written), and no attention.
1470
+ const intake = evaluateIntakeLimits(read.records, intakeScopeOf(load, resolution), { class: input.cls, origin: actor }, ts, ttlOf(load));
1471
+ if (!intake.pass) {
1472
+ const failedLimits = intake.verdicts.filter((entry) => !entry.pass);
1473
+ // Never null on this branch: `pass` is false only when a verdict failed.
1474
+ const code = intakeRefusalOf(intake) ?? "queue-full";
1475
+ const detail = failedLimits
1476
+ .map((entry) => `${entry.limit} (${entry.scope}, ${String(entry.observed)} of ${entry.ceiling === null ? "an unreadable ceiling" : String(entry.ceiling)}${entry.note === undefined ? "" : `: ${entry.note}`})`)
1477
+ .join(", ");
1478
+ return refuse(code, code === "queue-full"
1479
+ ? `the approver's queue is at its declared ceiling, so action ${input.actionKey} was not added to it: ${detail}. SPEC.md §5.2 caps simultaneously pending requests because approver attention is the resource this gate spends. Nothing was appended and no budget was consumed; the request can be made again once a pending request is decided, withdrawn, or lapses.`
1480
+ : `request intake is rate-limited for ${actor}: ${detail}. SPEC.md §5.2 caps request creation per origin over a rolling hour. Nothing was appended and no budget was consumed; the window is rolling, so the oldest request in it ages out on its own.`, { limits: failedLimits });
1481
+ }
1482
+ const budget = evaluateBudgetsWithTask(read.records, budgetScopeOf(load, resolution), { class: input.cls, est_cost_usd: costOf(input.est_cost_usd) }, ts,
1483
+ // S2: the registered envelope's own `budget.max_cost_usd`, conjunctive with
1484
+ // policy budgets and enforced at all three of intake, grant, and start.
1485
+ input.task);
1486
+ if (!budget.pass) {
1487
+ const failed = budget.verdicts.filter((verdict) => !verdict.pass);
1488
+ const logged = append(logPath, {
1489
+ ts,
1490
+ event: "budget.exceeded",
1491
+ actor,
1492
+ task: input.task,
1493
+ action_key: input.actionKey,
1494
+ payload: {
1495
+ class: input.cls,
1496
+ est_cost_usd: costOf(input.est_cost_usd),
1497
+ stage: "request",
1498
+ verdicts: budget.verdicts,
1499
+ },
1500
+ }, options, read.head);
1501
+ const message = `budget refused the request: ${failed
1502
+ .map((verdict) => `${verdict.limit} (${verdict.scope})`)
1503
+ .join(", ")}`;
1504
+ return logged.ok
1505
+ ? refuse("budget-exceeded", message, { verdicts: failed, record: logged.record })
1506
+ : refuse("budget-exceeded", `${message}; the budget.exceeded event could not be appended: ${logged.message}`, {
1507
+ verdicts: failed,
1508
+ });
1509
+ }
1510
+ // APRV-28, phase two: the write, after every check has passed and immediately
1511
+ // before the append. A refused request therefore stores nothing. The one
1512
+ // residue this ordering permits is an orphan: if the append then fails
1513
+ // `head-moved`, a `<hash>.json` file remains for a request that was never
1514
+ // recorded. That is accepted deliberately — the file is content-addressed, so
1515
+ // it is either exactly the bytes some later request will bind to or bytes
1516
+ // nothing will ever ask for, and in neither case can it authorize, alter or
1517
+ // be mistaken for anything. The reverse ordering (append, then store) trades
1518
+ // this harmless file for a recorded manual request whose bytes no channel can
1519
+ // display, which is a request no human can answer.
1520
+ if (input.payload !== undefined) {
1521
+ const stored = storePayload(options.payloadStoreDir ?? payloadStoreDirFor(logPath), input.payload.value);
1522
+ if (!stored.ok) {
1523
+ return refuse("payload-store-failed", `${stored.message} Nothing was appended: a manual request whose payload no channel can display is a request no human can answer (SPEC.md §10.4).`);
1524
+ }
1525
+ }
1526
+ const payload = {
1527
+ class: input.cls,
1528
+ est_cost_usd: costOf(input.est_cost_usd),
1529
+ payload_hash: payloadHash,
1530
+ // APRV-119 (WYSIWYS). The digest of the canonical rendering every channel
1531
+ // MUST present for this payload, so the log states what reading the
1532
+ // approver was shown and not only which bytes they were bound to. Assigned
1533
+ // here at the write boundary from `core/wysiwys.ts` — the same pure
1534
+ // function the channels render with — exactly as `policy_sha256` is
1535
+ // assigned from the runtime's own attestation check, and for the same
1536
+ // reason: a requester that could name its own display hash could show one
1537
+ // reading and record another. `RequestInput` carries no field for it.
1538
+ //
1539
+ // Absent, rather than invented, when this runtime does not hold the bytes:
1540
+ // the caller supplied none and the store has none. A hash over material
1541
+ // nobody holds would name a rendering nobody made.
1542
+ ...displayHashField(input, options, logPath, payloadHash, input.cls),
1543
+ // APRV-118. The attested policy this request was routed by, assigned here
1544
+ // at the write boundary from the runtime's own attestation check — the same
1545
+ // read that authorized the request, one line of code from the append.
1546
+ // {@link RequestInput} carries no field for it, exactly as it carries no
1547
+ // `ts`: the refusal of a caller-supplied value is structural, so a requester
1548
+ // cannot name the rules it claims to have been routed by.
1549
+ [POLICY_HASH_FIELD]: attested.sha256,
1550
+ };
1551
+ // APRV-105. Sealed delivery publishes an ADDRESS for the token this request
1552
+ // may earn: an ephemeral X25519 public key whose private half is written 0600
1553
+ // beside the log and never leaves this machine. Minted HERE, at the last check
1554
+ // before the append, so a refused request leaves no key file behind.
1555
+ //
1556
+ // Guarded by the policy, and by the policy alone: under the default
1557
+ // `manual` no key is minted, no field is added, and the record this call
1558
+ // appends is byte-identical to the one it appended before this feature
1559
+ // existed. `RequestInput` carries no field for the key, so a caller cannot
1560
+ // opt itself in — the operator's policy decides, exactly as it decides
1561
+ // autonomy. A key that cannot be written is not a reason to refuse a request:
1562
+ // the delivery is a convenience, the human's decision is not, and a request
1563
+ // that recorded a key it cannot open would be worse than one that recorded
1564
+ // none. So a failed write drops the field and the paste path stands.
1565
+ //
1566
+ // APRV-211. `delivery: "self"` mints the address whatever the policy says,
1567
+ // and refuses when it cannot. The operator's `token_delivery` setting chooses
1568
+ // between two ways of getting a token to a HUMAN AT A TERMINAL; a requester
1569
+ // that is a process in the same machine has no terminal on the other end, so
1570
+ // the setting has nothing to choose between and the address is the only route
1571
+ // that exists. See {@link RequestInput.delivery}.
1572
+ const selfDelivered = input.delivery === "self" && input.execution !== "harness";
1573
+ if ((tokenDeliveryOf(load) === "sealed" || selfDelivered) &&
1574
+ input.execution !== "harness" // a harness grant mints no token to deliver
1575
+ ) {
1576
+ const keypair = mintRecipientKeypair();
1577
+ const written = writePrivateKey(options.keyStoreDir ?? keyStoreDirFor(logPath), input.actionKey, keypair.privateKey);
1578
+ if (written.ok)
1579
+ payload[RECIPIENT_KEY_FIELD] = keypair.publicKey;
1580
+ else if (selfDelivered) {
1581
+ return {
1582
+ ok: false,
1583
+ code: "token-delivery-unavailable",
1584
+ message: `action ${input.actionKey} is requested with self-delivery, so the grant's token can only reach this process through the sealed address this request publishes — and the private half could not be written (${written.message}). Nothing was appended: a decision spent on an authorization nobody can open would be worse than a question asked again.`,
1585
+ };
1586
+ }
1587
+ if (written.ok && selfDelivered)
1588
+ payload[SELF_DELIVERY_FIELD] = "self";
1589
+ }
1590
+ // APRV-208. Present only when the live draw was DELEGATED to the daemon —
1591
+ // never for an in-process draw, which is why a sampled request made in the
1592
+ // operator's own terminal stays byte-for-byte a manual one (APRV-127's
1593
+ // property, pinned by `tests/autonomy-split.test.ts`). A delegated verdict is
1594
+ // an assertion by another process, and an assertion recorded without its proof
1595
+ // is a self-reported field; this records the proof (the MAC) or, when there
1596
+ // was no usable answer, the distinct reason the action gated instead. No
1597
+ // secret, no selection value and no caller clock ever enters it.
1598
+ if (live?.draw !== undefined)
1599
+ payload["live_draw"] = { ...live.draw };
1600
+ if (input.summary !== undefined)
1601
+ payload["summary"] = input.summary;
1602
+ if (input.reversible !== undefined)
1603
+ payload["reversible"] = input.reversible;
1604
+ // APRV-106. Both are recorded here rather than derived later because the log
1605
+ // is the only place a channel or a grant can read them from, and neither
1606
+ // reduces scrutiny: `execution: "harness"` removes the requester's own
1607
+ // ability to spend a token, and `wait_until` is display text.
1608
+ if (input.execution !== undefined)
1609
+ payload["execution"] = input.execution;
1610
+ if (input.wait_until !== undefined)
1611
+ payload["wait_until"] = input.wait_until;
1612
+ const appended = append(logPath, {
1613
+ ts,
1614
+ event: "approval.requested",
1615
+ actor,
1616
+ task: input.task,
1617
+ action_key: input.actionKey,
1618
+ payload,
1619
+ }, options,
1620
+ // The head read at the top of `request`: the duplicate-request, execution
1621
+ // and budget checks were all made against exactly that log.
1622
+ read.head);
1623
+ if (!appended.ok)
1624
+ return appended;
1625
+ return {
1626
+ ok: true,
1627
+ // `manual` because that is the path this action took and the rules it is now
1628
+ // under: it has a request, it needs a grant, and it will spend a token. The
1629
+ // CLASS may still be supervised-live — `resolution` says so, unchanged — and
1630
+ // `live` says how it got here. What a caller must not read back is
1631
+ // "supervised, proceed", so the field a caller branches on says `manual`.
1632
+ autonomy: "manual",
1633
+ proceed: false,
1634
+ resolution,
1635
+ record: appended.record,
1636
+ ...(live === null ? {} : { live }),
1637
+ };
1638
+ }
1639
+ /**
1640
+ * The two grades a grant may not carry without the approver's own words
1641
+ * (amended SPEC.md §5.2). The same pair `core/audit.ts` enforces on a review,
1642
+ * spelled here rather than imported as a value so this module's runtime imports
1643
+ * stay where they are; `tests/gate.test.ts` pins the two lists together.
1644
+ */
1645
+ const GRADES_REQUIRING_NOTE = new Set(["disliked", "loved"]);
1646
+ const DECISION_EVENT = {
1647
+ grant: "approval.granted",
1648
+ reject: "approval.rejected",
1649
+ revoke: "approval.revoked",
1650
+ };
1651
+ const DECISION_STATE = {
1652
+ grant: "granted",
1653
+ reject: "rejected",
1654
+ revoke: "revoked",
1655
+ };
1656
+ /**
1657
+ * Record a human decision on a request.
1658
+ *
1659
+ * **Human-only**, enforced here in code and again by the event schema for
1660
+ * grant/reject. `revoke` is human-only too: withdrawing an authorization is a
1661
+ * decision about an authorization, and an agent that could revoke could also
1662
+ * churn the queue.
1663
+ *
1664
+ * Attestation is required **for `grant` only**. Grant is the authorizing
1665
+ * decision, so an unverified policy must not be able to produce one. Reject and
1666
+ * revoke *withdraw* authority, and refusing them on an unattested policy would
1667
+ * leave a live grant standing because a file changed — the strict direction and
1668
+ * the safe direction point the same way, and it is not "refuse everything".
1669
+ *
1670
+ * Attestation also answers a question it could not answer before APRV-118:
1671
+ * *which* policy. The hash the live file matched is compared against the hash
1672
+ * `approval.requested` pinned, and a difference refuses `policy-drift` with
1673
+ * nothing appended. Attestation alone catches an unattested edit; this catches
1674
+ * an attested one, which is the case where every check still passes and the
1675
+ * rules have nonetheless changed underneath a pending question. The hash in
1676
+ * force is then recorded on the grant, so the log states the rules the approver
1677
+ * decided under rather than leaving a reader to assume they were the
1678
+ * requester's.
1679
+ *
1680
+ * Budgets are re-evaluated at grant time. A request may have sat in the queue
1681
+ * while other actions consumed the window, and the moment that matters for a
1682
+ * commitment is the moment the human commits.
1683
+ *
1684
+ * On `grant` a single-use execution token is minted (`core/token.ts`) and its
1685
+ * SHA-256 recorded in the payload as `token_sha256`, **alongside the request's
1686
+ * `payload_hash`** (amended SPEC.md §10, A1). The token is therefore bound to
1687
+ * three things — the request, its `idempotency_key`, and the bytes — and
1688
+ * `core/token.ts` refuses `payload-mismatch` for anything else. The raw token
1689
+ * is returned in `token` and is written nowhere: whoever calls this is the only
1690
+ * party that will ever hold it, and a lost token is unrecoverable by design —
1691
+ * revoke and request again.
1692
+ */
1693
+ export function decide(logPath, actionKey, decision, actor, options = {}) {
1694
+ return withHeadMovedRetry(options, () => attemptDecide(logPath, actionKey, decision, actor, options));
1695
+ }
1696
+ /**
1697
+ * One whole decision, from the clock read to the append.
1698
+ *
1699
+ * APRV-236 put this under the bounded retry, and this verb is the reason the
1700
+ * task exists: on 2026-09-02 `approval grant` refused a human's tap with
1701
+ * `head moved: expected seq 14218, found 14219` while two lanes and the daemon
1702
+ * were appending, and the person had to type it again. Three times.
1703
+ *
1704
+ * The re-entry re-derives everything a decision rests on: the fresh
1705
+ * `requestState`, the human-only class test, the TTL lapse, the policy
1706
+ * attestation and the §6.2 drift check, the approver roster, and the budgets at
1707
+ * the moment of commitment. So a request that stopped being decidable in the
1708
+ * window is refused for THAT, in the gate's own vocabulary — `already-decided`
1709
+ * when someone answered it, `request-withdrawn` when its asker took it back,
1710
+ * `expired` when the TTL lapsed, `policy-drift` when a human re-attested — and
1711
+ * never as a lost race. A token is minted per attempt and only the appended
1712
+ * attempt's digest reaches the log, so no attempt leaves a live credential
1713
+ * behind.
1714
+ */
1715
+ function attemptDecide(logPath, actionKey, decision, actor, options) {
1716
+ const ts = tick(options);
1717
+ if (!HUMAN_ACTOR.test(actor)) {
1718
+ return refuse("actor-not-human", `${decision} is a human-only verb; the actor must match human:<id>, got ${JSON.stringify(actor)}`);
1719
+ }
1720
+ // APRV-239, beside the actor check and before anything is read: whether a
1721
+ // grade came with words is a property of these arguments alone, so it is
1722
+ // settled before a log is opened and nothing is appended when it fails. The
1723
+ // schema enforces the same rule at the write boundary, which is what makes it
1724
+ // true of every record whatever surface wrote it; this refusal exists so the
1725
+ // person holding the phone gets a message that names the fix.
1726
+ if (decision === "grant" &&
1727
+ options.reaction !== undefined &&
1728
+ GRADES_REQUIRING_NOTE.has(options.reaction) &&
1729
+ (options.note === undefined || options.note.trim().length === 0)) {
1730
+ return refuse("reaction-note-required", `--reaction ${options.reaction} requires --note "<text>": it is the grade an agent is most likely to act on and least able to interpret alone, and "${options.reaction}" with no words says something about this action and nothing about what. Blank is not a note. \`liked\` and \`indifferent\` need none. Nothing was appended and the request is still pending.`);
1731
+ }
1732
+ const read = readGateRecords(logPath);
1733
+ if (!read.ok)
1734
+ return read;
1735
+ const policyRead = readPolicyOnce(options);
1736
+ let attestedSha256 = null;
1737
+ if (decision === "grant") {
1738
+ const attested = requireAttestation(read.records, policyRead);
1739
+ if (!attested.ok)
1740
+ return attested;
1741
+ attestedSha256 = attested.sha256;
1742
+ }
1743
+ const load = parsePolicy(policyRead, options);
1744
+ const ttlMs = ttlOf(load);
1745
+ const derivation = requestState(read.records, actionKey, ts, ttlMs);
1746
+ if (derivation.state === "none") {
1747
+ return refuse("not-requested", `action ${actionKey} has no approval.requested record to decide`, { state: derivation.state });
1748
+ }
1749
+ // APRV-185, amended SPEC.md §5.2. A request exists, and its class is one the
1750
+ // policy reserves to human hands: no decision may be recorded about it, in
1751
+ // any of this verb's three directions.
1752
+ //
1753
+ // `request` refuses such a class outright, so the only way a live request can
1754
+ // be sitting under one is a policy amendment between the request and the
1755
+ // decision. That is the case this exists for, and it is why REJECT and REVOKE
1756
+ // are refused alongside grant rather than left open as the tidy-up. Those two
1757
+ // withdraw authority rather than confer it, which is exactly why they are
1758
+ // normally unrestricted — but a decision record of any kind about a
1759
+ // human-only class reads afterwards as a class this gate transacts in, and
1760
+ // the log is the artifact both parties are supposed to be able to trust about
1761
+ // that. The request is not stranded: it authorizes nothing, no token exists
1762
+ // for it, and its requester withdraws it or its TTL lapses. Neither
1763
+ // `withdraw` nor `expire` is refused here, deliberately — they are the exits
1764
+ // from a question nobody may answer.
1765
+ //
1766
+ // A request whose payload carries no usable class cannot be tested and falls
1767
+ // through, exactly as it does for `grant-classless-request` below.
1768
+ const declaredClass = derivation.declared.class;
1769
+ if (declaredClass !== null && declaredClass.length > 0) {
1770
+ const classResolution = resolve(load, declaredClass, derivation.declared.reversible === null ? {} : { reversible: derivation.declared.reversible });
1771
+ if (classResolution.autonomy === "human-only") {
1772
+ return refuse("class-human-only", humanOnlyRefusal(declaredClass, `no ${decision} may be recorded for action ${actionKey}`), { state: derivation.state });
1773
+ }
1774
+ }
1775
+ if (derivation.state === "expired") {
1776
+ // Lazy expiry: materialise the event we just derived, then refuse. See the
1777
+ // module header for why the log must carry the state a reader can derive.
1778
+ let materialised;
1779
+ if (derivation.expiredLazily) {
1780
+ const logged = appendExpiry(logPath, derivation, load, ts, options, read.head);
1781
+ if (logged.ok)
1782
+ materialised = logged.record;
1783
+ }
1784
+ const message = derivation.expiredLazily
1785
+ ? `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(ttlMs)}ms TTL before ${ts}. The lapse is judged from the request's own timestamp, so a decision is refused whether or not an approval.expired event had been observed.`
1786
+ : `action ${actionKey} expired at ${String(derivation.decisionTs)} (approval.expired, seq ${String(derivation.decisionSeq)}); an expired request is terminal`;
1787
+ return refuse("expired", message, materialised === undefined
1788
+ ? { state: derivation.state }
1789
+ : { state: derivation.state, record: materialised });
1790
+ }
1791
+ if (derivation.state === "withdrawn") {
1792
+ // APRV-106. Its own code, not `already-decided`: nobody decided. The
1793
+ // requester stopped waiting, so a grant recorded here would be an
1794
+ // authorization with no process left to consume it — which is precisely the
1795
+ // decision SPEC.md §11 says must not be solicited, arriving too late.
1796
+ return refuse("request-withdrawn", `action ${actionKey} was withdrawn by its requester at seq ${String(derivation.decisionSeq)}; a withdrawn request is terminal and nothing can be decided about it. If the action is still wanted, request it again — that is a new request, and it gets its own decision.`, { state: derivation.state });
1797
+ }
1798
+ if (derivation.state === "rejected" || derivation.state === "revoked") {
1799
+ return refuse("already-decided", `action ${actionKey} was already ${derivation.state} at seq ${String(derivation.decisionSeq)}; a decided request is terminal`, { state: derivation.state });
1800
+ }
1801
+ if (derivation.state === "granted") {
1802
+ if (decision !== "revoke") {
1803
+ return refuse("already-decided", `action ${actionKey} was already granted at seq ${String(derivation.decisionSeq)}; a second decision would rewrite a human's answer`, { state: derivation.state });
1804
+ }
1805
+ if (derivation.execution.started !== null) {
1806
+ return refuse("already-executed", `action ${actionKey} already executed (execution.started at seq ${String(derivation.execution.started)}); revocation is only meaningful before execution`, { state: derivation.state });
1807
+ }
1808
+ }
1809
+ else if (decision === "revoke") {
1810
+ // state === "requested"
1811
+ return refuse("not-granted", `action ${actionKey} is awaiting a decision, not granted; reject it rather than revoking it`, { state: derivation.state });
1812
+ }
1813
+ const payload = {};
1814
+ if (decision === "grant") {
1815
+ // APRV-118, and first among the grant's checks because it decides whether
1816
+ // the request in front of this approver is still a request at all. A pinned
1817
+ // hash that differs from the hash in force now means a human re-attested a
1818
+ // policy between the routing and the decision, so the autonomy, limits and
1819
+ // TTL that produced this question are gone. The request is void; nothing is
1820
+ // appended, and the action is requested again under the policy that now
1821
+ // governs it. A request written before the field existed carries `null` and
1822
+ // is decided as it always was — the field is additive, and reading its
1823
+ // absence as drift would void every pending request in an older log.
1824
+ if (derivation.declared.policy_sha256 !== null &&
1825
+ attestedSha256 !== null &&
1826
+ derivation.declared.policy_sha256 !== attestedSha256) {
1827
+ return refuse("policy-drift", `action ${actionKey} was requested under policy ${derivation.declared.policy_sha256} and the attested policy is now ${attestedSha256}; the rules that routed this request to a human are no longer the rules in force, so a grant recorded here would claim a decision under a policy the approver was never shown. Nothing was appended here: the pending request is void and the action must be requested again, which re-resolves its autonomy, limits and TTL under the current policy.`, {
1828
+ state: derivation.state,
1829
+ // APRV-235. The comparison this refusal just made, handed to whoever
1830
+ // records the refusal. `decide` itself still appends nothing — that
1831
+ // contract is what lets a caller retry a refusal without wondering
1832
+ // what it wrote — and the surface that collected the human's gesture
1833
+ // is what appends the `audit.decision_refused` and withdraws the void
1834
+ // request (`core/decision-refusal.ts`).
1835
+ drift: { requested: derivation.declared.policy_sha256, attested: attestedSha256 },
1836
+ });
1837
+ }
1838
+ // A grant with no class is refused rather than recorded with an empty one.
1839
+ // The empty-string substitution this replaces produced an authorization
1840
+ // that no class rule could match and no class-scoped budget could charge —
1841
+ // a hole shaped exactly like a permitted action. Reject and revoke are
1842
+ // unaffected: withdrawing authority needs no class.
1843
+ if (derivation.declared.class === null || derivation.declared.class.length === 0) {
1844
+ return refuse("grant-classless-request", `the approval.requested record for ${actionKey} at seq ${String(derivation.requestSeq)} carries no usable payload.class; a grant is scoped by class — policy matching, the irreversibility floor, and every class-scoped budget read it — so an authorization that names none cannot be recorded. Request the action again through \`approval request\`, which copies the class from the task.registered declaration.`, { state: derivation.state });
1845
+ }
1846
+ // The budgets contract: class and est_cost_usd on every approval.granted,
1847
+ // copied from the request rather than re-derived from a file. A1 adds the
1848
+ // content binding on the same terms: copied, never recomputed.
1849
+ payload["class"] = derivation.declared.class;
1850
+ payload["est_cost_usd"] = derivation.declared.est_cost_usd ?? "0";
1851
+ if (derivation.declared.payload_hash !== null) {
1852
+ payload["payload_hash"] = derivation.declared.payload_hash;
1853
+ }
1854
+ // APRV-118. The one field on this payload that is NOT copied from the
1855
+ // request: it is the hash the runtime just checked the live policy against,
1856
+ // assigned here at the write boundary like `ts`. Copying the request's value
1857
+ // would record what the requester was routed by rather than what the
1858
+ // approver decided under, and the two agreeing is the check above, not an
1859
+ // assumption this line may make. `DecideOptions` carries no field for it, so
1860
+ // a caller-supplied value is refused structurally.
1861
+ if (attestedSha256 !== null)
1862
+ payload[POLICY_HASH_FIELD] = attestedSha256;
1863
+ // APRV-239. Grant only, and written only when it was given: an omitted
1864
+ // reaction leaves no key, which is the difference between "the approver said
1865
+ // nothing" and "the approver said indifferent". It sits under the grant's
1866
+ // own branch so a value passed with `reject` or `revoke` is structurally
1867
+ // unable to reach a record, whatever the CLI in front of it does.
1868
+ if (options.reaction !== undefined)
1869
+ payload["reaction"] = options.reaction;
1870
+ }
1871
+ if (options.note !== undefined)
1872
+ payload["note"] = options.note;
1873
+ if (decision !== "revoke" &&
1874
+ options.batchDeliveryId !== undefined &&
1875
+ options.batchDeliveryId.length > 0) {
1876
+ payload["batch_delivery_id"] = options.batchDeliveryId;
1877
+ }
1878
+ if (decision === "grant") {
1879
+ const cls = derivation.declared.class ?? "";
1880
+ const resolution = resolve(load, cls, derivation.declared.reversible === null ? {} : { reversible: derivation.declared.reversible });
1881
+ // APRV-137, and deliberately the last check before budgets: a budget
1882
+ // refusal WRITES a `budget.exceeded` record, so every cheaper refusal must
1883
+ // run first and leave the log untouched. `resolution` is the single winning
1884
+ // rule of SPEC.md §5.2 — the same one rule that contributes the limits the
1885
+ // next call charges against, so the roster enforced here and the ceiling
1886
+ // enforced there always come from the same author's line.
1887
+ //
1888
+ // A rule that declares no `approvers` restricts nobody: the list is a
1889
+ // narrowing, and a narrowing nobody wrote narrows nothing. A `default`- or
1890
+ // `fail-closed`-provenance resolution therefore restricts nobody either, by
1891
+ // carrying `null`, which is the reading that keeps a repository recoverable:
1892
+ // an unparseable policy already resolves every class to `manual`, and one
1893
+ // that ALSO refused every grant would be a gate nobody could pass to fix it.
1894
+ // Attestation is the control on that path.
1895
+ const approvers = resolution.approvers;
1896
+ if (approvers !== null && !namesApprover(approvers, actor)) {
1897
+ return refuse("actor-not-approver", `${actor} is not named in the approvers list for class ${cls}: the rule ${resolution.matched === null ? "in force" : `\`${resolution.matched.pattern}\``} names ${approvers.length === 0 ? "nobody" : approvers.map((name) => `\`${name}\``).join(", ")}. A grant recorded here would authorize the action on the word of someone the policy did not put in front of this class. Ask a named approver to decide it, or amend the policy and re-attest.`, { state: derivation.state });
1898
+ }
1899
+ const budget = evaluateBudgetsWithTask(read.records, budgetScopeOf(load, resolution), { class: cls, est_cost_usd: derivation.declared.est_cost_usd ?? "0" }, ts,
1900
+ // S2: the envelope's own cap, re-checked at the moment of commitment for
1901
+ // the same reason the policy budgets are — the queue may have moved.
1902
+ derivation.task);
1903
+ if (!budget.pass) {
1904
+ const failed = budget.verdicts.filter((verdict) => !verdict.pass);
1905
+ const logged = append(logPath, {
1906
+ ts,
1907
+ event: "budget.exceeded",
1908
+ actor,
1909
+ ...(derivation.task === null ? {} : { task: derivation.task }),
1910
+ action_key: actionKey,
1911
+ payload: {
1912
+ class: cls,
1913
+ est_cost_usd: derivation.declared.est_cost_usd ?? "0",
1914
+ stage: "grant",
1915
+ verdicts: budget.verdicts,
1916
+ },
1917
+ }, options, read.head);
1918
+ const message = `budget refused the grant: ${failed
1919
+ .map((verdict) => `${verdict.limit} (${verdict.scope})`)
1920
+ .join(", ")}`;
1921
+ return logged.ok
1922
+ ? refuse("budget-exceeded", message, {
1923
+ verdicts: failed,
1924
+ record: logged.record,
1925
+ state: derivation.state,
1926
+ })
1927
+ : refuse("budget-exceeded", `${message}; the budget.exceeded event could not be appended: ${logged.message}`, {
1928
+ verdicts: failed,
1929
+ state: derivation.state,
1930
+ });
1931
+ }
1932
+ }
1933
+ // APRV-17, the token seam. Minted here — after every check has passed and
1934
+ // immediately before the append — so a refused grant mints nothing. Only the
1935
+ // digest enters the payload; the raw token is returned to this caller alone.
1936
+ //
1937
+ // APRV-106 adds the one grant that mints nothing: a request the requester
1938
+ // declared `execution: "harness"`. Such a request is a permission question
1939
+ // asked by a process that will run the command itself, so there is no
1940
+ // `approval run` to hold a key and a minted token would be a live credential
1941
+ // with no owner and no spender. The grant is still a complete grant — class,
1942
+ // cost and payload binding are all recorded — and the marker is copied onto
1943
+ // it so a reader of the grant alone can see why there is no digest, rather
1944
+ // than reading the absence as a grant minted by something that predates
1945
+ // tokens. `core/token.ts` refuses the key as `harness-executed`.
1946
+ let token;
1947
+ if (decision === "grant") {
1948
+ if (derivation.declared.execution === "harness") {
1949
+ payload["execution"] = "harness";
1950
+ }
1951
+ else {
1952
+ token = mintToken();
1953
+ payload[TOKEN_HASH_FIELD] = tokenHash(token);
1954
+ // APRV-105. Sealed delivery, decided by the REQUEST rather than by this
1955
+ // site's own policy read: the recipient key exists only because the
1956
+ // requester's policy said `sealed`, and this grant may be happening on
1957
+ // another machine entirely — the listener on a laptop, the requester
1958
+ // elsewhere, the log synced through git. Reading the key off the request
1959
+ // is what makes the handover work across that gap, and it widens nothing:
1960
+ // the key can only receive a token, never mint, forge, rebind or respend
1961
+ // one. Under `manual` no request carries a key and no grant is sealed, so
1962
+ // the record here is byte-identical to a pre-APRV-105 grant.
1963
+ //
1964
+ // The raw token is STILL returned to this caller and still printed once on
1965
+ // the granting surface. Sealing adds a second reader; it removes none.
1966
+ //
1967
+ // APRV-211 adds the one request for which it DOES remove one. A request
1968
+ // that declared self-delivery was minted by a process that will open the
1969
+ // seal itself (the daemon's own advance), so every copy of the token that
1970
+ // leaves this function is a copy nobody needs: the observed defect was the
1971
+ // Telegram listener printing "copy it now" on Carter's terminal for an
1972
+ // action they were not going to run. Withheld HERE, at the single choke
1973
+ // point, rather than at each granting surface — a value never handed out
1974
+ // cannot be printed by a surface written later. Only when the seal was
1975
+ // actually written: an unopenable grant with no returned token would be a
1976
+ // decision spent on nothing.
1977
+ const declared = payloadOf(requestRecord(read.records, actionKey));
1978
+ const recipient = declared[RECIPIENT_KEY_FIELD];
1979
+ if (isRecipientKey(recipient)) {
1980
+ const sealed = sealToken(token, recipient, actionKey);
1981
+ // An unusable recipient key drops the convenience and never the grant:
1982
+ // a human's yes must not be voidable by a malformed delivery address.
1983
+ if (sealed !== null) {
1984
+ payload[SEALED_TOKEN_FIELD] = { ...sealed };
1985
+ if (declared[SELF_DELIVERY_FIELD] === "self")
1986
+ token = undefined;
1987
+ }
1988
+ }
1989
+ }
1990
+ }
1991
+ if (decision === "revoke") {
1992
+ // APRV-105. The authorization is dead, so its delivery address dies with it.
1993
+ // A key file that outlived its grant would be a standing decryption
1994
+ // capability for a ciphertext the log keeps forever, held for no reason.
1995
+ forgetPrivateKey(options.keyStoreDir ?? keyStoreDirFor(logPath), actionKey);
1996
+ }
1997
+ const appended = append(logPath, {
1998
+ ts,
1999
+ event: DECISION_EVENT[decision],
2000
+ actor,
2001
+ ...(derivation.task === null ? {} : { task: derivation.task }),
2002
+ action_key: actionKey,
2003
+ payload,
2004
+ }, options,
2005
+ // The head read at the top of `decide`: transition legality and the budget
2006
+ // re-check were both judged against exactly that log.
2007
+ read.head);
2008
+ if (!appended.ok)
2009
+ return appended;
2010
+ return {
2011
+ ok: true,
2012
+ decision,
2013
+ state: DECISION_STATE[decision],
2014
+ record: appended.record,
2015
+ ...(token === undefined ? {} : { token }),
2016
+ };
2017
+ }
2018
+ /**
2019
+ * Retract a pending request, as the party that opened it (amended SPEC.md §6.3,
2020
+ * APRV-106).
2021
+ *
2022
+ * ## Why the verb exists
2023
+ *
2024
+ * Observed live on 2026-08-19. A builder's `git commit --amend` went through the
2025
+ * Claude Code hook, which classified it manual and appended
2026
+ * `approval.requested`. The hook waited nine minutes, got nothing, denied the
2027
+ * tool call and moved on — but the request stayed pending for the policy's 24h
2028
+ * TTL. Half an hour later the human was pinged on their phone and approved it,
2029
+ * and the grant authorized nothing at all: the hook had long since answered,
2030
+ * and a retried tool call is a new request with a new key. A person spent
2031
+ * attention on a question whose asker had left. SPEC.md §11 makes human
2032
+ * attention the audit budget, and a decision nobody can consume must not be
2033
+ * solicited; so the asker takes the question back.
2034
+ *
2035
+ * ## The four rules
2036
+ *
2037
+ * 1. **Requester-only.** The actor MUST equal the actor of the
2038
+ * `approval.requested` that opened the current cycle, else `not-requester`.
2039
+ * Anything looser would make the approver's queue clearable by whoever
2040
+ * reached the log first. A human who wants a pending request gone rejects
2041
+ * it, on the record, as themselves.
2042
+ * 2. **Pending-only.** `not-requested` when there is nothing to withdraw,
2043
+ * `already-decided` when a human has answered, `request-withdrawn` for a
2044
+ * second withdrawal, `expired` when the TTL has lapsed — and expiry is
2045
+ * judged here exactly as {@link decide} judges it, from the request's own
2046
+ * timestamp, with the same lazy materialisation of the `approval.expired`
2047
+ * record. A lapse is a lapse whether or not an event says so, and a
2048
+ * withdrawal that pretended otherwise would rewrite the reason a request
2049
+ * ended.
2050
+ * 3. **No attestation, no budget.** Withdrawal removes a question; it authorizes
2051
+ * nothing and commits nothing. Refusing it on an unattested policy would
2052
+ * leave requests standing in a human's queue because a file changed, which
2053
+ * is the strict direction pointing the wrong way.
2054
+ * 4. **Compare-and-append, like everything else here.** The legality check and
2055
+ * the write are made against the same head (SPEC.md §11.1(5)), so a grant
2056
+ * that lands in between wins and this withdrawal never overwrites it. Since
2057
+ * APRV-236 the loser of that race re-reads and re-checks rather than
2058
+ * reporting the lost race: the human's answer is on the fresh head, so the
2059
+ * refusal the requester receives is `already-decided`, which is the fact they
2060
+ * need. `tests/concurrency.test.ts` races the two.
2061
+ *
2062
+ * `ts` is assigned at the write boundary from the injected clock, like every
2063
+ * other gate-typed event (SPEC.md §8, A2): there is no parameter to pass one.
2064
+ */
2065
+ export function withdraw(logPath, actionKey, actor, options = {}) {
2066
+ return withHeadMovedRetry(options, () => attemptWithdraw(logPath, actionKey, actor, options));
2067
+ }
2068
+ /** One whole withdrawal, re-entered from the top on a moved head (APRV-236). */
2069
+ function attemptWithdraw(logPath, actionKey, actor, options) {
2070
+ const ts = tick(options);
2071
+ if (!PRINCIPAL_ACTOR.test(actor)) {
2072
+ return refuse("actor-invalid", `withdraw requires a human: or agent: actor, got ${JSON.stringify(actor)}; system: is refused because the runtime's way of ending a request it was not asked to end is the TTL, not a withdrawal`);
2073
+ }
2074
+ const read = readGateRecords(logPath);
2075
+ if (!read.ok)
2076
+ return read;
2077
+ const load = parsePolicy(readPolicyOnce(options), options);
2078
+ const ttlMs = ttlOf(load);
2079
+ const derivation = requestState(read.records, actionKey, ts, ttlMs);
2080
+ if (derivation.state === "none") {
2081
+ return refuse("not-requested", `action ${actionKey} has no approval.requested record to withdraw`, { state: derivation.state });
2082
+ }
2083
+ if (derivation.state === "withdrawn") {
2084
+ return refuse("request-withdrawn", `action ${actionKey} was already withdrawn at seq ${String(derivation.decisionSeq)}; a withdrawn request is terminal`, { state: derivation.state });
2085
+ }
2086
+ if (derivation.state === "expired") {
2087
+ // The same lazy materialisation `decide` performs, for the same reason: the
2088
+ // log must carry the state a reader can already derive from it.
2089
+ let materialised;
2090
+ if (derivation.expiredLazily) {
2091
+ const logged = appendExpiry(logPath, derivation, load, ts, options, read.head);
2092
+ if (logged.ok)
2093
+ materialised = logged.record;
2094
+ }
2095
+ return refuse("expired", `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(ttlMs)}ms TTL before ${ts}. A lapsed request has already ended; there is nothing left to withdraw.`, materialised === undefined
2096
+ ? { state: derivation.state }
2097
+ : { state: derivation.state, record: materialised });
2098
+ }
2099
+ if (derivation.state !== "requested") {
2100
+ return refuse("already-decided", `action ${actionKey} was already ${derivation.state} at seq ${String(derivation.decisionSeq)}; a human's answer stands, and withdrawing a question that has been answered would erase the answer`, { state: derivation.state });
2101
+ }
2102
+ if (derivation.requestActor !== actor) {
2103
+ return refuse("not-requester", `action ${actionKey} was requested by ${JSON.stringify(derivation.requestActor)} and cannot be withdrawn by ${JSON.stringify(actor)}; only the party that asked may take the question back. To end a pending request as someone else, reject it — that is a decision, and it is recorded as one.`, { state: derivation.state });
2104
+ }
2105
+ const reason = options.reason ?? "cancelled";
2106
+ const payload = { action_key: actionKey, reason };
2107
+ if (options.note !== undefined)
2108
+ payload["note"] = options.note;
2109
+ const appended = append(logPath, {
2110
+ ts,
2111
+ event: "approval.withdrawn",
2112
+ actor,
2113
+ ...(derivation.task === null ? {} : { task: derivation.task }),
2114
+ action_key: actionKey,
2115
+ payload,
2116
+ }, options,
2117
+ // The head read at the top: requester identity and pending-ness were both
2118
+ // judged against exactly that log, so a decision appended since refuses
2119
+ // this write rather than being overwritten by it.
2120
+ read.head);
2121
+ if (!appended.ok)
2122
+ return appended;
2123
+ return { ok: true, state: "withdrawn", record: appended.record };
2124
+ }
2125
+ /**
2126
+ * The request an identical harness command may carry over, or `null`.
2127
+ *
2128
+ * ## The replay bounds, in one place
2129
+ *
2130
+ * A harness grant authorizes **the same bytes, in the same cwd, once, within
2131
+ * the TTL** — and nothing else. Each clause is a line of this function:
2132
+ *
2133
+ * - *the same bytes, in the same cwd*: the candidate's declared
2134
+ * `payload_hash` must equal `payloadHash`, which the caller computes over
2135
+ * the concrete payload (for the Claude Code hook, `{command, cwd}`). A
2136
+ * different command, a different directory, a different byte of either:
2137
+ * different hash, no carry, a new question for a human.
2138
+ * - *harness only*: the candidate must have declared `execution: "harness"`.
2139
+ * A grant that minted an execution token belongs to `approval run`, and a
2140
+ * harness invocation must never spend it by proceeding on it — the token
2141
+ * would still be live, and one authorization would have authorized two
2142
+ * different executions.
2143
+ * - *the same class*: an action key covers one class, and a command that
2144
+ * resolves to three classes asks three questions. Carrying a `deps.add`
2145
+ * grant into a `network.call` check would answer a question nobody asked.
2146
+ * - *once*: a candidate with any `execution.*` record is skipped here and
2147
+ * refused at the append in {@link consumeHarnessGrant}. The single-use rule
2148
+ * is the gate's existing one; this only stops the caller from queueing up a
2149
+ * write that would be refused.
2150
+ * - *within the TTL*: state is derived at `ts` with `ttlMs`, so a lapsed
2151
+ * request reads `expired` and carries nothing, whether or not the daemon has
2152
+ * materialised an `approval.expired` record.
2153
+ *
2154
+ * PURE, and reads only records the caller verified — the enforcement path never
2155
+ * touches an unverified log (SPEC.md §11.1). The latest candidate wins: a key
2156
+ * whose earlier cycle was rejected, withdrawn or expired is superseded by the
2157
+ * request that came after it, exactly as {@link requestState} treats cycles.
2158
+ */
2159
+ /**
2160
+ * Has a GRANTED request outlived `defaults.approval_ttl`?
2161
+ *
2162
+ * `requestState` reports a decided request by its decision forever: the TTL
2163
+ * bounds the window in which a human may answer, not the answer's shelf life.
2164
+ * The shelf life is a separate, settled rule and it already exists — `tokenStatus`
2165
+ * in `core/token.ts` re-applies `requestTs + approval_ttl` to a granted request
2166
+ * and refuses `token-expired` past it, so a token minted yesterday cannot be
2167
+ * spent today. This is the same arithmetic for the grant that mints no token: a
2168
+ * harness approval must not be the one kind that never goes stale.
2169
+ *
2170
+ * Unparseable instants read as lapsed, and a policy with no TTL declares no
2171
+ * lapse at all — both exactly as `core/token.ts` reads them.
2172
+ */
2173
+ function grantLapsed(derivation, ts, ttlMs) {
2174
+ if (ttlMs === null)
2175
+ return false;
2176
+ const requestedAt = Date.parse(derivation.requestTs ?? "");
2177
+ const asked = Date.parse(ts);
2178
+ if (Number.isNaN(requestedAt) || Number.isNaN(asked))
2179
+ return true;
2180
+ return asked > requestedAt + ttlMs;
2181
+ }
2182
+ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
2183
+ if (!isPayloadHash(payloadHash))
2184
+ return null;
2185
+ // Distinct keys, latest request first: a later question about the same bytes
2186
+ // is the live one, and an older key that was consumed or lapsed must not
2187
+ // shadow it.
2188
+ const keys = [];
2189
+ for (let index = records.length - 1; index >= 0; index -= 1) {
2190
+ const record = records[index];
2191
+ if (record === undefined)
2192
+ continue;
2193
+ if (record.event !== "approval.requested")
2194
+ continue;
2195
+ if (record.action_key === undefined)
2196
+ continue;
2197
+ if (keys.includes(record.action_key))
2198
+ continue;
2199
+ const payload = payloadOf(record);
2200
+ if (payload["execution"] !== "harness")
2201
+ continue;
2202
+ if (payload["payload_hash"] !== payloadHash)
2203
+ continue;
2204
+ if (payload["class"] !== cls)
2205
+ continue;
2206
+ keys.push(record.action_key);
2207
+ }
2208
+ let pending = null;
2209
+ for (const actionKey of keys) {
2210
+ const derivation = requestState(records, actionKey, ts, ttlMs);
2211
+ // The declaration is re-read from the derivation rather than from the
2212
+ // record matched above: `requestState` resets on every `approval.requested`,
2213
+ // so this is the cycle whose state was just derived.
2214
+ if (derivation.declared.payload_hash !== payloadHash)
2215
+ continue;
2216
+ if (derivation.declared.execution !== "harness")
2217
+ continue;
2218
+ if (derivation.execution.started !== null)
2219
+ continue;
2220
+ if (derivation.state === "granted") {
2221
+ // An answer has a shelf life, and it is its request's TTL.
2222
+ if (grantLapsed(derivation, ts, ttlMs))
2223
+ continue;
2224
+ return {
2225
+ actionKey,
2226
+ task: derivation.task,
2227
+ kind: "granted",
2228
+ requestSeq: derivation.requestSeq,
2229
+ decisionSeq: derivation.decisionSeq,
2230
+ };
2231
+ }
2232
+ if (derivation.state === "requested" && pending === null) {
2233
+ pending = {
2234
+ actionKey,
2235
+ task: derivation.task,
2236
+ kind: "pending",
2237
+ requestSeq: derivation.requestSeq,
2238
+ decisionSeq: null,
2239
+ };
2240
+ }
2241
+ }
2242
+ // A grant beats a pending question: proceeding on an answer that already
2243
+ // exists asks nobody anything.
2244
+ return pending;
2245
+ }
2246
+ /**
2247
+ * The policy hash pinned on the `approval.granted` record at `seq`, or `null`.
2248
+ *
2249
+ * `null` covers both shapes that are not a claim about policy: a grant written
2250
+ * before APRV-118 added the field, and a value that is not a SHA-256. A
2251
+ * malformed one reads as absent for the same reason `core/state.ts` reads it
2252
+ * that way — a corrupt byte must not be able to void an authorization, and a
2253
+ * crafted one must not be able to claim agreement it cannot prove.
2254
+ */
2255
+ function grantedPolicyHash(records, seq) {
2256
+ if (seq === null)
2257
+ return null;
2258
+ for (const record of records) {
2259
+ if (record.seq !== seq)
2260
+ continue;
2261
+ const value = payloadOf(record)[POLICY_HASH_FIELD];
2262
+ return isPolicySha256(value) ? value : null;
2263
+ }
2264
+ return null;
2265
+ }
2266
+ /** The payload field {@link HarnessGrantOrigin} is recorded under. */
2267
+ export const HARNESS_GRANT_ORIGIN = "grant_origin";
2268
+ /**
2269
+ * The payload field naming the TOOL CALL that spent a carried grant (APRV-287).
2270
+ *
2271
+ * A carried grant's `execution.started` names the task of the request, because
2272
+ * that is the task the log holds the approval lifecycle under. The tool call
2273
+ * that actually ran the command is a different one, and until this field the
2274
+ * runtime had no way back to it: the completion counterpart rebuilds a task id
2275
+ * from the reporting event's session and tool-use id, found no start under it,
2276
+ * and refused `not-delegated`. The consequence was the one an operator saw on
2277
+ * 2026-09-06 — a granted commit-and-push completed, no `execution.completed`
2278
+ * was ever written, and the loop floor the refusal text promises would clear on
2279
+ * a completion stayed shut over the rest of the session.
2280
+ *
2281
+ * DERIVED, never declared: the value is the task id the runtime minted for the
2282
+ * spending invocation from the harness's session and tool-use ids, the same one
2283
+ * {@link HARNESS_GRANT_ORIGIN} is computed against. A reporter cannot name a
2284
+ * bucket with it, because the only thing it can reach is a start this runtime
2285
+ * wrote for that same tool call.
2286
+ *
2287
+ * Absent where the spend is `direct` (the record's own `task` already names the
2288
+ * tool call) and on every record written before this field existed, which is why
2289
+ * every reader treats absence as "no second name" rather than as a fault.
2290
+ */
2291
+ export const HARNESS_SPENDING_TASK = "spent_by_task";
2292
+ /**
2293
+ * Spend a harness grant, exactly once (APRV-117).
2294
+ *
2295
+ * ## Why this is `execution.started`, and why it is alone
2296
+ *
2297
+ * A harness grant mints no token (APRV-106), so nothing in `core/token.ts`
2298
+ * records that it was used, and without such a record a grant could authorize
2299
+ * an unbounded number of identical retries for the whole TTL. The consumption
2300
+ * marker has to be a real event through compare-and-append (SPEC.md §11.1(5)),
2301
+ * and it has to be one the gate already reads as terminal for an idempotency
2302
+ * key. `execution.started` is exactly that: {@link request} refuses a key that
2303
+ * has one as `already-executed`, and {@link decide} refuses to revoke past it.
2304
+ * Reusing it means the single-use rule is the gate's existing rule rather than
2305
+ * a second one written next to it.
2306
+ *
2307
+ * **No `execution.completed` or `execution.failed` follows, ever.** The harness
2308
+ * runs the command; this runtime hands over permission and never observes an
2309
+ * exit status. Appending a completion would fabricate an outcome, and in this
2310
+ * vocabulary it would also assert something with consequences — an
2311
+ * `execution.completed` clears a task's loop-escalation streak (SPEC.md §10.2).
2312
+ * A harness execution is therefore recorded as begun and never as finished,
2313
+ * which is precisely what the runtime knows. The `execution: "harness"` marker
2314
+ * on the payload says so on the record itself, so a reader of the start event
2315
+ * alone can see why no outcome ever lands.
2316
+ *
2317
+ * ## What it refuses
2318
+ *
2319
+ * Attestation is checked here, and not as a formality: this is the one
2320
+ * enforcement path that reaches a harness `allow` without passing through
2321
+ * {@link request} (that happened in an earlier process, possibly against
2322
+ * earlier policy bytes). A policy that changed since the human attested it
2323
+ * cannot answer anything, so it answers nothing. `policy-drift` is the second
2324
+ * half of the same idea and is APRV-134: attested is not enough when what is
2325
+ * attested is a DIFFERENT policy from the one the approver decided under, and
2326
+ * the gap between a tap and a retry's spend is exactly where a re-attestation
2327
+ * fits.
2328
+ *
2329
+ * Everything else follows the derivation: `not-requested` when the key has no
2330
+ * request, `expired` when the TTL lapsed (judged from the request's own `ts`,
2331
+ * event or no event, exactly as {@link decide} judges it), `already-executed`
2332
+ * when something already spent it, `not-granted` for every other state and for
2333
+ * a grant that is not harness-executed. The content binding is checked last and
2334
+ * refuses twice over (APRV-146): `payload-hash-required` when the grant records
2335
+ * no bytes or the consumer states none, `payload-mismatch` when the bytes stated
2336
+ * are not the bytes approved. Budgets are not re-evaluated: the
2337
+ * authorization was charged at `approval.granted`, and `core/budgets.ts`'s
2338
+ * consumption contract already dedupes a start event against a grant carrying
2339
+ * the same `action_key`.
2340
+ *
2341
+ * ## What the record says about ORDER (APRV-200)
2342
+ *
2343
+ * The start carries `grant_origin`, which answers a question the log could not
2344
+ * previously be asked: was the tool call that spent this grant the tool call
2345
+ * that asked for it? `direct` says yes, and the gate observed the whole ordering
2346
+ * in one process. `carried` says a LATER invocation spent it — APRV-117's
2347
+ * carryover, or its adoption sibling — which means the asking invocation had
2348
+ * already returned a verdict, and this runtime never sees whether the harness
2349
+ * honoured it. A grant that arrives after the effect it names is a ratification
2350
+ * and not an approval, and `carried` is the window in which that is possible.
2351
+ * See {@link HarnessGrantOrigin} and `docs/claude-code-hook.md`.
2352
+ */
2353
+ export function consumeHarnessGrant(logPath, actionKey, actor, options = {}) {
2354
+ // APRV-150. Every attempt is a complete spend: a fresh verified read, a fresh
2355
+ // attestation, a fresh derivation of the request's state, and an append
2356
+ // against the head that read observed. A record landing in the window moves
2357
+ // the head and nothing else, unless it is a record that bears on this spend —
2358
+ // a competing consumer's `execution.started` — in which case the next attempt
2359
+ // derives `already-executed` and refuses it. The grant is spent once either
2360
+ // way, and it is the fresh log that decides which.
2361
+ return withHeadMovedRetry(options, () => attemptHarnessConsume(logPath, actionKey, actor, options));
2362
+ }
2363
+ function attemptHarnessConsume(logPath, actionKey, actor, options) {
2364
+ const ts = tick(options);
2365
+ if (!PRINCIPAL_ACTOR.test(actor)) {
2366
+ return refuse("actor-invalid", `consuming a harness grant requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
2367
+ }
2368
+ const read = readGateRecords(logPath);
2369
+ if (!read.ok)
2370
+ return read;
2371
+ const policyRead = readPolicyOnce(options);
2372
+ const attested = requireAttestation(read.records, policyRead);
2373
+ if (!attested.ok)
2374
+ return attested;
2375
+ const load = parsePolicy(policyRead, options);
2376
+ const derivation = requestState(read.records, actionKey, ts, ttlOf(load));
2377
+ if (derivation.state === "none") {
2378
+ return refuse("not-requested", `action ${actionKey} has no approval.requested record, so there is no grant to proceed on`, { state: derivation.state });
2379
+ }
2380
+ // APRV-185, and the same placement `decide` uses: once a request is known to
2381
+ // exist, a class the policy reserves to human hands is answered before every
2382
+ // question about the spend. A harness grant is spent by a LATER process, so a
2383
+ // policy amendment can raise the class in the gap — and a spend here would
2384
+ // let a harness run a command in a class no agent may execute at all, on the
2385
+ // strength of a grant recorded under rules that no longer stand.
2386
+ const spendClass = derivation.declared.class;
2387
+ if (spendClass !== null && spendClass.length > 0) {
2388
+ const spendResolution = resolve(load, spendClass, derivation.declared.reversible === null ? {} : { reversible: derivation.declared.reversible });
2389
+ if (spendResolution.autonomy === "human-only") {
2390
+ return refuse("class-human-only", humanOnlyRefusal(spendClass, `the harness grant for action ${actionKey} may not be spent and no execution.started was written`), { state: derivation.state });
2391
+ }
2392
+ }
2393
+ if (derivation.execution.started !== null) {
2394
+ return refuse("already-executed", `action ${actionKey} was already spent (execution.started at seq ${String(derivation.execution.started)}); a harness grant authorizes one execution of the bytes it approved, and a further identical command is a new question`, { state: derivation.state });
2395
+ }
2396
+ if (derivation.state === "expired") {
2397
+ return refuse("expired", `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its TTL before ${ts}. A grant authorizes only inside the approval window.`, { state: derivation.state });
2398
+ }
2399
+ if (derivation.state !== "granted") {
2400
+ return refuse("not-granted", `action ${actionKey} is ${derivation.state}, not granted; nothing authorizes proceeding on it`, { state: derivation.state });
2401
+ }
2402
+ if (derivation.declared.execution !== "harness") {
2403
+ return refuse("not-granted", `action ${actionKey} was granted as an ordinary request and minted an execution token; it is spent by presenting that token to \`approval run\`, not by a harness proceeding on it. Two spenders of one authorization is the property this refuses.`, { state: derivation.state });
2404
+ }
2405
+ if (grantLapsed(derivation, ts, ttlOf(load))) {
2406
+ return refuse("expired", `action ${actionKey}'s grant expired: the request at ${String(derivation.requestTs)} lapsed its TTL before ${ts}. There is no separate grant TTL — an approval lives exactly as long as its parent request, which is the rule \`core/token.ts\` applies to a token-bearing grant.`, { state: derivation.state });
2407
+ }
2408
+ if (derivation.task === null) {
2409
+ return refuse("not-registered", `action ${actionKey} has a grant but no task on its request record; an execution event names both (SPEC.md §8) and nothing here invents one`, { state: derivation.state });
2410
+ }
2411
+ // APRV-134: the spend-time half of APRV-118's comparison. `decide` refuses a
2412
+ // grant whose request was routed under a policy that is no longer in force;
2413
+ // this path is the remaining consumer that could still spend one under
2414
+ // different rules, because a harness grant is spent by a LATER PROCESS —
2415
+ // a retry after the first invocation's wait timed out, minutes later. A human
2416
+ // re-attesting in that gap changes the autonomy, the limits and the TTL that
2417
+ // put the question in front of them, and the command about to run is the one
2418
+ // they answered under the old rules. Refused with APRV-118's own
2419
+ // `policy-drift`, and deliberately the same code: the fact is the same fact
2420
+ // (the file is attested and is a different file), the remedy is the same
2421
+ // remedy (request it again under the policy that governs now), and a second
2422
+ // code for one condition would be a distinction an agent has to learn without
2423
+ // being able to act on it differently.
2424
+ //
2425
+ // The grant's own pinned hash is read first and the request's is the
2426
+ // fallback, so a log in which only one of the pair carries the field is
2427
+ // judged by whichever one does. Absence on both is not a mismatch: the field
2428
+ // is additive per SPEC.md §8, and reading its absence as drift would strand
2429
+ // every grant in a log written before APRV-118.
2430
+ const pinned = grantedPolicyHash(read.records, derivation.decisionSeq) ?? derivation.declared.policy_sha256;
2431
+ if (pinned !== null && pinned !== attested.sha256) {
2432
+ return refuse("policy-drift", `action ${actionKey} was approved under policy ${pinned} and the attested policy is now ${attested.sha256}; a human re-attested between the decision and this spend, so the rules the approver saw are not the rules this command would run under. Nothing was appended: the grant is void and the action must be requested again, which re-resolves its autonomy, limits and TTL under the current policy.`,
2433
+ // The comparison, carried for the same reason `decide`'s is (APRV-235).
2434
+ // Nothing records THIS one: the party refused here is an agent spending a
2435
+ // carried grant, and agent-side refusals stay unlogged.
2436
+ { state: derivation.state, drift: { requested: pinned, attested: attested.sha256 } });
2437
+ }
2438
+ // Content binding at the spend (APRV-146), reading APRV-140's rule the way
2439
+ // `core/execute.ts` reads it. A harness grant approves specific bytes: the
2440
+ // request recorded their hash, the human answered about them, and
2441
+ // `findHarnessCarry` matched a retry to this grant on that hash alone. So the
2442
+ // process about to run the command states the bytes it holds and they must be
2443
+ // the ones the grant carries.
2444
+ //
2445
+ // Neither absence is waved through. A request that recorded no binding is a
2446
+ // record this gate could not have written — `request` refuses
2447
+ // `payload-hash-required` for every manual action — so accepting it would make
2448
+ // the binding bypassable by log construction. A consumer that presents none
2449
+ // has not shown that it is running the approved command, which is the same
2450
+ // fact stated by omission. Ambiguity resolves to the stricter path, and the
2451
+ // grant stays live either way: nothing here is appended.
2452
+ const declaredHash = derivation.declared.payload_hash;
2453
+ if (declaredHash === null) {
2454
+ return refuse("payload-hash-required", `action ${actionKey}'s request records no payload_hash, so there is nothing for this spend to be checked against. Amended SPEC.md §6.2 makes the binding MUST for a manual action, and a harness request is one; a grant carrying none reached the log some other way. Request the action again, which binds it to the payload the harness is about to run.`, { state: derivation.state });
2455
+ }
2456
+ const presented = options.presentedPayloadHash;
2457
+ if (!isPayloadHash(presented)) {
2458
+ return refuse("payload-hash-required", `the grant for ${actionKey} binds to payload_hash ${declaredHash} and this consumer presented ${presented === undefined ? "none" : JSON.stringify(presented)}. Amended SPEC.md §10.4: an executor MUST recompute the hash of the payload it is about to execute, so a spend that cannot state its bytes cannot be shown to be running the approved ones. Nothing was appended and the grant is still live.`, { state: derivation.state });
2459
+ }
2460
+ if (presented !== declaredHash) {
2461
+ return refuse("payload-mismatch", `the payload presented for ${actionKey} is not the one approved: the grant binds to ${declaredHash}, this consumer presented ${JSON.stringify(presented)}. A grant approves specific bytes; changing them after the decision requires a new request. Nothing was appended and the grant is still live.`, { state: derivation.state });
2462
+ }
2463
+ const payload = {
2464
+ // The budgets contract: class and est_cost_usd on every start event.
2465
+ class: derivation.declared.class ?? "",
2466
+ est_cost_usd: derivation.declared.est_cost_usd ?? "0",
2467
+ // Why no completion will ever follow (see the doc comment).
2468
+ execution: "harness",
2469
+ // APRV-146: unconditional, because a grant with no binding and a consumer
2470
+ // that states none were both refused above. Every execution.started this
2471
+ // module writes names the bytes that ran.
2472
+ payload_hash: declaredHash,
2473
+ // APRV-200: whether the tool call that spent this grant is the tool call
2474
+ // that asked for it. Derived here, from the request's own task as the
2475
+ // verified log records it, so the laxer of the two values is unreachable by
2476
+ // assertion alone.
2477
+ [HARNESS_GRANT_ORIGIN]: (options.spendingTask !== undefined &&
2478
+ derivation.task !== null &&
2479
+ options.spendingTask === derivation.task
2480
+ ? "direct"
2481
+ : "carried"),
2482
+ };
2483
+ // APRV-287. A carried spend records WHICH tool call spent it, so the
2484
+ // completion counterpart can find this start from the event that reports how
2485
+ // that tool call went. Written only where the two differ: on a direct spend
2486
+ // the record's own `task` already names it, and a duplicate field would be a
2487
+ // second place for the same fact to be read from.
2488
+ if (options.spendingTask !== undefined &&
2489
+ options.spendingTask.length > 0 &&
2490
+ options.spendingTask !== derivation.task) {
2491
+ payload[HARNESS_SPENDING_TASK] = options.spendingTask;
2492
+ }
2493
+ if (derivation.decisionSeq !== null)
2494
+ payload["grant_seq"] = derivation.decisionSeq;
2495
+ const appended = append(logPath, {
2496
+ ts,
2497
+ event: "execution.started",
2498
+ actor,
2499
+ task: derivation.task,
2500
+ action_key: actionKey,
2501
+ payload,
2502
+ }, options,
2503
+ // The head read at the top: single-use, liveness and the harness marker
2504
+ // were all judged against exactly that log, so a competing consumer that
2505
+ // landed in between wins and this one is refused `head-moved`.
2506
+ read.head);
2507
+ if (!appended.ok)
2508
+ return appended;
2509
+ return { ok: true, record: appended.record };
2510
+ }
2511
+ /**
2512
+ * Charge and record a harness execution that no human was asked about
2513
+ * (APRV-141).
2514
+ *
2515
+ * ## The blind spot this closes
2516
+ *
2517
+ * `core/budgets.ts` computes consumption from `approval.granted` and
2518
+ * `execution.started`, and `core/audit.ts` draws its retrospective sample from
2519
+ * `execution.started` alone. The harness hook wrote neither for a supervised or
2520
+ * autonomous verdict — the comment said, correctly, that writing one per agent
2521
+ * action fills the log — so under Claude Code the majority of real activity
2522
+ * consumed no budget, `daily_actions` included, and was invisible to the
2523
+ * overseer that exists to read a sample of it. A budget that the busiest
2524
+ * execution path does not charge is not a budget, and the decision recorded on
2525
+ * APRV-141 is that the log volume is the lesser cost.
2526
+ *
2527
+ * ## Why this record and not a new event type
2528
+ *
2529
+ * It is the same `execution.started` {@link consumeHarnessGrant} appends, with
2530
+ * the same `execution: "harness"` marker saying why no `execution.completed` or
2531
+ * `execution.failed` will ever follow: the harness runs the command and this
2532
+ * runtime never observes an exit status. Reusing the shape means budgets and
2533
+ * audit count these without learning a second vocabulary, and the gate's
2534
+ * existing single-use rule (a key with an `execution.started` is
2535
+ * `already-executed`) applies unchanged. What differs is only the authorization
2536
+ * being recorded: there, a human's grant; here, the policy itself.
2537
+ *
2538
+ * ## What it refuses
2539
+ *
2540
+ * The same two facts the hook's own guard checks and `core/execute.ts` checks
2541
+ * before an unattended start — attestation and loop-escalation — re-checked at
2542
+ * the write boundary against the records this append is authorized by, plus the
2543
+ * budget verdict this record is the charge for. A class that resolves `manual`
2544
+ * is refused outright: a manual action is authorized by a grant and spent
2545
+ * through {@link consumeHarnessGrant} or a token, and admitting one here would
2546
+ * be a second, unapproved spender.
2547
+ *
2548
+ * Since APRV-146 the content binding is refused here too. `payload-hash-required`
2549
+ * says the caller named no bytes, and it is a refusal rather than an omitted
2550
+ * field because a start event with no `payload_hash` is a record that says
2551
+ * something ran without saying what — the state APRV-140 closed everywhere else.
2552
+ */
2553
+ export function startHarnessExecution(logPath, input, actor, options = {}) {
2554
+ // APRV-150, and the writer the incident was reported against. This is the
2555
+ // busiest append in the system — one per class per gated tool call, most of
2556
+ // them autonomous — so it is the one most likely to lose a benign race, and a
2557
+ // lost race denied a command no human had any question about. Each attempt
2558
+ // re-runs all of it: attestation, resolution, escalation, the loop floor, the
2559
+ // single-use scan and the budget verdict, against the head it appends on.
2560
+ return withHeadMovedRetry(options, () => attemptHarnessStart(logPath, input, actor, options));
2561
+ }
2562
+ function attemptHarnessStart(logPath, input, actor, options) {
2563
+ const ts = tick(options);
2564
+ if (!PRINCIPAL_ACTOR.test(actor)) {
2565
+ return refuse("actor-invalid", `recording a harness execution requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
2566
+ }
2567
+ const read = readGateRecords(logPath);
2568
+ if (!read.ok)
2569
+ return read;
2570
+ // One read of the policy file for the whole operation (APRV-142): the same
2571
+ // bytes are hashed for attestation and parsed for the decision.
2572
+ const policyRead = readPolicyOnce(options);
2573
+ const attested = requireAttestation(read.records, policyRead);
2574
+ if (!attested.ok)
2575
+ return attested;
2576
+ const load = parsePolicy(policyRead, options);
2577
+ const resolution = resolve(load, input.cls);
2578
+ // APRV-185, and the belt to the hook's braces exactly as the loop floor below
2579
+ // is: `approval hook claude-code` denies a human-only class before the harness
2580
+ // ever runs the command, and a caller that reaches this write boundary without
2581
+ // asking the hook first must not be able to record an execution in a class no
2582
+ // agent may execute. Checked before `manual`, because the two refusals say
2583
+ // different things: that one says a human's grant authorizes this, and this
2584
+ // one says nothing authorizes it here at all.
2585
+ if (resolution.autonomy === "human-only") {
2586
+ return refuse("class-human-only", humanOnlyRefusal(input.cls, `the harness execution of ${input.actionKey} may not be recorded and no execution.started was written`));
2587
+ }
2588
+ if (resolution.autonomy === "manual") {
2589
+ return refuse("not-granted", `class ${input.cls} resolves to manual (${resolution.provenance}), and a manual action is authorized by a human's grant rather than by the policy. Request it and spend the grant; this path records only the executions the policy itself authorized.`);
2590
+ }
2591
+ if (isLoopEscalated(read.records, input.task)) {
2592
+ return refuse("loop-escalated", `loop-escalated: task ${input.task} has three consecutive failed side-effecting executions and is escalated to manual (amended SPEC.md §10.2), so its ${resolution.autonomy} actions may not start unsupervised. ${loopClearance("task", input.task)}.`);
2593
+ }
2594
+ // APRV-145, the harness scopes of the amended §10.2, re-checked at the write
2595
+ // boundary. The hook applies the floor before it gets here — an escalated
2596
+ // session's command is routed to the human gate rather than recorded as
2597
+ // unattended — and this is the belt to that pair of braces: a caller that
2598
+ // reaches this function without asking the hook first must not be able to
2599
+ // record an unattended harness execution for a session or an actor that is
2600
+ // three failed tool calls deep. A check in the hook alone is a check-then-
2601
+ // append with a window in it (§11.1 invariant 5).
2602
+ //
2603
+ // APRV-297 narrows it exactly as the hook narrows its own: a class that only
2604
+ // READS is outside the floor. The floor bounds the harm of an agent retrying a
2605
+ // side effect that keeps failing, and a read cannot cause that harm, so
2606
+ // refusing to record one buys no safety and takes away the session's ability
2607
+ // to find out what is wrong. The predicate is `core/loop.ts`'s own, the same
2608
+ // one that decides what accrues, so what the floor counts and what it refuses
2609
+ // cannot come apart; a class this build has never heard of is side-effecting
2610
+ // by construction and is refused here as it always was.
2611
+ const floor = harnessLoopFloor(read.records, input.task, actor);
2612
+ if (floor !== null && isSideEffectingClass(input.cls)) {
2613
+ return refuse("loop-escalated", `loop-escalated: ${floor.scope} ${floor.key} has ${String(floor.consecutiveFailures)} consecutive failed side-effecting harness tool calls and is floored to manual (amended SPEC.md §10.2), so ${input.actionKey} may not be recorded as an unattended execution. Route the command through the human gate; ${loopClearance(floor.scope, floor.key)}`);
2614
+ }
2615
+ for (const record of read.records) {
2616
+ if (record.action_key !== input.actionKey)
2617
+ continue;
2618
+ if (record.event !== "execution.started")
2619
+ continue;
2620
+ return refuse("already-executed", `action ${input.actionKey} already started at seq ${record.seq}; an idempotency key is single-use`);
2621
+ }
2622
+ // The content binding, REQUIRED (APRV-146). Until this it was recorded only
2623
+ // when a caller happened to supply one, so APRV-140's rule — every
2624
+ // `execution.started` names the bytes that ran — reached `approval run` and
2625
+ // stopped at the harness path, which is where most of the executions in this
2626
+ // repository's own log are written. Checked after the free checks and BEFORE
2627
+ // the budget evaluation, because a budget refusal WRITES and this one must
2628
+ // leave the log exactly as it found it.
2629
+ const bytes = input.payload_hash;
2630
+ if (!isPayloadHash(bytes)) {
2631
+ return refuse("payload-hash-required", `recording a harness execution of ${input.actionKey} requires the payload_hash of what is about to run (amended SPEC.md §6.2, APRV-140), and this caller presented ${bytes === undefined ? "none" : JSON.stringify(bytes)}. A start event that cannot state its bytes says only that something ran; the harness computes the hash of the payload it is about to execute and passes it here. Nothing was appended.`);
2632
+ }
2633
+ const cost = costOf(input.est_cost_usd);
2634
+ const budget = evaluateBudgetsWithTask(read.records, budgetScopeOf(load, resolution), { class: input.cls, est_cost_usd: cost }, ts, input.task);
2635
+ if (!budget.pass) {
2636
+ const failed = budget.verdicts.filter((verdict) => !verdict.pass);
2637
+ const logged = append(logPath, {
2638
+ ts,
2639
+ event: "budget.exceeded",
2640
+ actor,
2641
+ task: input.task,
2642
+ action_key: input.actionKey,
2643
+ payload: {
2644
+ class: input.cls,
2645
+ est_cost_usd: cost,
2646
+ stage: "execution",
2647
+ verdicts: budget.verdicts,
2648
+ },
2649
+ }, options, read.head);
2650
+ const message = `budget refused the execution: ${failed
2651
+ .map((verdict) => `${verdict.limit} (${verdict.scope})`)
2652
+ .join(", ")}`;
2653
+ return logged.ok
2654
+ ? refuse("budget-exceeded", message, { verdicts: failed, record: logged.record })
2655
+ : refuse("budget-exceeded", `${message}; the budget.exceeded event could not be appended: ${logged.message}`, { verdicts: failed });
2656
+ }
2657
+ const payload = {
2658
+ // The budgets contract: class and est_cost_usd on every start event.
2659
+ class: input.cls,
2660
+ est_cost_usd: cost,
2661
+ // Why no completion will ever follow (see `consumeHarnessGrant`).
2662
+ execution: "harness",
2663
+ // APRV-146: unconditional, because a caller that states no bytes was refused
2664
+ // above. The record says what ran, not only that something did.
2665
+ payload_hash: bytes,
2666
+ };
2667
+ const appended = append(logPath, {
2668
+ ts,
2669
+ event: "execution.started",
2670
+ actor,
2671
+ task: input.task,
2672
+ action_key: input.actionKey,
2673
+ payload,
2674
+ }, options,
2675
+ // The head read at the top: attestation, escalation, single-use and the
2676
+ // budget verdict were all judged against exactly that log.
2677
+ read.head);
2678
+ if (!appended.ok)
2679
+ return appended;
2680
+ return { ok: true, record: appended.record };
2681
+ }
2682
+ // ---------------------------------------------------------------------------
2683
+ // finishHarnessExecution — the completion counterpart (APRV-145)
2684
+ // ---------------------------------------------------------------------------
2685
+ /**
2686
+ * Which untrusted reporter asserted a harness outcome. CLOSED, and extended only
2687
+ * by a task that adds the case.
2688
+ *
2689
+ * It names the reporter and reduces nothing: it is a CLAIMED field in the
2690
+ * computed-versus-claimed vocabulary of SPEC.md §9, recorded so a reader can
2691
+ * tell a report from an observation without reading the record's provenance out
2692
+ * of its shape.
2693
+ */
2694
+ export const HARNESS_REPORTERS = ["post-tool-use"];
2695
+ export function isHarnessReporter(value) {
2696
+ return typeof value === "string" && HARNESS_REPORTERS.includes(value);
2697
+ }
2698
+ /**
2699
+ * Close the delegated starts one harness tool call opened, with the outcome the
2700
+ * harness reported (APRV-145, amended SPEC.md §10.2).
2701
+ *
2702
+ * ## Why this is its own surface and not one of the recovery verbs
2703
+ *
2704
+ * APRV-146 made `finishExecution`, `resolveExecution` and `indeterminateExecution`
2705
+ * refuse `execution-delegated` over a harness start, and the reconciliation
2706
+ * recorded on APRV-145 keeps all three refusing it exactly as merged. Those three
2707
+ * write an outcome the RUNTIME observed, or a person did, and a harness start has
2708
+ * neither. This function writes a third thing — an outcome an untrusted reporter
2709
+ * ASSERTED, marked as such on its face — and it is a separate, marked surface so
2710
+ * that the carve-out is one named function a reader can audit rather than a
2711
+ * condition threaded through the human recovery path.
2712
+ *
2713
+ * ## What it will not do
2714
+ *
2715
+ * - **It resolves task and key from the log, never from the report.** The caller
2716
+ * names a session and a tool-use id; this function reads the `execution.started`
2717
+ * records the runtime itself wrote for that task (§11.1 invariant 1). A report
2718
+ * can therefore only ever close an execution this runtime authorized.
2719
+ * - **It refuses a start with no harness marker** (`not-delegated`), so an
2720
+ * untrusted report can never close an `approval run` execution it does not own.
2721
+ * - **It records none of the tool's output text.** §11.1 invariant 3 has no
2722
+ * exception for diagnostics, and a tool's stdout is exactly where a credential
2723
+ * arrives.
2724
+ * - **It takes no timestamp.** `execution.*` is gate-typed, so the refusal is
2725
+ * structural: there is no parameter to pass and the clock is read once here,
2726
+ * as {@link startHarnessExecution} reads it.
2727
+ * - **It requires no attestation and charges no budget.** The counterpart
2728
+ * authorizes nothing (§11.1 invariant 8 does not bind it), and a report of a
2729
+ * FAILURE that an unattested policy could block would be a self-reported field
2730
+ * lowering scrutiny by omission.
2731
+ *
2732
+ * A partial close — some of the tool call's keys settled and some not — is left
2733
+ * as it is found. It over-counts failures and under-counts completions, and both
2734
+ * are the strict direction.
2735
+ */
2736
+ /**
2737
+ * Was this `execution.started` written for the tool call `task` names?
2738
+ * (APRV-287.)
2739
+ *
2740
+ * Two ways to be that tool call, and both are the runtime's own writing. The
2741
+ * record's `task` is the ordinary one. {@link HARNESS_SPENDING_TASK} is the
2742
+ * carried spend: the start sits under the REQUESTING tool call, because that is
2743
+ * where the approval lifecycle lives, and the field names the later tool call
2744
+ * that spent the grant and ran the command. Without the second reading a
2745
+ * granted retry could never be closed, so its completion could never clear the
2746
+ * loop floor the refusal text promises it clears.
2747
+ */
2748
+ function startsToolCall(record, task) {
2749
+ if (record.task === task)
2750
+ return true;
2751
+ return payloadOf(record)[HARNESS_SPENDING_TASK] === task;
2752
+ }
2753
+ export function finishHarnessExecution(logPath, input, actor, options = {}) {
2754
+ if (!PRINCIPAL_ACTOR.test(actor)) {
2755
+ return refuse("actor-invalid", `reporting a harness outcome requires a human: or agent: actor, got ${JSON.stringify(actor)}. The runtime did not observe this exit; the party that did must be named on the record.`);
2756
+ }
2757
+ const task = `${HARNESS_TASK_PREFIX}${input.sessionId}:${input.toolUseId}`;
2758
+ const survey = readGateRecords(logPath);
2759
+ if (!survey.ok)
2760
+ return survey;
2761
+ /** Every action key this task started, and whether it is delegated and open. */
2762
+ const started = new Map();
2763
+ for (const record of survey.records) {
2764
+ const key = record.action_key;
2765
+ if (typeof key !== "string" || key.length === 0)
2766
+ continue;
2767
+ if (record.event === "execution.started") {
2768
+ if (!startsToolCall(record, task)) {
2769
+ started.delete(key);
2770
+ continue;
2771
+ }
2772
+ started.set(key, {
2773
+ harness: payloadOf(record)["execution"] === "harness",
2774
+ open: true,
2775
+ seq: record.seq,
2776
+ });
2777
+ continue;
2778
+ }
2779
+ if (record.event === "execution.completed" ||
2780
+ record.event === "execution.failed" ||
2781
+ record.event === "execution.indeterminate" ||
2782
+ record.event === "execution.reconciled") {
2783
+ const entry = started.get(key);
2784
+ if (entry !== undefined)
2785
+ entry.open = false;
2786
+ }
2787
+ }
2788
+ const delegated = [...started.entries()].filter(([, entry]) => entry.harness);
2789
+ if (delegated.length === 0) {
2790
+ return refuse("not-delegated", started.size === 0
2791
+ ? `no execution.started record names task ${task}, so this report closes nothing. A harness outcome may only close an execution this runtime authorized, and the task and the action key are read from the log rather than from the report (SPEC.md §10.2, §11.1 invariant 1). Nothing was appended.`
2792
+ : `task ${task} started ${String(started.size)} execution(s) and none carries execution: "harness", so none of them is a harness's to close. An outcome reported from the harness side may not be written over an execution this runtime is watching itself; \`approval execution resolve\` is the human recovery verb for those. Nothing was appended.`);
2793
+ }
2794
+ const open = delegated.filter(([, entry]) => entry.open).map(([key]) => key);
2795
+ if (open.length === 0) {
2796
+ return refuse("already-finished", `every delegated execution of task ${task} already carries an outcome; an execution has exactly one. Nothing was appended.`);
2797
+ }
2798
+ const event = input.outcome === "completed" ? "execution.completed" : "execution.failed";
2799
+ const exitCode = input.exitCode ?? null;
2800
+ const appended = [];
2801
+ for (const actionKey of open) {
2802
+ // One read per append, and the append carries the head that read observed:
2803
+ // the delegated-and-open judgment is re-made against exactly the log this
2804
+ // record chains onto (§11.1 invariant 5). The first append moves the head,
2805
+ // so a single head reused across the loop would refuse every record after
2806
+ // the first.
2807
+ //
2808
+ // APRV-236's bounded retry is applied HERE, per key, rather than around the
2809
+ // whole verb. This loop appends one record per open delegated execution, and
2810
+ // re-entering the verb after some of them landed would find those keys
2811
+ // already closed and could report `already-finished` for a call that in fact
2812
+ // wrote records. The per-key read-check-append IS the cycle, so retrying it
2813
+ // is exactly the unit the helper is for, and every counterpart already
2814
+ // appended stands untouched.
2815
+ const step = withHeadMovedRetry(options, () => attemptFinishOne(logPath, task, actionKey, event, exitCode, actor, input, appended.length, options));
2816
+ if (!step.ok)
2817
+ return step;
2818
+ if (step.record !== undefined)
2819
+ appended.push(step.record);
2820
+ }
2821
+ if (appended.length === 0) {
2822
+ return refuse("already-finished", `every delegated execution of task ${task} already carries an outcome; an execution has exactly one. Nothing was appended.`);
2823
+ }
2824
+ return { ok: true, task, records: appended };
2825
+ }
2826
+ function attemptFinishOne(logPath, task, actionKey, event, exitCode, actor, input, alreadyAppended, options) {
2827
+ const read = readGateRecords(logPath);
2828
+ if (!read.ok)
2829
+ return read;
2830
+ let harness = false;
2831
+ let stillOpen = false;
2832
+ for (const record of read.records) {
2833
+ if (record.action_key !== actionKey)
2834
+ continue;
2835
+ if (record.event === "execution.started") {
2836
+ harness = startsToolCall(record, task) && payloadOf(record)["execution"] === "harness";
2837
+ stillOpen = harness;
2838
+ continue;
2839
+ }
2840
+ if (record.event === "execution.completed" ||
2841
+ record.event === "execution.failed" ||
2842
+ record.event === "execution.indeterminate" ||
2843
+ record.event === "execution.reconciled") {
2844
+ stillOpen = false;
2845
+ }
2846
+ }
2847
+ if (!harness) {
2848
+ return refuse("not-delegated", `action ${actionKey} is no longer a delegated start of task ${task}; the log moved under this report. ${String(alreadyAppended)} counterpart(s) were appended before it and stand.`);
2849
+ }
2850
+ if (!stillOpen)
2851
+ return { ok: true };
2852
+ const result = append(logPath, {
2853
+ ts: tick(options),
2854
+ event,
2855
+ actor,
2856
+ task,
2857
+ action_key: actionKey,
2858
+ payload: {
2859
+ // The same marker the start carries: this record is about a command the
2860
+ // harness ran, and says so on its face.
2861
+ execution: "harness",
2862
+ // WHO asserted it. A closed code, and nothing of what the tool printed.
2863
+ reported_by: input.reportedBy,
2864
+ exit_code: exitCode,
2865
+ },
2866
+ }, options, read.head);
2867
+ if (!result.ok)
2868
+ return result;
2869
+ return { ok: true, record: result.record };
2870
+ }
2871
+ /** The shared append used by both `expire` and `decide`'s lazy materialisation. */
2872
+ function appendExpiry(logPath, derivation, load, ts, options, expectedHead) {
2873
+ const payload = {};
2874
+ if (derivation.requestTs !== null)
2875
+ payload["requested_ts"] = derivation.requestTs;
2876
+ const ttlMs = ttlOf(load);
2877
+ if (ttlMs !== null)
2878
+ payload["ttl_ms"] = ttlMs;
2879
+ const onExpiry = load.ok ? load.policy.defaults?.on_expiry : undefined;
2880
+ if (onExpiry !== undefined)
2881
+ payload["on_expiry"] = onExpiry;
2882
+ if (derivation.declared.class !== null)
2883
+ payload["class"] = derivation.declared.class;
2884
+ return append(logPath, {
2885
+ ts,
2886
+ event: "approval.expired",
2887
+ // SPEC.md §8: `system:` is for runtime-originated events, and expiry is
2888
+ // the example the spec itself gives. No human acted; the clock did.
2889
+ actor: EXPIRY_ACTOR,
2890
+ ...(derivation.task === null ? {} : { task: derivation.task }),
2891
+ action_key: derivation.actionKey,
2892
+ payload,
2893
+ }, options, expectedHead);
2894
+ }
2895
+ /**
2896
+ * Append `approval.expired` for a live request whose TTL has lapsed.
2897
+ *
2898
+ * The system verb: no human decides an expiry, so the actor is
2899
+ * {@link EXPIRY_ACTOR} and there is no identity to resolve. Used by the daemon's
2900
+ * sweep (M5) and by tests; `decide` performs the same append itself when it
2901
+ * discovers a lapse first.
2902
+ *
2903
+ * Refuses when the request is not live (`not-requested`, `already-decided`) or
2904
+ * when the TTL has not lapsed (`not-expired`, which also covers a policy that
2905
+ * declares no `defaults.approval_ttl` — no TTL means no lapse, and expiring a
2906
+ * request the policy never bounded would be the runtime inventing a deadline).
2907
+ *
2908
+ * `defaults.on_expiry` is recorded in the payload. Its only v0.1 value,
2909
+ * `reject`, does not change the mechanics here — an expired request is terminal
2910
+ * either way — it tells the projection layer to render the envelope's `state:`
2911
+ * as `rejected`.
2912
+ */
2913
+ export function expire(logPath, actionKey, options = {}) {
2914
+ const ts = tick(options);
2915
+ const read = readGateRecords(logPath);
2916
+ if (!read.ok)
2917
+ return read;
2918
+ const load = parsePolicy(readPolicyOnce(options), options);
2919
+ const ttlMs = ttlOf(load);
2920
+ const derivation = requestState(read.records, actionKey, ts, ttlMs);
2921
+ if (derivation.state === "none") {
2922
+ return refuse("not-requested", `action ${actionKey} has no approval.requested record to expire`, { state: derivation.state });
2923
+ }
2924
+ if (derivation.expiredByEvent) {
2925
+ return refuse("already-decided", `action ${actionKey} already has an approval.expired record at seq ${String(derivation.decisionSeq)}`, { state: derivation.state });
2926
+ }
2927
+ if (derivation.state !== "expired") {
2928
+ if (derivation.state !== "requested") {
2929
+ return refuse("already-decided", `action ${actionKey} was already ${derivation.state} at seq ${String(derivation.decisionSeq)}; only a live request can expire`, { state: derivation.state });
2930
+ }
2931
+ return refuse("not-expired", ttlMs === null
2932
+ ? `action ${actionKey} cannot expire: the policy declares no defaults.approval_ttl, so the request is not bounded by a TTL`
2933
+ : `action ${actionKey} has not expired: the request at ${String(derivation.requestTs)} has not lapsed its ${String(ttlMs)}ms TTL as of ${ts}`, { state: derivation.state });
2934
+ }
2935
+ const expired = appendExpiry(logPath, derivation, load, ts, options, read.head);
2936
+ if (expired.ok) {
2937
+ // APRV-105, the third and last death of a delivery address. A lapsed request
2938
+ // can never be granted, so the private key that would have opened its token
2939
+ // opens nothing; keeping it would be keeping a decryption capability for a
2940
+ // ciphertext that may not even exist. Best effort and never fatal: the
2941
+ // expiry is the record that matters, and a key file that survives a failed
2942
+ // unlink is inert.
2943
+ forgetPrivateKey(options.keyStoreDir ?? keyStoreDirFor(logPath), actionKey);
2944
+ }
2945
+ return expired;
2946
+ }
2947
+ //# sourceMappingURL=gate.js.map