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