approval-md 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (522) hide show
  1. package/LICENSE +176 -0
  2. package/NOTICE +5 -0
  3. package/README.md +909 -4
  4. package/SPEC.md +445 -32
  5. package/cli.js +29 -3
  6. package/dist/src/adapters/agentmail.js +1200 -0
  7. package/dist/src/adapters/agentmail.js.map +1 -0
  8. package/dist/src/adapters/conformance.js +461 -0
  9. package/dist/src/adapters/conformance.js.map +1 -0
  10. package/dist/src/adapters/contract.js +941 -0
  11. package/dist/src/adapters/contract.js.map +1 -0
  12. package/dist/src/adapters/email.js +749 -0
  13. package/dist/src/adapters/email.js.map +1 -0
  14. package/dist/src/adapters/env-passphrase.js +132 -0
  15. package/dist/src/adapters/env-passphrase.js.map +1 -0
  16. package/dist/src/adapters/registry.js +76 -0
  17. package/dist/src/adapters/registry.js.map +1 -0
  18. package/dist/src/adapters/smtp.js +499 -0
  19. package/dist/src/adapters/smtp.js.map +1 -0
  20. package/dist/src/adapters/vault-provider.js +161 -0
  21. package/dist/src/adapters/vault-provider.js.map +1 -0
  22. package/dist/src/channels/batch.js +121 -0
  23. package/dist/src/channels/batch.js.map +1 -0
  24. package/dist/src/channels/cli.js +468 -0
  25. package/dist/src/channels/cli.js.map +1 -0
  26. package/dist/src/channels/conformance.js +445 -0
  27. package/dist/src/channels/conformance.js.map +1 -0
  28. package/dist/src/channels/contract.js +494 -0
  29. package/dist/src/channels/contract.js.map +1 -0
  30. package/dist/src/channels/payload-view.js +43 -0
  31. package/dist/src/channels/payload-view.js.map +1 -0
  32. package/dist/src/channels/render-queue.js +564 -0
  33. package/dist/src/channels/render-queue.js.map +1 -0
  34. package/dist/src/channels/tagging.js +723 -0
  35. package/dist/src/channels/tagging.js.map +1 -0
  36. package/dist/src/channels/telegram.js +3190 -0
  37. package/dist/src/channels/telegram.js.map +1 -0
  38. package/dist/src/channels/web.js +903 -0
  39. package/dist/src/channels/web.js.map +1 -0
  40. package/dist/src/cli/adapter.js +278 -0
  41. package/dist/src/cli/adapter.js.map +1 -0
  42. package/dist/src/cli/amend.js +2171 -0
  43. package/dist/src/cli/amend.js.map +1 -0
  44. package/dist/src/cli/args.js +86 -0
  45. package/dist/src/cli/args.js.map +1 -0
  46. package/dist/src/cli/attest.js +307 -0
  47. package/dist/src/cli/attest.js.map +1 -0
  48. package/dist/src/cli/audit-card.js +201 -0
  49. package/dist/src/cli/audit-card.js.map +1 -0
  50. package/dist/src/cli/audit.js +460 -0
  51. package/dist/src/cli/audit.js.map +1 -0
  52. package/dist/src/cli/channel-telegram.js +2063 -0
  53. package/dist/src/cli/channel-telegram.js.map +1 -0
  54. package/dist/src/cli/channel-web.js +357 -0
  55. package/dist/src/cli/channel-web.js.map +1 -0
  56. package/dist/src/cli/channel.js +438 -0
  57. package/dist/src/cli/channel.js.map +1 -0
  58. package/dist/src/cli/checkpoint-tap.js +238 -0
  59. package/dist/src/cli/checkpoint-tap.js.map +1 -0
  60. package/dist/src/cli/coverage.js +343 -0
  61. package/dist/src/cli/coverage.js.map +1 -0
  62. package/dist/src/cli/daemon.js +631 -0
  63. package/dist/src/cli/daemon.js.map +1 -0
  64. package/dist/src/cli/doctor.js +2648 -0
  65. package/dist/src/cli/doctor.js.map +1 -0
  66. package/dist/src/cli/env.js +302 -0
  67. package/dist/src/cli/env.js.map +1 -0
  68. package/dist/src/cli/execute.js +1682 -0
  69. package/dist/src/cli/execute.js.map +1 -0
  70. package/dist/src/cli/exit-codes.js +82 -0
  71. package/dist/src/cli/exit-codes.js.map +1 -0
  72. package/dist/src/cli/feedback.js +205 -0
  73. package/dist/src/cli/feedback.js.map +1 -0
  74. package/dist/src/cli/gate-window.js +294 -0
  75. package/dist/src/cli/gate-window.js.map +1 -0
  76. package/dist/src/cli/gate.js +557 -0
  77. package/dist/src/cli/gate.js.map +1 -0
  78. package/dist/src/cli/git-scope.js +295 -0
  79. package/dist/src/cli/git-scope.js.map +1 -0
  80. package/dist/src/cli/gloss-attach.js +107 -0
  81. package/dist/src/cli/gloss-attach.js.map +1 -0
  82. package/dist/src/cli/gloss-codex-child.js +149 -0
  83. package/dist/src/cli/gloss-codex-child.js.map +1 -0
  84. package/dist/src/cli/gloss-codex.js +255 -0
  85. package/dist/src/cli/gloss-codex.js.map +1 -0
  86. package/dist/src/cli/gloss-options.js +79 -0
  87. package/dist/src/cli/gloss-options.js.map +1 -0
  88. package/dist/src/cli/gloss.js +362 -0
  89. package/dist/src/cli/gloss.js.map +1 -0
  90. package/dist/src/cli/help.js +2217 -0
  91. package/dist/src/cli/help.js.map +1 -0
  92. package/dist/src/cli/hook.js +2743 -0
  93. package/dist/src/cli/hook.js.map +1 -0
  94. package/dist/src/cli/import.js +175 -0
  95. package/dist/src/cli/import.js.map +1 -0
  96. package/dist/src/cli/init.js +336 -0
  97. package/dist/src/cli/init.js.map +1 -0
  98. package/dist/src/cli/instructions.js +262 -0
  99. package/dist/src/cli/instructions.js.map +1 -0
  100. package/dist/src/cli/journal.js +238 -0
  101. package/dist/src/cli/journal.js.map +1 -0
  102. package/dist/src/cli/log-advance.js +749 -0
  103. package/dist/src/cli/log-advance.js.map +1 -0
  104. package/dist/src/cli/log-anchor.js +387 -0
  105. package/dist/src/cli/log-anchor.js.map +1 -0
  106. package/dist/src/cli/log-checkpoint.js +128 -0
  107. package/dist/src/cli/log-checkpoint.js.map +1 -0
  108. package/dist/src/cli/log-sync.js +849 -0
  109. package/dist/src/cli/log-sync.js.map +1 -0
  110. package/dist/src/cli/log-verbs.js +354 -0
  111. package/dist/src/cli/log-verbs.js.map +1 -0
  112. package/dist/src/cli/long-help.js +148 -0
  113. package/dist/src/cli/long-help.js.map +1 -0
  114. package/dist/src/cli/main.js +1056 -0
  115. package/dist/src/cli/main.js.map +1 -0
  116. package/dist/src/cli/mcp.js +306 -0
  117. package/dist/src/cli/mcp.js.map +1 -0
  118. package/dist/src/cli/paths.js +79 -0
  119. package/dist/src/cli/paths.js.map +1 -0
  120. package/dist/src/cli/payload.js +253 -0
  121. package/dist/src/cli/payload.js.map +1 -0
  122. package/dist/src/cli/policy.js +229 -0
  123. package/dist/src/cli/policy.js.map +1 -0
  124. package/dist/src/cli/preflight.js +888 -0
  125. package/dist/src/cli/preflight.js.map +1 -0
  126. package/dist/src/cli/progress.js +112 -0
  127. package/dist/src/cli/progress.js.map +1 -0
  128. package/dist/src/cli/prompt.js +312 -0
  129. package/dist/src/cli/prompt.js.map +1 -0
  130. package/dist/src/cli/records.js +66 -0
  131. package/dist/src/cli/records.js.map +1 -0
  132. package/dist/src/cli/render.js +132 -0
  133. package/dist/src/cli/render.js.map +1 -0
  134. package/dist/src/cli/sandbox.js +150 -0
  135. package/dist/src/cli/sandbox.js.map +1 -0
  136. package/dist/src/cli/scaffold.js +137 -0
  137. package/dist/src/cli/scaffold.js.map +1 -0
  138. package/dist/src/cli/setup-adapter.js +475 -0
  139. package/dist/src/cli/setup-adapter.js.map +1 -0
  140. package/dist/src/cli/setup-channel.js +635 -0
  141. package/dist/src/cli/setup-channel.js.map +1 -0
  142. package/dist/src/cli/setup-checkpoint.js +196 -0
  143. package/dist/src/cli/setup-checkpoint.js.map +1 -0
  144. package/dist/src/cli/setup-common.js +376 -0
  145. package/dist/src/cli/setup-common.js.map +1 -0
  146. package/dist/src/cli/setup-flow.js +476 -0
  147. package/dist/src/cli/setup-flow.js.map +1 -0
  148. package/dist/src/cli/setup-service.js +308 -0
  149. package/dist/src/cli/setup-service.js.map +1 -0
  150. package/dist/src/cli/setup.js +473 -0
  151. package/dist/src/cli/setup.js.map +1 -0
  152. package/dist/src/cli/style.js +469 -0
  153. package/dist/src/cli/style.js.map +1 -0
  154. package/dist/src/cli/token.js +274 -0
  155. package/dist/src/cli/token.js.map +1 -0
  156. package/dist/src/cli/up.js +847 -0
  157. package/dist/src/cli/up.js.map +1 -0
  158. package/dist/src/cli/usage.js +91 -0
  159. package/dist/src/cli/usage.js.map +1 -0
  160. package/dist/src/cli/values.js +189 -0
  161. package/dist/src/cli/values.js.map +1 -0
  162. package/dist/src/cli/vault.js +362 -0
  163. package/dist/src/cli/vault.js.map +1 -0
  164. package/dist/src/cli/verb-registry.js +2173 -0
  165. package/dist/src/cli/verb-registry.js.map +1 -0
  166. package/dist/src/cli/wordmark.js +52 -0
  167. package/dist/src/cli/wordmark.js.map +1 -0
  168. package/dist/src/core/advance-cycle.js +200 -0
  169. package/dist/src/core/advance-cycle.js.map +1 -0
  170. package/dist/src/core/agents-md.js +747 -0
  171. package/dist/src/core/agents-md.js.map +1 -0
  172. package/dist/src/core/attest.js +577 -0
  173. package/dist/src/core/attest.js.map +1 -0
  174. package/dist/src/core/audit.js +882 -0
  175. package/dist/src/core/audit.js.map +1 -0
  176. package/dist/src/core/budgets.js +449 -0
  177. package/dist/src/core/budgets.js.map +1 -0
  178. package/dist/src/core/checkpoint.js +738 -0
  179. package/dist/src/core/checkpoint.js.map +1 -0
  180. package/dist/src/core/child-env.js +86 -0
  181. package/dist/src/core/child-env.js.map +1 -0
  182. package/dist/src/core/clock.js +43 -0
  183. package/dist/src/core/clock.js.map +1 -0
  184. package/dist/src/core/command-class.js +2321 -0
  185. package/dist/src/core/command-class.js.map +1 -0
  186. package/dist/src/core/coverage-sources/adapter.js +71 -0
  187. package/dist/src/core/coverage-sources/adapter.js.map +1 -0
  188. package/dist/src/core/coverage-sources/gh.js +136 -0
  189. package/dist/src/core/coverage-sources/gh.js.map +1 -0
  190. package/dist/src/core/coverage-sources/git.js +269 -0
  191. package/dist/src/core/coverage-sources/git.js.map +1 -0
  192. package/dist/src/core/coverage.js +337 -0
  193. package/dist/src/core/coverage.js.map +1 -0
  194. package/dist/src/core/credential-spec.js +23 -0
  195. package/dist/src/core/credential-spec.js.map +1 -0
  196. package/dist/src/core/dark-session.js +714 -0
  197. package/dist/src/core/dark-session.js.map +1 -0
  198. package/dist/src/core/decision-refusal.js +265 -0
  199. package/dist/src/core/decision-refusal.js.map +1 -0
  200. package/dist/src/core/env-file.js +837 -0
  201. package/dist/src/core/env-file.js.map +1 -0
  202. package/dist/src/core/execute.js +1233 -0
  203. package/dist/src/core/execute.js.map +1 -0
  204. package/dist/src/core/frontmatter.js +100 -0
  205. package/dist/src/core/frontmatter.js.map +1 -0
  206. package/dist/src/core/gate-window.js +506 -0
  207. package/dist/src/core/gate-window.js.map +1 -0
  208. package/dist/src/core/gate.js +2947 -0
  209. package/dist/src/core/gate.js.map +1 -0
  210. package/dist/src/core/git-run.js +93 -0
  211. package/dist/src/core/git-run.js.map +1 -0
  212. package/dist/src/core/harness-version.js +210 -0
  213. package/dist/src/core/harness-version.js.map +1 -0
  214. package/dist/src/core/harness-wait.js +58 -0
  215. package/dist/src/core/harness-wait.js.map +1 -0
  216. package/dist/src/core/head-retry.js +121 -0
  217. package/dist/src/core/head-retry.js.map +1 -0
  218. package/dist/src/core/instance.js +319 -0
  219. package/dist/src/core/instance.js.map +1 -0
  220. package/dist/src/core/intake-limits.js +350 -0
  221. package/dist/src/core/intake-limits.js.map +1 -0
  222. package/dist/src/core/jcs.js +132 -0
  223. package/dist/src/core/jcs.js.map +1 -0
  224. package/dist/src/core/journal.js +200 -0
  225. package/dist/src/core/journal.js.map +1 -0
  226. package/dist/src/core/live-draw.js +703 -0
  227. package/dist/src/core/live-draw.js.map +1 -0
  228. package/dist/src/core/log-reconcile.js +136 -0
  229. package/dist/src/core/log-reconcile.js.map +1 -0
  230. package/dist/src/core/log.js +546 -0
  231. package/dist/src/core/log.js.map +1 -0
  232. package/dist/src/core/loop.js +476 -0
  233. package/dist/src/core/loop.js.map +1 -0
  234. package/dist/src/core/md-fence.js +74 -0
  235. package/dist/src/core/md-fence.js.map +1 -0
  236. package/dist/src/core/money.js +195 -0
  237. package/dist/src/core/money.js.map +1 -0
  238. package/dist/src/core/payload-census.js +146 -0
  239. package/dist/src/core/payload-census.js.map +1 -0
  240. package/dist/src/core/payload-store.js +340 -0
  241. package/dist/src/core/payload-store.js.map +1 -0
  242. package/dist/src/core/payload.js +80 -0
  243. package/dist/src/core/payload.js.map +1 -0
  244. package/dist/src/core/policy-diff.js +565 -0
  245. package/dist/src/core/policy-diff.js.map +1 -0
  246. package/dist/src/core/policy-expectations.js +394 -0
  247. package/dist/src/core/policy-expectations.js.map +1 -0
  248. package/dist/src/core/policy-explain.js +230 -0
  249. package/dist/src/core/policy-explain.js.map +1 -0
  250. package/dist/src/core/policy-load.js +524 -0
  251. package/dist/src/core/policy-load.js.map +1 -0
  252. package/dist/src/core/policy-match.js +467 -0
  253. package/dist/src/core/policy-match.js.map +1 -0
  254. package/dist/src/core/policy-proposal.js +458 -0
  255. package/dist/src/core/policy-proposal.js.map +1 -0
  256. package/dist/src/core/prompt-layout.js +422 -0
  257. package/dist/src/core/prompt-layout.js.map +1 -0
  258. package/dist/src/core/protected-path-guard.js +1087 -0
  259. package/dist/src/core/protected-path-guard.js.map +1 -0
  260. package/dist/src/core/registration.js +39 -0
  261. package/dist/src/core/registration.js.map +1 -0
  262. package/dist/src/core/reindex.js +336 -0
  263. package/dist/src/core/reindex.js.map +1 -0
  264. package/dist/src/core/sampler.js +388 -0
  265. package/dist/src/core/sampler.js.map +1 -0
  266. package/dist/src/core/sandbox.js +424 -0
  267. package/dist/src/core/sandbox.js.map +1 -0
  268. package/dist/src/core/seal.js +290 -0
  269. package/dist/src/core/seal.js.map +1 -0
  270. package/dist/src/core/state.js +1009 -0
  271. package/dist/src/core/state.js.map +1 -0
  272. package/dist/src/core/task-file.js +464 -0
  273. package/dist/src/core/task-file.js.map +1 -0
  274. package/dist/src/core/telegram-config.js +114 -0
  275. package/dist/src/core/telegram-config.js.map +1 -0
  276. package/dist/src/core/token.js +578 -0
  277. package/dist/src/core/token.js.map +1 -0
  278. package/dist/src/core/validate.js +0 -0
  279. package/dist/src/core/validate.js.map +1 -0
  280. package/dist/src/core/values.js +153 -0
  281. package/dist/src/core/values.js.map +1 -0
  282. package/dist/src/core/vault.js +612 -0
  283. package/dist/src/core/vault.js.map +1 -0
  284. package/dist/src/core/verified-snapshot.js +506 -0
  285. package/dist/src/core/verified-snapshot.js.map +1 -0
  286. package/dist/src/core/verify.js +549 -0
  287. package/dist/src/core/verify.js.map +1 -0
  288. package/dist/src/core/version.js +9 -0
  289. package/dist/src/core/version.js.map +1 -0
  290. package/dist/src/core/wysiwys.js +728 -0
  291. package/dist/src/core/wysiwys.js.map +1 -0
  292. package/dist/src/daemon/advance-child.js +78 -0
  293. package/dist/src/daemon/advance-child.js.map +1 -0
  294. package/dist/src/daemon/advance.js +849 -0
  295. package/dist/src/daemon/advance.js.map +1 -0
  296. package/dist/src/daemon/audit.js +90 -0
  297. package/dist/src/daemon/audit.js.map +1 -0
  298. package/dist/src/daemon/daemon.js +1988 -0
  299. package/dist/src/daemon/daemon.js.map +1 -0
  300. package/dist/src/daemon/dark-session.js +119 -0
  301. package/dist/src/daemon/dark-session.js.map +1 -0
  302. package/dist/src/daemon/draw-child.js +132 -0
  303. package/dist/src/daemon/draw-child.js.map +1 -0
  304. package/dist/src/daemon/draw.js +458 -0
  305. package/dist/src/daemon/draw.js.map +1 -0
  306. package/dist/src/daemon/git-evidence.js +345 -0
  307. package/dist/src/daemon/git-evidence.js.map +1 -0
  308. package/dist/src/daemon/projection.js +233 -0
  309. package/dist/src/daemon/projection.js.map +1 -0
  310. package/dist/src/daemon/prune.js +376 -0
  311. package/dist/src/daemon/prune.js.map +1 -0
  312. package/dist/src/mcp/http.js +343 -0
  313. package/dist/src/mcp/http.js.map +1 -0
  314. package/dist/src/mcp/server.js +594 -0
  315. package/dist/src/mcp/server.js.map +1 -0
  316. package/docs/cli-reference.md +5363 -0
  317. package/package.json +43 -4
  318. package/schema/.gitkeep +0 -0
  319. package/schema/LICENSE +117 -0
  320. package/schema/envelope.schema.json +137 -0
  321. package/schema/event.schema.json +1810 -0
  322. package/schema/fixtures/envelope/invalid/action-missing-idempotency-key.json +15 -0
  323. package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
  324. package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
  325. package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
  326. package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
  327. package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
  328. package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
  329. package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
  330. package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
  331. package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
  332. package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
  333. package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
  334. package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
  335. package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
  336. package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
  337. package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
  338. package/schema/fixtures/envelope/valid/canonical.json +25 -0
  339. package/schema/fixtures/envelope/valid/minimal.json +7 -0
  340. package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
  341. package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
  342. package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
  343. package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
  344. package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
  345. package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
  346. package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
  347. package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
  348. package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
  349. package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
  350. package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
  351. package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
  352. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
  353. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
  354. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
  355. package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
  356. package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
  357. package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
  358. package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
  359. package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
  360. package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
  361. package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
  362. package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
  363. package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
  364. package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
  365. package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
  366. package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
  367. package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
  368. package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
  369. package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
  370. package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
  371. package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
  372. package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
  373. package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
  374. package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
  375. package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
  376. package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
  377. package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
  378. package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
  379. package/schema/fixtures/event/invalid/missing-alg.json +14 -0
  380. package/schema/fixtures/event/invalid/missing-hash.json +14 -0
  381. package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
  382. package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
  383. package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
  384. package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
  385. package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
  386. package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
  387. package/schema/fixtures/event/invalid/short-hash.json +15 -0
  388. package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
  389. package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
  390. package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
  391. package/schema/fixtures/event/valid/approval-expired.json +15 -0
  392. package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
  393. package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
  394. package/schema/fixtures/event/valid/approval-granted.json +15 -0
  395. package/schema/fixtures/event/valid/approval-rejected.json +15 -0
  396. package/schema/fixtures/event/valid/approval-requested.json +19 -0
  397. package/schema/fixtures/event/valid/approval-revoked.json +15 -0
  398. package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
  399. package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
  400. package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
  401. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
  402. package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
  403. package/schema/fixtures/event/valid/audit-sampled.json +14 -0
  404. package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
  405. package/schema/fixtures/event/valid/envelope-drift.json +16 -0
  406. package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
  407. package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
  408. package/schema/fixtures/event/valid/execution-completed.json +15 -0
  409. package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
  410. package/schema/fixtures/event/valid/execution-failed.json +15 -0
  411. package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
  412. package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
  413. package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
  414. package/schema/fixtures/event/valid/execution-started.json +14 -0
  415. package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
  416. package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
  417. package/schema/fixtures/event/valid/gate-closed.json +14 -0
  418. package/schema/fixtures/event/valid/gate-opened.json +16 -0
  419. package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
  420. package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
  421. package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
  422. package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
  423. package/schema/fixtures/event/valid/payload-pruned.json +17 -0
  424. package/schema/fixtures/event/valid/policy-declined.json +16 -0
  425. package/schema/fixtures/event/valid/policy-proposed.json +35 -0
  426. package/schema/fixtures/event/valid/policy-updated.json +14 -0
  427. package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
  428. package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
  429. package/schema/fixtures/event/valid/route-accepted.json +15 -0
  430. package/schema/fixtures/event/valid/route-proposed.json +16 -0
  431. package/schema/fixtures/event/valid/spec-example.json +15 -0
  432. package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
  433. package/schema/fixtures/event/valid/task-registered.json +14 -0
  434. package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
  435. package/schema/fixtures/hash/known-answer.json +74 -0
  436. package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
  437. package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
  438. package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
  439. package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
  440. package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
  441. package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
  442. package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
  443. package/schema/fixtures/policy/invalid/missing-version.json +8 -0
  444. package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
  445. package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
  446. package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
  447. package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
  448. package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
  449. package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
  450. package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
  451. package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
  452. package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
  453. package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
  454. package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
  455. package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
  456. package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
  457. package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
  458. package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
  459. package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
  460. package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
  461. package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
  462. package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
  463. package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
  464. package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
  465. package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
  466. package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
  467. package/schema/fixtures/policy/valid/canonical.json +47 -0
  468. package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
  469. package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
  470. package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
  471. package/schema/fixtures/policy/valid/global-budgets.json +19 -0
  472. package/schema/fixtures/policy/valid/human-only.json +9 -0
  473. package/schema/fixtures/policy/valid/minimal.json +6 -0
  474. package/schema/fixtures/policy/valid/protected-paths.json +10 -0
  475. package/schema/fixtures/policy/valid/record-namespace.json +13 -0
  476. package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
  477. package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
  478. package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
  479. package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
  480. package/schema/fixtures/policy/valid/wildcards.json +15 -0
  481. package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
  482. package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
  483. package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
  484. package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
  485. package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
  486. package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
  487. package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
  488. package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
  489. package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
  490. package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
  491. package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
  492. package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
  493. package/schema/fixtures/policy-md/valid/canonical.md +50 -0
  494. package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
  495. package/schema/fixtures/policy-md/valid/minimal.md +3 -0
  496. package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
  497. package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
  498. package/schema/fixtures/policy-md/valid/with-values.md +79 -0
  499. package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
  500. package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
  501. package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
  502. package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
  503. package/schema/fixtures/sample-record/valid/minimal.json +4 -0
  504. package/schema/fixtures/sample-record/valid/with-note.json +5 -0
  505. package/schema/fixtures/values/invalid/class-shaped.json +9 -0
  506. package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
  507. package/schema/fixtures/values/invalid/non-string-item.json +4 -0
  508. package/schema/fixtures/values/invalid/over-cap.json +26 -0
  509. package/schema/fixtures/values/invalid/unknown-key.json +5 -0
  510. package/schema/fixtures/values/invalid/version-string.json +1 -0
  511. package/schema/fixtures/values/valid/empty-lists.json +7 -0
  512. package/schema/fixtures/values/valid/full.json +20 -0
  513. package/schema/fixtures/values/valid/minimal.json +1 -0
  514. package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
  515. package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
  516. package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
  517. package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
  518. package/schema/fixtures/values-md/valid/absent.md +50 -0
  519. package/schema/fixtures/values-md/valid/with-values.md +79 -0
  520. package/schema/policy.schema.json +481 -0
  521. package/schema/sample-record.schema.json +26 -0
  522. package/schema/values.schema.json +55 -0
