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