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