@@ -0,0 +1,1233 @@
1
+ /**
2
+ * Execution: the events that say a side effect actually happened (SPEC.md §8,
3
+ * §10.1, and the human-settled execution points of 2026-08-06).
4
+ *
5
+ * `core/gate.ts` decides whether an action *may* run and appends no
6
+ * `execution.*` event. `core/token.ts` spends a manual action's token. This
7
+ * module is the single door between those two facts and the world: it is where
8
+ * `execution.started` is appended before a command is spawned, and where
9
+ * `execution.completed` / `execution.failed` are appended after it exits.
10
+ *
11
+ * ## The five properties this module exists to hold
12
+ *
13
+ * 1. **Nothing starts without authorization.** On the manual path a valid token
14
+ * is REQUIRED; absent it, {@link startExecution} refuses `token-required` and
15
+ * appends nothing at all. On the supervised/autonomous paths no token exists
16
+ * (amended SPEC.md §6.3 gives them no grant), so authorization is proven
17
+ * differently and in this order: the action must be declared in a
18
+ * `task.registered` record, the policy must be attested, loop safety must not
19
+ * have escalated the task, no execution may already have started for the key,
20
+ * the executor's recomputed `payload_hash` must equal the one the declaration
21
+ * bound to (APRV-140), and the budget must pass. Only then is
22
+ * `execution.started` appended.
23
+ * 2. **`started` precedes the side effect.** The CLI's `approval run` appends
24
+ * the start event *before* it spawns the child, never after. A log that
25
+ * records an execution only once it succeeded is a log that cannot tell you
26
+ * about the one that did not.
27
+ * 3. **A crash therefore leaves a dangling execution, and that is correct.**
28
+ * Between `started` and its outcome the log honestly says "this began and we
29
+ * do not know how it ended". {@link danglingExecutions} surfaces that state
30
+ * distinctly — `approval status` reports it, `approval queue` does not,
31
+ * because a dangling execution is not a pending decision. It is one of five
32
+ * custody states {@link executionCustody} distinguishes (APRV-120), and the
33
+ * other four matter for the same reason: a harness execution is terminal by
34
+ * design rather than debris, and an attempt whose outcome is unknown is
35
+ * neither a failure nor a thing to retry.
36
+ * 4. **Nothing auto-repairs.** No function here closes a dangling execution as a
37
+ * side effect of anything else. A second `approval run` for the same key does
38
+ * not "recover" the first; it refuses (`token-consumed` on the manual path,
39
+ * `already-executed` off it). Recovery is a human calling
40
+ * {@link resolveExecution} with the outcome they actually observed and a
41
+ * mandatory note saying how they know — the same append path, no fabricated
42
+ * exit code, `attested_by_human: true` so no reader mistakes it for a
43
+ * machine's report. ({@link finishExecution} is the mechanical sibling, used
44
+ * by `approval run`, which watched the child exit.) An automatic
45
+ * reconciliation would have to *guess* whether the email went out, and a
46
+ * guess written into an append-only log is indistinguishable from a fact.
47
+ * The same rule governs an INDETERMINATE outcome, more strictly:
48
+ * {@link reconcileExecution} is human-only, appends beside the record rather
49
+ * than over it, and nothing anywhere converts one into completed or failed.
50
+ * 5. **The budgets contract is honored at the documented charge point.**
51
+ * `core/budgets.ts` charges the manual path at `approval.granted` and the
52
+ * supervised/autonomous paths at `execution.started`. This module is that
53
+ * second charge point: it evaluates budgets at the start timestamp, appends
54
+ * `budget.exceeded` and refuses when they fail, and records
55
+ * `payload.class` + `payload.est_cost_usd` on every start event it writes.
56
+ * The manual path is charged at grant and is deliberately NOT charged again
57
+ * here — `consumeToken` writes that start event, and the evaluator already
58
+ * ignores a start whose window holds a matching grant.
59
+ *
60
+ * ## Loop safety (SPEC.md §10.2), from this side
61
+ *
62
+ * Three consecutive `execution.failed` events for one task escalate it to
63
+ * manual. `core/loop.ts` computes that; this module enforces it on the
64
+ * execution side: an escalated task's supervised/autonomous action refuses with
65
+ * `loop-escalated`, which is not a ban but a redirection — request the action,
66
+ * have a human grant it, and run it with the token. `core/gate.ts` enforces the
67
+ * matching half at intake so the redirection is visible one step earlier.
68
+ *
69
+ * ## Time (amended SPEC.md §8, A2)
70
+ *
71
+ * `execution.*` events are gate-typed, so their timestamps are assigned by the
72
+ * runtime at the write boundary: no public function here takes a `ts`, each
73
+ * reads {@link ExecuteOptions.clock} once, and the party whose budget window
74
+ * and TTL are being judged does not author the clock. Replay is preserved by
75
+ * injection — a test hands in a fixed clock, production hands in nothing.
76
+ */
77
+ import { existsSync } from "node:fs";
78
+ import { join } from "node:path";
79
+ import { attestationRefusal, checkAttestation } from "./attest.js";
80
+ import { evaluateBudgetsWithTask } from "./budgets.js";
81
+ import { tick } from "./clock.js";
82
+ import { attemptsOf, withHeadRetry } from "./head-retry.js";
83
+ import { appendEvent, } from "./log.js";
84
+ import { isLoopEscalated } from "./loop.js";
85
+ import { usdOrZero } from "./money.js";
86
+ import { isPayloadHash } from "./payload.js";
87
+ import { loadPolicy, POLICY_FILENAMES } from "./policy-load.js";
88
+ import { humanOnlyRefusal, resolve } from "./policy-match.js";
89
+ import { readVerifiedRecords } from "./state.js";
90
+ import { forgetPrivateKey, keyStoreDirFor } from "./seal.js";
91
+ import { consumeToken, deliveredToken } from "./token.js";
92
+ export { isLoopEscalated, loopEscalation, LOOP_ESCALATION_THRESHOLD, } from "./loop.js";
93
+ /**
94
+ * The closed set of execution refusal codes. Frozen public API in the same sense
95
+ * the gate's and the token module's are: an agent branches on these to decide
96
+ * whether to fix itself, ask a human, or stop.
97
+ *
98
+ * The five token codes are re-exposed verbatim rather than collapsed into one:
99
+ * `approval run` on the manual path is a token spend, and "you presented no
100
+ * token" (`token-required`), "you presented the wrong one" (`token-mismatch`),
101
+ * and "it was already spent" (`token-consumed`) call for three different
102
+ * responses.
103
+ */
104
+ export const EXECUTE_REFUSAL_CODES = [
105
+ /** No `task.registered` record declares this action key (SPEC.md §7). */
106
+ "action-not-registered",
107
+ /**
108
+ * The action's class resolves to `human-only` (APRV-185, amended SPEC.md
109
+ * §5.2): the policy reserves it to human hands, and a person performs it
110
+ * outside agent execution entirely.
111
+ *
112
+ * Refused on BOTH paths, before either is chosen, which is what separates it
113
+ * from `token-required`. That code is a redirection — get a token and come
114
+ * back — and this one is not: there is no token to get and no grant that
115
+ * could mint one, because `core/gate.ts` refuses the request that would open
116
+ * one under the same code. Nothing is appended on either path, and no retry
117
+ * of any shape changes the answer.
118
+ *
119
+ * Surfaced verbatim from `core/token.ts` as well, for a manual-path spend of
120
+ * a token whose class a policy amendment raised after the grant, so an
121
+ * executor meets one spelling of one fact.
122
+ */
123
+ "class-human-only",
124
+ /** The class resolves manual and no token was presented. Nothing appended. */
125
+ "token-required",
126
+ /** Loop safety escalated the task to manual (SPEC.md §10.2). */
127
+ "loop-escalated",
128
+ /** Policy is unattested or its bytes changed (`core/attest.ts`). */
129
+ "policy-not-attested",
130
+ /** An `execution.started` already exists for this key (idempotency). */
131
+ "already-executed",
132
+ /** Budgets refused the start; a `budget.exceeded` event WAS appended. */
133
+ "budget-exceeded",
134
+ /** `finishExecution` found no unfinished `execution.started`. */
135
+ "not-started",
136
+ /** `finishExecution` found the started execution already closed. */
137
+ "already-finished",
138
+ /** No grant governs this manual action key. */
139
+ "not-granted",
140
+ /** A grant exists, but the presented token is not its preimage. */
141
+ "token-mismatch",
142
+ /** The token was already spent. */
143
+ "token-consumed",
144
+ /** The parent request's TTL lapsed. */
145
+ "token-expired",
146
+ /** A human withdrew the grant. */
147
+ "token-revoked",
148
+ /**
149
+ * The grant was harness-executed and minted no token (APRV-106). Surfaced
150
+ * verbatim from `core/token.ts` so the executor's vocabulary stays that
151
+ * module's vocabulary: an agent that reads this has not lost a token, it is
152
+ * holding a grant that authorized a process which runs the command itself.
153
+ */
154
+ "harness-executed",
155
+ /**
156
+ * The payload presented does not hash to the bytes the grant approved
157
+ * (amended SPEC.md §10, A1). Nothing was appended and the token is still live.
158
+ */
159
+ "payload-mismatch",
160
+ /**
161
+ * `resolveExecution` was called without the mandatory human observation, or
162
+ * by an actor that is not a `human:`. Recorded here rather than reusing
163
+ * `not-started` because the log is unchanged for a different reason: the
164
+ * caller, not the state.
165
+ */
166
+ "actor-not-human",
167
+ /**
168
+ * The key's latest `execution.started` is a DELEGATED record (APRV-117,
169
+ * APRV-120): it carries `payload.execution: "harness"`, so the harness ran the
170
+ * command and this runtime never observed an exit status. The record is
171
+ * complete as written and terminal by design, and no outcome may be placed
172
+ * over it.
173
+ *
174
+ * Distinct from the two refusals it sits between, and the distinctions are the
175
+ * point. `not-started` says nothing began; `already-finished` says something
176
+ * began and an outcome already exists. This one says the thing that began is
177
+ * not this runtime's to close: a `completed` or `failed` written here would
178
+ * report an exit code nobody watched, and an `execution.completed` would
179
+ * additionally clear the task's loop-escalation streak (SPEC.md §10.2) on the
180
+ * strength of it. {@link executionCustody} reports these as `delegated` and
181
+ * {@link danglingExecutions} deliberately leaves them out, so this code is the
182
+ * enforcement half of a custody state the projections already draw.
183
+ */
184
+ "execution-delegated",
185
+ /**
186
+ * The key's execution ended in an unknown outcome (APRV-120) and has not been
187
+ * reconciled. INDETERMINATE IS A CUSTODY STATE: the token stays spent, the
188
+ * idempotency key stays burned, and a re-run is refused here rather than
189
+ * anywhere else, because "we do not know whether this happened" is a
190
+ * different fact from "this already happened" and calls for a different
191
+ * repair — a person establishing which it was, with `execution reconcile`.
192
+ */
193
+ "execution-indeterminate",
194
+ /**
195
+ * `reconcileExecution` found no unreconciled `execution.indeterminate` for
196
+ * the key. There is nothing whose outcome is in doubt, so there is nothing to
197
+ * resolve; a dangling execution is closed with `execution resolve` instead.
198
+ */
199
+ "not-indeterminate",
200
+ /**
201
+ * The indeterminate outcome already carries a resolution. A second one would
202
+ * be a second answer to a question a person already answered, and the first
203
+ * record is never rewritten.
204
+ */
205
+ "already-reconciled",
206
+ /**
207
+ * `execution resolve --dangling` was asked to attest with no terminal to
208
+ * attest at, and without `--yes` (APRV-264). The bulk form writes
209
+ * `attested_by_human: true` on every record it appends, and a confirmation a
210
+ * pipe could answer is not an attestation. Distinct from `actor-not-human`,
211
+ * which is about WHO is attesting: this one is about whether anybody was
212
+ * actually asked.
213
+ */
214
+ "dangling-stdin-not-tty",
215
+ /**
216
+ * The bulk confirmation was declined, or withdrawn at the prompt (APRV-264).
217
+ * Nothing was appended and the dangling executions stand exactly as they
218
+ * were. Its own code because "the operator said no" and "the operator could
219
+ * not be asked" are different facts about the same prompt.
220
+ */
221
+ "dangling-declined",
222
+ /** The log could not be read, or holds a line that is not a record. */
223
+ "log-unreadable",
224
+ /** The log's final line is unterminated (a crashed write). */
225
+ "log-torn-tail",
226
+ /** The chain does not verify; nothing may execute on an untrustworthy log. */
227
+ "log-corrupt",
228
+ /**
229
+ * The append itself failed; `append` carries the underlying error. Its `code`
230
+ * is `head-moved` when a record landed between this module's read and its
231
+ * append, so the idempotency and budget checks that authorized the write were
232
+ * made against an older log. Nothing was written. Since APRV-236 this code
233
+ * reaches a caller of {@link startExecution} only after the bounded
234
+ * read-check-append retry is spent (`core/head-retry.ts`), and its message
235
+ * says how many attempts were made; a single lost race is re-derived rather
236
+ * than reported.
237
+ */
238
+ "append-failed",
239
+ ];
240
+ function refuse(code, message, extra = {}) {
241
+ return { ok: false, code, message, ...extra };
242
+ }
243
+ /** Narrow a verified-read refusal onto this module's codes, unchanged. */
244
+ function fromReadRefusal(refusal) {
245
+ return refuse(refusal.code, refusal.message);
246
+ }
247
+ /**
248
+ * Narrow a token refusal onto this module's codes.
249
+ *
250
+ * The names are identical on purpose — `core/token.ts` chose them so the CLI
251
+ * could map both modules onto the frozen exit table with one function.
252
+ */
253
+ function fromTokenRefusal(refusal) {
254
+ const extra = {};
255
+ if (refusal.seq !== undefined)
256
+ extra.seq = refusal.seq;
257
+ if (refusal.append !== undefined)
258
+ extra.append = refusal.append;
259
+ return refuse(refusal.code, refusal.message, extra);
260
+ }
261
+ function payloadOf(record) {
262
+ const payload = record.payload;
263
+ return typeof payload === "object" && payload !== null ? payload : {};
264
+ }
265
+ function loadOptionsOf(options) {
266
+ const policy = options.policy ?? {};
267
+ const load = {};
268
+ if (policy.file !== undefined)
269
+ load.file = policy.file;
270
+ else
271
+ load.dir = policy.dir ?? process.cwd();
272
+ if (options.schemaDir !== undefined)
273
+ load.schemaDir = options.schemaDir;
274
+ return load;
275
+ }
276
+ /**
277
+ * The policy file whose bytes are attested — discovered exactly as
278
+ * `core/gate.ts` discovers it, so the attested file and the enforced file are
279
+ * the same file. A missing policy returns the first candidate anyway, so
280
+ * `checkAttestation` reports `unreadable` and the start is refused: a missing
281
+ * policy is never a pass.
282
+ */
283
+ function policyPathOf(options) {
284
+ const policy = options.policy ?? {};
285
+ if (policy.file !== undefined)
286
+ return policy.file;
287
+ const dir = policy.dir ?? process.cwd();
288
+ for (const filename of POLICY_FILENAMES) {
289
+ const candidate = join(dir, filename);
290
+ if (existsSync(candidate))
291
+ return candidate;
292
+ }
293
+ return join(dir, POLICY_FILENAMES[0] ?? "APPROVAL.md");
294
+ }
295
+ function appendOptionsOf(options) {
296
+ const append = { ...options.append };
297
+ if (options.schemaDir !== undefined)
298
+ append.schemaDir = options.schemaDir;
299
+ return append;
300
+ }
301
+ /**
302
+ * Append one event under the compare-and-append precondition (APRV-20).
303
+ *
304
+ * `expectedHead` is the head observed at the read that authorized this write:
305
+ * the already-executed check, the loop-safety check, and the budget evaluation
306
+ * were all made against a log ending exactly there.
307
+ */
308
+ function append(logPath, input, options, expectedHead) {
309
+ const result = appendEvent(logPath, input, { ...appendOptionsOf(options), expectedHead });
310
+ if (result.ok)
311
+ return { ok: true, record: result.record };
312
+ return refuse("append-failed", `${input.event} could not be appended: ${result.error.message}`, { append: result.error });
313
+ }
314
+ /**
315
+ * Find the declaration for `actionKey` across every `task.registered` record.
316
+ *
317
+ * The log — not the task file, which may have been edited since — is the
318
+ * authority, exactly as it is for `approval request`. The search is by action
319
+ * key alone because an execution names a key, not a task: SPEC.md §7 makes the
320
+ * `idempotency_key` the identity of a side effect, and an undeclared key is the
321
+ * one thing that must never execute.
322
+ *
323
+ * A key must be declared by exactly one task. If a log somehow carries the same
324
+ * key under two tasks, this returns the last, but callers on an enforcement path
325
+ * MUST first fail closed via {@link declaringTasks}: the collision is refused at
326
+ * registration (`core/gate.ts`, APRV-138), so a log that still holds one is
327
+ * untrustworthy and nothing may execute from the guess.
328
+ */
329
+ export function findDeclaration(records, actionKey) {
330
+ let found = null;
331
+ for (const { task, key, item } of declaredActions(records)) {
332
+ if (key !== actionKey)
333
+ continue;
334
+ const declaration = declarationOf(task, item);
335
+ if (declaration === null)
336
+ continue;
337
+ found = declaration;
338
+ }
339
+ return found;
340
+ }
341
+ function* declaredActions(records) {
342
+ for (const record of records) {
343
+ if (record.event !== "task.registered")
344
+ continue;
345
+ const task = record.task;
346
+ if (typeof task !== "string" || task.length === 0)
347
+ continue;
348
+ const actions = payloadOf(record)["actions"];
349
+ if (!Array.isArray(actions))
350
+ continue;
351
+ for (const entry of actions) {
352
+ if (typeof entry !== "object" || entry === null)
353
+ continue;
354
+ const item = entry;
355
+ yield { task, key: item["idempotency_key"], item };
356
+ }
357
+ }
358
+ }
359
+ /** The {@link Declaration} an entry states, or `null` when it declares no class. */
360
+ function declarationOf(task, item) {
361
+ const cls = item["class"];
362
+ if (typeof cls !== "string")
363
+ return null;
364
+ const cost = item["est_cost_usd"];
365
+ const reversible = item["reversible"];
366
+ const summary = item["summary"];
367
+ const binding = item["payload_hash"];
368
+ return {
369
+ task,
370
+ class: cls,
371
+ est_cost_usd: usdOrZero(cost),
372
+ reversible: typeof reversible === "boolean" ? reversible : null,
373
+ summary: typeof summary === "string" ? summary : null,
374
+ payload_hash: isPayloadHash(binding) ? binding : null,
375
+ };
376
+ }
377
+ export function indexDeclarations(records) {
378
+ const tasksByKey = new Map();
379
+ const declarations = new Map();
380
+ const requested = new Set();
381
+ for (const { task, key, item } of declaredActions(records)) {
382
+ if (typeof key !== "string")
383
+ continue;
384
+ const tasks = tasksByKey.get(key);
385
+ if (tasks === undefined)
386
+ tasksByKey.set(key, [task]);
387
+ else if (!tasks.includes(task))
388
+ tasks.push(task);
389
+ const declaration = declarationOf(task, item);
390
+ if (declaration !== null)
391
+ declarations.set(key, declaration);
392
+ }
393
+ for (const record of records) {
394
+ if (record.event !== "approval.requested")
395
+ continue;
396
+ const key = record.action_key;
397
+ if (typeof key === "string")
398
+ requested.add(key);
399
+ }
400
+ return { declaringTasks: tasksByKey, declarations, requested };
401
+ }
402
+ /**
403
+ * The distinct tasks that declare `actionKey`. More than one is a cross-task
404
+ * collision (APRV-138): the registration boundary refuses these, so a log that
405
+ * still holds one cannot be trusted to say which declaration governs. Every
406
+ * enforcement caller of {@link findDeclaration} guards on this and fails closed
407
+ * rather than executing the last-registered (possibly weaker) declaration.
408
+ */
409
+ export function declaringTasks(records, actionKey) {
410
+ const tasks = new Set();
411
+ for (const { task, key } of declaredActions(records)) {
412
+ if (key === actionKey)
413
+ tasks.add(task);
414
+ }
415
+ return [...tasks];
416
+ }
417
+ /**
418
+ * Has a human ever been asked about this action key (amended SPEC.md §6.3,
419
+ * APRV-127)?
420
+ *
421
+ * True as soon as the log holds one `approval.requested` for the key, and it
422
+ * stays true: a rejected, expired or withdrawn cycle is still a cycle, and an
423
+ * action that could shed its gate by being refused would be an action that
424
+ * profits from a "no".
425
+ *
426
+ * Pure, and derived from the log alone — never from a payload field a requester
427
+ * wrote about itself. Two callers: {@link startExecution}, which requires the
428
+ * token of any action that went through the gate, and `core/audit.ts`, which
429
+ * leaves such actions out of the retrospective pool because a human already
430
+ * looked.
431
+ */
432
+ export function hasApprovalCycle(records, actionKey) {
433
+ return records.some((record) => record.event === "approval.requested" && record.action_key === actionKey);
434
+ }
435
+ /**
436
+ * Begin an execution: the single entry point for appending `execution.started`.
437
+ *
438
+ * Check order, and why it is this order:
439
+ *
440
+ * 1. **The log reads**, so a torn or unreadable log stops everything before a
441
+ * policy question is asked.
442
+ * 2. **The declaration.** An action key no `task.registered` record declares is
443
+ * `action-not-registered` — SPEC.md §7's "an action's class MUST be declared
444
+ * before an execution token can be requested for it", enforced at the last
445
+ * possible moment as well as the first.
446
+ * 3. **Policy resolution**, including SPEC.md §7's irreversibility floor (the
447
+ * declared `reversible: false` forces `manual`, which forces a token). A
448
+ * failed policy load resolves everything to `manual` — `policy-match.ts`'s
449
+ * contract, not softened here — so an unparseable policy makes every action
450
+ * require a human's token.
451
+ * 4. **Manual path: the token, or nothing.** No token → `token-required`, and
452
+ * the log is untouched. With one, `consumeToken` verifies it and appends the
453
+ * start event; a class that resolves manual but was never granted refuses
454
+ * `not-granted` from that layer. Attestation is not re-checked here: the
455
+ * grant that minted the token could only have happened under an attested
456
+ * policy, and re-checking would refuse an execution a human already
457
+ * authorized because a file changed afterwards.
458
+ * 5. **Non-manual path**, in order: attestation → loop escalation → idempotency
459
+ * → content binding → budgets → append. Attestation first because an
460
+ * unverified policy cannot answer the autonomy question it was just asked to
461
+ * answer; the binding (APRV-140) after the free checks and before the
462
+ * charging one; budgets last because a budget refusal *writes*
463
+ * (`budget.exceeded`), and the cheaper refusals must leave the log
464
+ * untouched.
465
+ *
466
+ * `actor` is not pre-validated: the event schema is the authority on actor
467
+ * shape, and a malformed one is refused at the write boundary as
468
+ * `append-failed`, with the schema's own error attached. One rule about actors,
469
+ * enforced in one place.
470
+ */
471
+ export function startExecution(logPath, actionKey, options, actor) {
472
+ return withHeadRetry(attemptsOf(options.retryOnHeadMoved), () => attemptStart(logPath, actionKey, options, actor));
473
+ }
474
+ /**
475
+ * One whole start, from the clock read to the append (APRV-236 put it under the
476
+ * bounded head-moved retry; see `core/head-retry.ts`).
477
+ *
478
+ * `approval run` is the verb a session drives, and a start that lost the append
479
+ * race used to hand the session a refusal about someone else's write. The
480
+ * re-entry re-derives the lot: the fresh verified read, the declaration and its
481
+ * cross-task collision check, custody, the policy resolution and the human-only
482
+ * test, the approval-cycle test, attestation, loop escalation, the single-use
483
+ * scan, the content binding and the budgets. So a key another writer started in
484
+ * the window is refused `already-executed`, a ceiling it exhausted is
485
+ * `budget-exceeded`, and a task it escalated is `loop-escalated`.
486
+ *
487
+ * The manual path's append happens inside `core/token.ts`'s `consumeToken`,
488
+ * which keeps no retry of its own: the retried cycle re-enters it whole, so its
489
+ * own read, its digest comparison and its single-use scan are re-run rather than
490
+ * skipped, and a token another process spent in the window is refused
491
+ * `token-consumed`. Double-spend stays exactly as pinned.
492
+ */
493
+ function attemptStart(logPath, actionKey, options, actor) {
494
+ const ts = tick(options);
495
+ const read = readVerifiedRecords(logPath, options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
496
+ if (!read.ok)
497
+ return fromReadRefusal(read);
498
+ const records = read.records;
499
+ const declaring = declaringTasks(records, actionKey);
500
+ if (declaring.length > 1) {
501
+ return refuse("action-not-registered", `action key ${JSON.stringify(actionKey)} is declared by more than one task (${declaring.join(", ")}); the runtime will not guess which governs and refuses rather than execute the later declaration. Registration refuses such collisions (APRV-138); a log that holds one is untrustworthy.`);
502
+ }
503
+ const declared = findDeclaration(records, actionKey);
504
+ if (declared === null) {
505
+ return refuse("action-not-registered", `no task.registered record declares an action with idempotency_key ${JSON.stringify(actionKey)}; SPEC.md §7 requires a class to be declared before the action can execute. Run \`approval register <task-file>\` first.`);
506
+ }
507
+ // Custody before authorization (APRV-120). A key whose execution ended in an
508
+ // unknown outcome is BURNED, and it is burned on both paths: the manual one
509
+ // would otherwise say `token-consumed`, which is true and unhelpful, and the
510
+ // non-manual one `already-executed`, which is the assertion nobody can make.
511
+ // Checked before the policy is even read, because the fact is about the key
512
+ // rather than about its autonomy, and a blind retry is what the state exists
513
+ // to refuse.
514
+ const custody = executionCustody(records).find((entry) => entry.actionKey === actionKey);
515
+ if (custody?.state === "indeterminate") {
516
+ return refuse("execution-indeterminate", `action ${actionKey}'s execution ended in an unknown outcome (execution.indeterminate at seq ${String(custody.indeterminateSeq)}${custody.reason === null ? "" : `, ${custody.reason}`}): the side effect was attempted and nobody knows whether it committed. Running it again would be a blind double-execution, so it is refused, and the token and the idempotency key stay spent. Establish what actually happened and record it with \`approval execution reconcile\`; if it did not happen, declare a fresh action and request that.`, { seq: custody.indeterminateSeq ?? custody.seq });
517
+ }
518
+ const load = loadPolicy(loadOptionsOf(options));
519
+ const resolution = resolve(load, declared.class, declared.reversible === null ? {} : { reversible: declared.reversible });
520
+ // APRV-185, amended SPEC.md §5.2, and the first question asked of the
521
+ // resolution: a class reserved to human hands has no execution path here at
522
+ // all. Above the manual/supervised fork deliberately — the fork is a question
523
+ // about HOW this action is authorized, and this one says nothing authorizes it
524
+ // in this process. Above the `gatedByCycle` test for the same reason: an
525
+ // approval cycle opened before a policy amendment raised the class does not
526
+ // survive the amendment as a licence to run.
527
+ if (resolution.autonomy === "human-only") {
528
+ return refuse("class-human-only", humanOnlyRefusal(declared.class, `action ${actionKey} may not be executed and no execution.started was written`));
529
+ }
530
+ // APRV-127. An action that went through the gate is spent through the gate,
531
+ // whatever its class resolves to now.
532
+ //
533
+ // The case this exists for is a `supervised-live` action the live draw
534
+ // selected: `core/gate.ts` sent it down the manual path and recorded an
535
+ // `approval.requested`, but its CLASS still resolves `supervised`, so without
536
+ // this line `approval run` would take the unsupervised branch and start it
537
+ // without spending the token a human minted. The rule is stated as a property
538
+ // of the log rather than of the class deliberately — this process cannot
539
+ // recompute the live draw (the secret is the operator's, and deliberately not
540
+ // an agent's to read), so it asks the one question it can answer from the
541
+ // records it already holds: was a human asked about this action?
542
+ //
543
+ // It is also strictly scrutiny-raising in every other case it can fire. A
544
+ // class relaxed from `manual` to `supervised` after a request was opened would
545
+ // otherwise let the pending question be bypassed by simply running the action;
546
+ // now the token is still required, and `core/token.ts` answers `not-granted`
547
+ // until a human decides. Nothing here can make an ungated action gated: an
548
+ // action with no `approval.requested` never reaches this branch.
549
+ const gatedByCycle = hasApprovalCycle(records, actionKey);
550
+ if (resolution.autonomy === "manual" || gatedByCycle) {
551
+ // APRV-105. With no token in hand, look for one delivered to this machine:
552
+ // the grant sealed it to the ephemeral public key this action's request
553
+ // published, and the private half is in the key store beside the log. Under
554
+ // the default `token_delivery: manual` nothing was sealed and this returns
555
+ // null, so the refusal below is exactly the one it always was.
556
+ //
557
+ // An explicitly passed token always wins. A caller that names a token is
558
+ // making a claim this runtime then checks against the grant's digest, and
559
+ // silently substituting a different one would answer a question nobody
560
+ // asked.
561
+ const keyDir = options.keyStoreDir ?? keyStoreDirFor(logPath);
562
+ const token = options.token !== undefined && options.token.length > 0
563
+ ? options.token
564
+ : (deliveredToken(records, actionKey, keyDir) ?? undefined);
565
+ if (token === undefined || token.length === 0) {
566
+ return refuse("token-required", resolution.autonomy === "manual"
567
+ ? `action ${actionKey} resolves to manual (${resolution.provenance}${resolution.floorApplied ? ", irreversibility floor" : ""}) and cannot execute without the single-use token minted at grant. Request the action, have a human grant it, and pass the token that grant printed.`
568
+ : `action ${actionKey} resolves to ${resolution.autonomy} but the log already carries an approval.requested for it, so a human was asked about this action and it executes on their answer. This is what a supervised-live draw looks like from the executor's side: the gate selected this action into the live fraction and it now follows the manual path. Wait for the decision and pass the token the grant printed.`);
569
+ }
570
+ const consumed = consumeToken(logPath, actionKey, token, actor, {
571
+ ...(options.policy?.file === undefined ? {} : { policyFile: options.policy.file }),
572
+ ...(options.policy?.file === undefined
573
+ ? { policyDir: options.policy?.dir ?? process.cwd() }
574
+ : {}),
575
+ ...(options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir }),
576
+ ...(options.append === undefined ? {} : { append: options.append }),
577
+ ...(options.presentedPayloadHash === undefined
578
+ ? {}
579
+ : { presentedPayloadHash: options.presentedPayloadHash }),
580
+ // APRV-205: the manual path's `execution.started` is appended by
581
+ // `consumeToken`, so the count travels with the spend.
582
+ ...(options.envStripped === undefined ? {} : { envStripped: options.envStripped }),
583
+ // APRV-193: and the room it ran in, for the same reason — the manual
584
+ // path's start event is written by the spend, so both fields travel with it.
585
+ ...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
586
+ // One moment for the whole operation: the timestamp already read above is
587
+ // the one the spend records, so `startExecution` and the `execution.started`
588
+ // it produces cannot disagree about when this happened.
589
+ clock: () => ts,
590
+ });
591
+ if (!consumed.ok)
592
+ return fromTokenRefusal(consumed);
593
+ // APRV-105. The token is spent, so its delivery address is finished. Done
594
+ // AFTER the append rather than before: an unlink before a failed spend would
595
+ // destroy the only local copy of a token that is still live.
596
+ forgetPrivateKey(keyDir, actionKey);
597
+ const payload = payloadOf(consumed.record);
598
+ const cost = payload["est_cost_usd"];
599
+ return {
600
+ ok: true,
601
+ record: consumed.record,
602
+ autonomy: "manual",
603
+ task: declared.task,
604
+ class: typeof payload["class"] === "string" ? payload["class"] : declared.class,
605
+ est_cost_usd: usdOrZero(cost),
606
+ tokenSha256: consumed.tokenSha256,
607
+ };
608
+ }
609
+ // --- supervised / autonomous: no grant exists, so no token exists ---------
610
+ const attestation = attestationRefusal(checkAttestation(records, policyPathOf(options)));
611
+ if (attestation !== null) {
612
+ return refuse("policy-not-attested", attestation.message, { detail: attestation.detail });
613
+ }
614
+ if (isLoopEscalated(records, declared.task)) {
615
+ return refuse("loop-escalated", `task ${declared.task} has three consecutive execution.failed events and is escalated to manual (SPEC.md §10.2), so its ${resolution.autonomy} actions may not start unsupervised. This is a redirection, not a ban: request ${actionKey}, have a human grant it, and run it with the token. The escalation clears only when an execution.completed for the task lands.`);
616
+ }
617
+ for (const record of records) {
618
+ if (record.action_key !== actionKey)
619
+ continue;
620
+ if (record.event !== "execution.started")
621
+ continue;
622
+ return refuse("already-executed", `action ${actionKey} already started at seq ${record.seq}; an idempotency key is single-use and nothing here reconciles or reruns it. If that execution is dangling, close it with the outcome you observed.`, { seq: record.seq });
623
+ }
624
+ // Content binding off the manual path (amended SPEC.md §6.2/§10.4, APRV-140).
625
+ //
626
+ // Until this, a supervised or autonomous action executed whatever bytes the
627
+ // executor happened to hold: no grant exists on this path, so nothing was
628
+ // compared, and `approval run <key> -- <anything>` under an autonomous class
629
+ // was unauthenticated arbitrary execution (the residual APRV-138 left open).
630
+ // The declaration is what authorizes here, so the declaration is what the
631
+ // executor is checked against: it states its bytes, and they must be the ones
632
+ // the registered action named.
633
+ //
634
+ // A declaration carrying NO binding is refused rather than waved through, for
635
+ // the reason `core/token.ts` refuses an unbound grant: an action that can
636
+ // execute without stating its bytes makes the binding optional in practice,
637
+ // and ambiguity resolves to the stricter path. The repair is to declare
638
+ // `payload_hash` on the action and register the task again.
639
+ //
640
+ // Checked before budgets, because a budget refusal WRITES and this one must
641
+ // leave the log exactly as it found it.
642
+ const presented = options.presentedPayloadHash;
643
+ if (declared.payload_hash === null) {
644
+ return refuse("payload-mismatch", `action ${actionKey} resolves to ${resolution.autonomy} and its registered declaration carries no payload_hash. Off the manual path there is no grant, so the declaration is the only statement of what was authorized: amended SPEC.md §6.2 (APRV-140) makes the hash MUST for every action that executes, and an execution that cannot be checked against anything is not an authorized execution. Declare payload_hash (SHA-256 over the RFC 8785 canonical serialization of the concrete payload) on the action and register the task again.`);
645
+ }
646
+ if (!isPayloadHash(presented) || presented !== declared.payload_hash) {
647
+ return refuse("payload-mismatch", presented === undefined
648
+ ? `action ${actionKey} is declared with payload_hash ${declared.payload_hash} and this executor presented none. Amended SPEC.md §10.4: an executor MUST recompute the hash of the payload it is about to execute; a start that cannot state its bytes cannot be shown to be executing the declared ones. Nothing was appended.`
649
+ : `the payload presented for ${actionKey} is not the one declared: the registration binds to ${declared.payload_hash}, this executor presented ${JSON.stringify(presented)}. A declaration authorizes specific bytes; changing them requires registering the action again. Nothing was appended.`);
650
+ }
651
+ const budget = evaluateBudgetsWithTask(records, {
652
+ classLimits: resolution.limits,
653
+ classPattern: resolution.matched === null ? null : resolution.matched.pattern,
654
+ globalBudgets: load.ok ? load.policy.budgets ?? null : null,
655
+ }, { class: declared.class, est_cost_usd: declared.est_cost_usd }, ts,
656
+ // S2: the registered envelope's own `budget.max_cost_usd`. This is the
657
+ // supervised/autonomous charge point, so it is where the task cap binds for
658
+ // actions that never pass through a grant.
659
+ declared.task);
660
+ if (!budget.pass) {
661
+ const failed = budget.verdicts.filter((entry) => !entry.pass);
662
+ const logged = append(logPath, {
663
+ ts,
664
+ event: "budget.exceeded",
665
+ actor,
666
+ task: declared.task,
667
+ action_key: actionKey,
668
+ payload: {
669
+ class: declared.class,
670
+ est_cost_usd: declared.est_cost_usd,
671
+ stage: "execution",
672
+ verdicts: budget.verdicts,
673
+ },
674
+ }, options, read.head);
675
+ const message = `budget refused the execution: ${failed
676
+ .map((entry) => `${entry.limit} (${entry.scope})`)
677
+ .join(", ")}`;
678
+ return logged.ok
679
+ ? refuse("budget-exceeded", message, { verdicts: failed, record: logged.record })
680
+ : refuse("budget-exceeded", `${message}; the budget.exceeded event could not be appended: ${logged.message}`, { verdicts: failed });
681
+ }
682
+ const appended = append(logPath, {
683
+ ts,
684
+ event: "execution.started",
685
+ actor,
686
+ task: declared.task,
687
+ action_key: actionKey,
688
+ // The budgets contract: class and est_cost_usd on every start event. This
689
+ // is the charge point for supervised/autonomous actions.
690
+ //
691
+ // APRV-140 adds the third field: the hash of the bytes that are about to
692
+ // run, recomputed by the executor and checked against the declaration
693
+ // just above. It is what makes the log say WHAT ran rather than only that
694
+ // something did — for `approval run` it is `runPayloadHash(argv, cwd)`,
695
+ // which an operator holding the command can reproduce exactly. The argv
696
+ // itself is deliberately NOT recorded: a command line carries whatever an
697
+ // agent put on it, secrets included, and §11.1's third invariant says the
698
+ // log holds hashes of such material rather than the material.
699
+ payload: {
700
+ class: declared.class,
701
+ est_cost_usd: declared.est_cost_usd,
702
+ payload_hash: declared.payload_hash,
703
+ // APRV-205: how many credential-bearing variables the child was starved
704
+ // of. Additive and optional — an execution that spawns nothing (an
705
+ // adapter's `act`, which runs in this process) records no count at all,
706
+ // because "none withheld" and "no child" are different facts.
707
+ ...(options.envStripped === undefined ? {} : { env_stripped: options.envStripped }),
708
+ // APRV-193: the room the child ran in. `egress-denied` is the default
709
+ // for this path — nobody was asked about this action, so the code it
710
+ // runs executes into a room with no doors. Optional and additive for
711
+ // the same reason the count above is.
712
+ ...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
713
+ },
714
+ }, options, read.head);
715
+ if (!appended.ok)
716
+ return appended;
717
+ return {
718
+ ok: true,
719
+ record: appended.record,
720
+ autonomy: resolution.autonomy,
721
+ task: declared.task,
722
+ class: declared.class,
723
+ est_cost_usd: declared.est_cost_usd,
724
+ };
725
+ }
726
+ /**
727
+ * The bounds SPEC.md §8 and `schema/event.schema.json` place on a reference.
728
+ *
729
+ * Printable ASCII with no spaces, and short. An identifier is short; the bound
730
+ * is what keeps the field from becoming somewhere to put a message. Stated here
731
+ * as well as in the schema so that the write path can DECLINE to record a
732
+ * reference that would not validate, rather than hand the schema a record it
733
+ * will reject and leave a completed side effect with no outcome in the log.
734
+ */
735
+ export const PROVIDER_REF_ADAPTER_MAX = 64;
736
+ export const PROVIDER_REF_ID_MAX = 256;
737
+ const PROVIDER_REF_SHAPE = /^[\x21-\x7e]+$/u;
738
+ /** Does `value` fit what the schema will accept for a reference member? */
739
+ export function providerRefMemberOk(value, max) {
740
+ return value.length > 0 && value.length <= max && PROVIDER_REF_SHAPE.test(value);
741
+ }
742
+ /** Is `ref` recordable, in full? A half-recordable reference is not recorded. */
743
+ export function providerRefRecordable(ref) {
744
+ return (providerRefMemberOk(ref.adapter, PROVIDER_REF_ADAPTER_MAX) &&
745
+ providerRefMemberOk(ref.id, PROVIDER_REF_ID_MAX));
746
+ }
747
+ /**
748
+ * Close an execution with the outcome that actually happened.
749
+ *
750
+ * Exit `0` appends `execution.completed`; anything else appends
751
+ * `execution.failed`. Both carry `payload.exit_code` — the number, unmapped and
752
+ * uninterpreted, so a reader can tell exit 1 from exit 127 from a signal death
753
+ * (which `approval run` records as `128 + signal`, the shell convention).
754
+ * Neither event consumes budget: the commitment was charged at authorization
755
+ * time and charging it again would double-count (`core/budgets.ts`).
756
+ *
757
+ * Refuses `not-started` when the key has no `execution.started`,
758
+ * `already-finished` when the most recent start already has an outcome after
759
+ * it, and `execution-delegated` when that start was the harness's rather than
760
+ * this runtime's (APRV-146). All three leave the log untouched.
761
+ *
762
+ * **This is the human recovery path for a dangling execution**, and it is
763
+ * deliberately the only one. Nothing in this codebase closes a dangling
764
+ * execution automatically: an operator who knows the email went out records
765
+ * `0`, an operator who knows it did not records the failure, and either way the
766
+ * log holds an observation rather than a runtime's guess.
767
+ */
768
+ export function finishExecution(logPath, actionKey, exitCode, actor, options = {}) {
769
+ const open = openExecution(logPath, actionKey, options);
770
+ if (!open.ok)
771
+ return open;
772
+ // APRV-261. The read is done and the append has not started: the one instant
773
+ // in which a test can move the head under this attempt on purpose. See
774
+ // `FinishOptions.afterRead` for why it can only ever make the append fail.
775
+ options.afterRead?.();
776
+ const event = exitCode === 0 ? "execution.completed" : "execution.failed";
777
+ // APRV-211. A non-zero exit with no reason is not a report: the daemon's
778
+ // advance recorded `exit_code: 1` and the operator's only surfaces — the
779
+ // daemon's event stream, which is gone the moment nobody is tailing it, and
780
+ // the `log-advance-cadence` doctor row, which reads the log — could say
781
+ // nothing about WHY. So the executor's own words travel with the outcome.
782
+ // Recorded ONLY on failure, and only when the caller states them: the
783
+ // completed case has nothing to explain, and a reason nobody supplied would
784
+ // be a runtime's guess in an append-only log.
785
+ const reason = event === "execution.failed" && options.reason !== undefined
786
+ ? { code: options.reason.code, message: options.reason.message }
787
+ : // APRV-234. The mirror of it: a completion the executor wants on the
788
+ // record (the advance rebuilt the day's branch on a moved trunk).
789
+ // Same closed shape, same one-way street — a report, never read back.
790
+ event === "execution.completed" && options.note !== undefined
791
+ ? { code: options.note.code, message: options.note.message }
792
+ : {};
793
+ // APRV-251. The provider's own identifier for the effect, on a completion and
794
+ // nowhere else: a failed execution produced no effect for a provider to file.
795
+ // Recorded only when the caller states one, and only when it fits what the
796
+ // schema admits — handing the write boundary a record it will reject would
797
+ // leave a side effect that happened with no outcome in the log, which is a
798
+ // worse outcome than a completion carrying no reference.
799
+ const providerRef = event === "execution.completed" &&
800
+ options.providerRef !== undefined &&
801
+ providerRefRecordable(options.providerRef)
802
+ ? {
803
+ provider_ref: {
804
+ adapter: options.providerRef.adapter,
805
+ id: options.providerRef.id,
806
+ },
807
+ }
808
+ : {};
809
+ const appended = append(logPath, {
810
+ ts: tick(options),
811
+ event,
812
+ actor,
813
+ task: open.task,
814
+ action_key: actionKey,
815
+ payload: { exit_code: exitCode, ...reason, ...providerRef },
816
+ }, options,
817
+ // The head read above, when the not-started / already-finished checks ran.
818
+ open.head);
819
+ if (!appended.ok)
820
+ return appended;
821
+ return { ok: true, record: appended.record, event, exitCode, task: open.task };
822
+ }
823
+ /**
824
+ * The one dangling execution for `actionKey`, or a refusal explaining why there
825
+ * is none to close.
826
+ *
827
+ * Shared by {@link finishExecution}, {@link resolveExecution} and
828
+ * {@link indeterminateExecution} so the three verbs cannot drift about what
829
+ * "still open" means. Returns the head observed at the read, which the caller
830
+ * passes as `expectedHead`: the not-started, delegated and already-finished
831
+ * checks were made against a log ending exactly there.
832
+ *
833
+ * A DELEGATED start is refused `execution-delegated` (APRV-146). The three verbs
834
+ * that share this function all write an outcome, and a harness start has none to
835
+ * write: the record says so on its face (`payload.execution: "harness"`), and
836
+ * {@link executionCustody} has reported it terminal by design since APRV-120.
837
+ * Enforcing it in this one place is what keeps the three verbs from disagreeing
838
+ * about it, which is the reason this function exists.
839
+ *
840
+ * EXCEPT BY THE MARKED COUNTERPART (APRV-145). One surface may close a delegated
841
+ * start, and it is not one of these three: `core/gate.ts`'s
842
+ * `finishHarnessExecution`, which appends the outcome a harness REPORTED, marked
843
+ * `execution: "harness"` with a closed `reported_by` code naming which untrusted
844
+ * reporter asserted it. The refusal here is unchanged and deliberately so: these
845
+ * three write an outcome the runtime observed or a person did, and neither exists
846
+ * for a harness start. The counterpart writes a third thing and says so on the
847
+ * record, which is what makes it a carve-out rather than a hole. Amended
848
+ * SPEC.md §10.2 states the rule; the reconciliation recorded on APRV-145 is that
849
+ * these three keep refusing exactly as APRV-146 merged them.
850
+ *
851
+ * An `execution.indeterminate` closes a cycle here like any other outcome
852
+ * (APRV-120). It is not an invitation to try again: a second outcome for a key
853
+ * whose effect may already have happened is exactly the blind double-execution
854
+ * the state exists to refuse, and the repair is `execution reconcile`, which
855
+ * appends beside the record rather than closing it a second time.
856
+ */
857
+ function openExecution(logPath, actionKey, options) {
858
+ const read = readVerifiedRecords(logPath, options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
859
+ if (!read.ok)
860
+ return fromReadRefusal(read);
861
+ let started = null;
862
+ let finished = null;
863
+ for (const record of read.records) {
864
+ if (record.action_key !== actionKey)
865
+ continue;
866
+ if (record.event === "execution.started") {
867
+ started = record;
868
+ finished = null;
869
+ continue;
870
+ }
871
+ if (record.event === "execution.completed" ||
872
+ record.event === "execution.failed" ||
873
+ record.event === "execution.indeterminate") {
874
+ if (started !== null)
875
+ finished = record;
876
+ }
877
+ }
878
+ if (started === null) {
879
+ return refuse("not-started", `action ${actionKey} has no execution.started record; an outcome cannot be recorded for an execution that never began`);
880
+ }
881
+ // APRV-146. A delegated start is terminal by design, so there is no open
882
+ // execution here to close. Checked BEFORE the already-finished branch because
883
+ // the fact is about this record's custody rather than about what else the log
884
+ // holds: a harness execution was never going to gain an outcome, whether or
885
+ // not something has since written one over it.
886
+ if (isDelegatedStart(started)) {
887
+ return refuse("execution-delegated", `action ${actionKey}'s execution.started at seq ${started.seq} carries execution: "harness" (APRV-117): the harness ran the command and this runtime never observed an exit status, so the record is complete as written and terminal by design. No outcome may be written over it — a completed or failed recorded here would report an exit code nobody watched, and an execution.completed would additionally clear the task's loop-escalation streak (SPEC.md §10.2). \`approval status\` lists such a record as delegated rather than dangling for the same reason.`, { seq: started.seq });
888
+ }
889
+ if (finished !== null) {
890
+ return refuse("already-finished", finished.event === "execution.indeterminate"
891
+ ? `action ${actionKey} ended in an unknown outcome (execution.indeterminate at seq ${finished.seq}); its side effect was attempted and nobody knows whether it committed, so no outcome may be written over it. Establish what happened and record it with \`approval execution reconcile\`, which appends beside that record and never rewrites it.`
892
+ : `action ${actionKey} was already closed by ${finished.event} at seq ${finished.seq}; an execution has exactly one outcome`, { seq: finished.seq });
893
+ }
894
+ const task = started.task;
895
+ if (typeof task !== "string" || task.length === 0) {
896
+ // Unreachable through the real append path: event.schema.json requires
897
+ // `task` on every execution event. Kept as a fail-closed backstop.
898
+ return refuse("not-started", `the execution.started record for ${actionKey} at seq ${started.seq} names no task; the outcome event requires one`, { seq: started.seq });
899
+ }
900
+ return { ok: true, task, startedSeq: started.seq, head: read.head };
901
+ }
902
+ /** Actors permitted to resolve. A fact nobody observed is not an observation. */
903
+ const HUMAN_ACTOR = /^human:.+/u;
904
+ /**
905
+ * Close a dangling execution with what a human actually observed.
906
+ *
907
+ * {@link finishExecution} is the mechanical path: `approval run` knows the
908
+ * child's exit code because it waited for it. This is the path for the case
909
+ * that code cannot cover — the runtime died between `execution.started` and its
910
+ * outcome, so the log honestly says "this began and we do not know how it
911
+ * ended", and only a person who went and looked can say more.
912
+ *
913
+ * Five properties, all deliberate:
914
+ *
915
+ * 1. **The note is mandatory and non-empty.** The whole value of this event is
916
+ * the observation behind it; an unexplained human-attested outcome is
917
+ * indistinguishable from a guess, and a guess written into an append-only
918
+ * log is indistinguishable from a fact. The CLI refuses an empty note as a
919
+ * usage error before reaching here, and this refuses it again.
920
+ * 2. **Human-only.** An agent closing its own dangling execution is the agent
921
+ * reporting on itself, which is the one thing the log exists not to accept.
922
+ * 3. **`exit_code: null`.** Not `0`, not `127`: nobody ran anything and there
923
+ * is no code to report. A fabricated exit code would read exactly like an
924
+ * observed one, and `payload.attested_by_human: true` marks the difference
925
+ * for every reader and every projection.
926
+ * 4. **A harness execution is out of reach** (APRV-146). A delegated start is
927
+ * refused `execution-delegated` here as it is in {@link finishExecution}: the
928
+ * record is terminal by design, and a person attesting an outcome for a
929
+ * command this runtime never watched would be attesting to the one thing the
930
+ * log already says nobody observed.
931
+ * 5. **No attestation requirement.** Resolve records a fact a human observed;
932
+ * it exercises no policy authority — it authorizes nothing, spends no
933
+ * budget, mints no token — so it does not require an attested policy. A
934
+ * dangling execution left unclosable because a policy file was edited would
935
+ * be a repair blocked by an unrelated fact.
936
+ */
937
+ export function resolveExecution(logPath, actionKey, outcome, note, actor, options = {}) {
938
+ if (!HUMAN_ACTOR.test(actor)) {
939
+ return refuse("actor-not-human", `resolve is human-only: it records what a person observed about an execution nobody watched finish, and an agent-attested outcome would be the executing party reporting on itself. The actor must match human:<id>, got ${JSON.stringify(actor)}.`);
940
+ }
941
+ if (note.trim().length === 0) {
942
+ return refuse("actor-not-human", `resolve requires a non-empty --note: the event's value is the observation behind it, and an unexplained human-attested outcome cannot be told apart from a guess`);
943
+ }
944
+ const open = openExecution(logPath, actionKey, options);
945
+ if (!open.ok)
946
+ return open;
947
+ const event = outcome === "completed" ? "execution.completed" : "execution.failed";
948
+ const appended = append(logPath, {
949
+ ts: tick(options),
950
+ event,
951
+ actor,
952
+ task: open.task,
953
+ action_key: actionKey,
954
+ payload: { note, attested_by_human: true, exit_code: null },
955
+ }, options, open.head);
956
+ if (!appended.ok)
957
+ return appended;
958
+ return { ok: true, record: appended.record, event, outcome, task: open.task };
959
+ }
960
+ /**
961
+ * Close an execution as INDETERMINATE: the side effect was attempted and
962
+ * nobody knows whether it committed (APRV-120).
963
+ *
964
+ * `execution.failed` used to carry this case, and conflating the two is what
965
+ * made a retry look safe. An adapter that times out mid-send reads, in a log
966
+ * that only knows `failed`, exactly like one that never opened a socket; a
967
+ * caller reading the second reasonably tries again, and against the first that
968
+ * is a double send. So the runtime writes down which it is, and the difference
969
+ * is positional rather than a judgment: the adapter contract records
970
+ * `execution.failed` for everything that goes wrong BEFORE `act` is entered
971
+ * (provably not committed) and this for anything after (provably nothing).
972
+ *
973
+ * Three properties, all deliberate:
974
+ *
975
+ * 1. **The consumption is burned.** The token was spent at
976
+ * `execution.started` and stays spent; the idempotency key stays used; the
977
+ * budget stays charged. Refunding an attempt whose outcome is unknown would
978
+ * be the runtime deciding the effect did not happen, which is the one thing
979
+ * nobody here knows.
980
+ * 2. **No exception text.** `reason` is a closed code and nothing else is
981
+ * recorded. An error message is where a credential rides into the log with
982
+ * a plausible excuse, and §11.1's third invariant does not have an
983
+ * exception for diagnostics. The caller still receives the message, redacted,
984
+ * from the adapter contract.
985
+ * 3. **Nothing auto-resolves.** No function here, and nothing in the daemon,
986
+ * ever converts this into completed or failed. Only
987
+ * {@link reconcileExecution} does, on a person's evidence.
988
+ */
989
+ export function indeterminateExecution(logPath, actionKey, reason, actor, options = {}) {
990
+ const open = openExecution(logPath, actionKey, options);
991
+ if (!open.ok)
992
+ return open;
993
+ const appended = append(logPath, {
994
+ ts: tick(options),
995
+ event: "execution.indeterminate",
996
+ actor,
997
+ task: open.task,
998
+ action_key: actionKey,
999
+ // `exit_code: null` for the reason `resolve` writes it: nobody watched a
1000
+ // process exit, and a fabricated number would read like a measured one.
1001
+ payload: { reason, exit_code: null },
1002
+ }, options,
1003
+ // The head read above, when the not-started / already-finished checks ran.
1004
+ open.head);
1005
+ if (!appended.ok)
1006
+ return appended;
1007
+ return { ok: true, record: appended.record, reason, task: open.task };
1008
+ }
1009
+ /**
1010
+ * Record what a person established about an indeterminate execution.
1011
+ *
1012
+ * The counterpart of {@link resolveExecution}, and deliberately a separate verb
1013
+ * with separate refusals: `resolve` closes an execution nobody watched finish,
1014
+ * and this resolves one whose effect may or may not have landed. The questions
1015
+ * are different ("what did the runtime do?" against "did the far side commit?"),
1016
+ * the evidence is different (this repo's log against the relying party's), and
1017
+ * an operator who reached for the wrong one should be told so rather than
1018
+ * quietly write the wrong record.
1019
+ *
1020
+ * Four properties:
1021
+ *
1022
+ * 1. **The original is never rewritten.** This appends a record that NAMES the
1023
+ * indeterminate one by seq. The observation "we did not know" survives its
1024
+ * own resolution, which is the whole reason the log is append-only, and an
1025
+ * auditor can see both the doubt and its answer.
1026
+ * 2. **Human-only, and never the daemon.** An agent reconciling its own unknown
1027
+ * outcome is the executing party reporting on itself; a daemon doing it on a
1028
+ * schedule is a guess with a cron entry. The mandatory note is the evidence,
1029
+ * in the reconciler's own words.
1030
+ * 3. **The two resolutions are distinct in the log.** `executed` and
1031
+ * `not-executed` are separate closed values, not two readings of one
1032
+ * sentence, because everything downstream of the record turns on which.
1033
+ * 4. **The key stays burned either way.** Resolving `not-executed` re-opens the
1034
+ * possibility of the EFFECT, not of this action: an `idempotency_key` is the
1035
+ * global identity of one side effect (§6.2) and a used one is used. The
1036
+ * repair is to declare a fresh action and request it, which is a new
1037
+ * question with a new answer, and the reconciliation is what makes asking it
1038
+ * honest.
1039
+ */
1040
+ export function reconcileExecution(logPath, actionKey, resolution, note, actor, options = {}) {
1041
+ if (!HUMAN_ACTOR.test(actor)) {
1042
+ return refuse("actor-not-human", `reconcile is human-only: it records what a person established about an execution whose outcome the runtime could not observe, and an agent-attested resolution would be the executing party reporting on itself. The actor must match human:<id>, got ${JSON.stringify(actor)}.`);
1043
+ }
1044
+ if (note.trim().length === 0) {
1045
+ return refuse("actor-not-human", `reconcile requires a non-empty note: the record's value is the evidence behind it, and an unexplained resolution of an unknown outcome cannot be told apart from a guess`);
1046
+ }
1047
+ const read = readVerifiedRecords(logPath, options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
1048
+ if (!read.ok)
1049
+ return fromReadRefusal(read);
1050
+ const cycle = executionCustody(read.records).find((entry) => entry.actionKey === actionKey);
1051
+ if (cycle === undefined || cycle.indeterminateSeq === null) {
1052
+ return refuse("not-indeterminate", `action ${actionKey} has no execution.indeterminate record, so there is no unknown outcome to resolve. A started execution with no outcome at all is a dangling execution and is closed with \`approval execution resolve\`.`);
1053
+ }
1054
+ if (cycle.state === "reconciled") {
1055
+ return refuse("already-reconciled", `action ${actionKey}'s indeterminate outcome at seq ${cycle.indeterminateSeq} was already reconciled at seq ${String(cycle.closedSeq)}; a second resolution would be a second answer to a question a person already answered, and neither record is rewritten`, { seq: cycle.closedSeq ?? cycle.indeterminateSeq });
1056
+ }
1057
+ const task = cycle.task;
1058
+ if (task === null || task.length === 0) {
1059
+ // Unreachable through the real append path: event.schema.json requires
1060
+ // `task` on every execution event. Kept as a fail-closed backstop.
1061
+ return refuse("not-indeterminate", `the execution.started record for ${actionKey} at seq ${cycle.seq} names no task; the reconciliation event requires one`, { seq: cycle.seq });
1062
+ }
1063
+ const appended = append(logPath, {
1064
+ ts: tick(options),
1065
+ event: "execution.reconciled",
1066
+ actor,
1067
+ task,
1068
+ action_key: actionKey,
1069
+ payload: {
1070
+ indeterminate_seq: cycle.indeterminateSeq,
1071
+ resolution,
1072
+ note,
1073
+ attested_by_human: true,
1074
+ },
1075
+ }, options,
1076
+ // Compare-and-append: the "is there an unreconciled indeterminate here"
1077
+ // check above was made against the log ending exactly at this head, so two
1078
+ // reconcilers of one record cannot both land.
1079
+ read.head);
1080
+ if (!appended.ok)
1081
+ return appended;
1082
+ return {
1083
+ ok: true,
1084
+ record: appended.record,
1085
+ resolution,
1086
+ task,
1087
+ indeterminateSeq: cycle.indeterminateSeq,
1088
+ };
1089
+ }
1090
+ // ---------------------------------------------------------------------------
1091
+ // custody
1092
+ // ---------------------------------------------------------------------------
1093
+ /**
1094
+ * What the log knows about one started execution (APRV-120).
1095
+ *
1096
+ * The word is custody rather than status because the question is not "did it
1097
+ * work" but "who is holding this, and what may still be done with it". Five
1098
+ * states, and the two that are easy to confuse are the reason the vocabulary
1099
+ * exists:
1100
+ *
1101
+ * - `settled` — an `execution.completed` or `execution.failed` closed it. The
1102
+ * runtime watched the outcome and wrote down what it saw.
1103
+ * - `open` — a start with no outcome, written by a runtime that MEANT to watch
1104
+ * one. This is the dangling execution: a crash between `execution.started`
1105
+ * and its outcome, repairable by a person with `execution resolve`. It is
1106
+ * debris, and `approval status` says so.
1107
+ * - `delegated` — a start carrying `payload.execution: "harness"` (APRV-117,
1108
+ * APRV-141). **Terminal by design, and never debris.** The harness runs the
1109
+ * command and this runtime never observes an exit status, so no outcome event
1110
+ * will ever follow; the record is complete as written. Reporting these as
1111
+ * dangling — which is what happened before this state existed, to every
1112
+ * harness execution in the reference repository's own log — trains operators
1113
+ * to ignore the one list that is supposed to mean something.
1114
+ * - `indeterminate` — an `execution.indeterminate`: the side effect was
1115
+ * attempted and nobody knows whether it committed. The token is spent and the
1116
+ * key is burned, and a re-run is refused, because a retry against an unknown
1117
+ * outcome is a blind double-execution.
1118
+ * - `reconciled` — a person established which it was, and said so in an
1119
+ * `execution.reconciled` that sits beside the indeterminate record rather
1120
+ * than over it.
1121
+ */
1122
+ export const CUSTODY_STATES = [
1123
+ "settled",
1124
+ "open",
1125
+ "delegated",
1126
+ "indeterminate",
1127
+ "reconciled",
1128
+ ];
1129
+ /** Where an indeterminate outcome's unknowing began. Closed (schema §8). */
1130
+ export const INDETERMINATE_REASONS = ["act-threw"];
1131
+ /** What a reconciliation established. Closed, and the two are distinct. */
1132
+ export const RECONCILE_RESOLUTIONS = ["executed", "not-executed"];
1133
+ export function isIndeterminateReason(value) {
1134
+ return typeof value === "string" && INDETERMINATE_REASONS.includes(value);
1135
+ }
1136
+ export function isReconcileResolution(value) {
1137
+ return typeof value === "string" && RECONCILE_RESOLUTIONS.includes(value);
1138
+ }
1139
+ /** Does this `execution.started` record say the harness ran the command? */
1140
+ function isDelegatedStart(record) {
1141
+ return payloadOf(record)["execution"] === "harness";
1142
+ }
1143
+ /**
1144
+ * The custody state of every started execution, in log order.
1145
+ *
1146
+ * Pure: no I/O, no clock. Per action key, only the **latest cycle** counts — a
1147
+ * start followed by an outcome is closed, and a later start reopens the key.
1148
+ * (The gate refuses a second start for a key anyway; this function does not
1149
+ * assume that, because a projection that only works on well-formed logs is a
1150
+ * projection that goes quiet exactly when something has gone wrong.)
1151
+ */
1152
+ export function executionCustody(records) {
1153
+ const cycles = new Map();
1154
+ for (const record of records) {
1155
+ const actionKey = record.action_key;
1156
+ if (typeof actionKey !== "string" || actionKey.length === 0)
1157
+ continue;
1158
+ if (record.event === "execution.started") {
1159
+ cycles.set(actionKey, {
1160
+ actionKey,
1161
+ task: record.task ?? null,
1162
+ // The marker is read once, here: a harness start is complete as
1163
+ // written, and every later reader asks this projection rather than
1164
+ // re-deriving the rule from a payload field.
1165
+ state: isDelegatedStart(record) ? "delegated" : "open",
1166
+ ts: record.ts,
1167
+ seq: record.seq,
1168
+ actor: record.actor,
1169
+ closedSeq: null,
1170
+ indeterminateSeq: null,
1171
+ reason: null,
1172
+ resolution: null,
1173
+ });
1174
+ continue;
1175
+ }
1176
+ const cycle = cycles.get(actionKey);
1177
+ if (cycle === undefined)
1178
+ continue;
1179
+ if (record.event === "execution.completed" || record.event === "execution.failed") {
1180
+ cycle.state = "settled";
1181
+ cycle.closedSeq = record.seq;
1182
+ continue;
1183
+ }
1184
+ if (record.event === "execution.indeterminate") {
1185
+ const reason = payloadOf(record)["reason"];
1186
+ cycle.state = "indeterminate";
1187
+ cycle.closedSeq = record.seq;
1188
+ cycle.indeterminateSeq = record.seq;
1189
+ cycle.reason = isIndeterminateReason(reason) ? reason : null;
1190
+ continue;
1191
+ }
1192
+ if (record.event === "execution.reconciled") {
1193
+ const resolution = payloadOf(record)["resolution"];
1194
+ cycle.state = "reconciled";
1195
+ cycle.closedSeq = record.seq;
1196
+ cycle.resolution = isReconcileResolution(resolution) ? resolution : null;
1197
+ }
1198
+ }
1199
+ return [...cycles.values()].sort((a, b) => a.seq - b.seq);
1200
+ }
1201
+ /**
1202
+ * Executions that started, were meant to be watched, and never finished.
1203
+ *
1204
+ * This is the state a crash between `execution.started` and its outcome leaves
1205
+ * behind, and it is reported as itself: not as completed, not as failed, not as
1206
+ * clean. `approval status` lists it; `approval queue` does not, because nobody
1207
+ * is being asked to decide anything.
1208
+ *
1209
+ * A `delegated` start is NOT here (APRV-120). The harness ran the command and
1210
+ * this runtime never sees an exit status, so its record was never going to gain
1211
+ * an outcome; listing it as debris says something false about a log that is
1212
+ * exactly right.
1213
+ */
1214
+ export function danglingExecutions(records) {
1215
+ return executionCustody(records)
1216
+ .filter((cycle) => cycle.state === "open")
1217
+ .map(({ actionKey, task, ts, seq, actor }) => ({ actionKey, task, ts, seq, actor }));
1218
+ }
1219
+ /**
1220
+ * Executions whose side effect was attempted and whose outcome nobody knows,
1221
+ * and which no one has reconciled yet.
1222
+ *
1223
+ * Distinct from {@link danglingExecutions} in what it asks of a person. A
1224
+ * dangling execution needs someone to look at what the runtime did; an
1225
+ * indeterminate one needs someone to establish, from the relying party's own
1226
+ * evidence, whether the effect happened at all. Both are debris and both make
1227
+ * `approval status` unhealthy; only one of them is repaired with
1228
+ * `execution resolve`.
1229
+ */
1230
+ export function indeterminateExecutions(records) {
1231
+ return executionCustody(records).filter((cycle) => cycle.state === "indeterminate");
1232
+ }
1233
+ //# sourceMappingURL=execute.js.map