approval-md 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +98 -13
  3. package/cli.js +66 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +19 -0
  8. package/dist/src/channels/contract.js +105 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +271 -1
  22. package/dist/src/channels/telegram.js +403 -45
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/cli/amend.js +350 -4
  25. package/dist/src/cli/amend.js.map +1 -1
  26. package/dist/src/cli/attest.js +43 -6
  27. package/dist/src/cli/attest.js.map +1 -1
  28. package/dist/src/cli/channel-relay.d.ts +108 -0
  29. package/dist/src/cli/channel-relay.js +334 -0
  30. package/dist/src/cli/channel-relay.js.map +1 -0
  31. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  32. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  33. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  34. package/dist/src/cli/channel-telegram.d.ts +272 -26
  35. package/dist/src/cli/channel-telegram.js +478 -41
  36. package/dist/src/cli/channel-telegram.js.map +1 -1
  37. package/dist/src/cli/channel.d.ts +1 -1
  38. package/dist/src/cli/channel.js +5 -1
  39. package/dist/src/cli/channel.js.map +1 -1
  40. package/dist/src/cli/daemon.js +37 -5
  41. package/dist/src/cli/daemon.js.map +1 -1
  42. package/dist/src/cli/doctor.d.ts +63 -0
  43. package/dist/src/cli/doctor.js +856 -111
  44. package/dist/src/cli/doctor.js.map +1 -1
  45. package/dist/src/cli/execute.d.ts +17 -1
  46. package/dist/src/cli/execute.js +341 -20
  47. package/dist/src/cli/execute.js.map +1 -1
  48. package/dist/src/cli/exit-codes.d.ts +12 -0
  49. package/dist/src/cli/exit-codes.js +13 -0
  50. package/dist/src/cli/exit-codes.js.map +1 -1
  51. package/dist/src/cli/gate.d.ts +2 -0
  52. package/dist/src/cli/gate.js +233 -2
  53. package/dist/src/cli/gate.js.map +1 -1
  54. package/dist/src/cli/help.d.ts +9 -1
  55. package/dist/src/cli/help.js +246 -38
  56. package/dist/src/cli/help.js.map +1 -1
  57. package/dist/src/cli/hook.d.ts +371 -3
  58. package/dist/src/cli/hook.js +1926 -44
  59. package/dist/src/cli/hook.js.map +1 -1
  60. package/dist/src/cli/init.js +16 -7
  61. package/dist/src/cli/init.js.map +1 -1
  62. package/dist/src/cli/instructions.js +24 -3
  63. package/dist/src/cli/instructions.js.map +1 -1
  64. package/dist/src/cli/log-anchor.d.ts +55 -0
  65. package/dist/src/cli/log-anchor.js +199 -60
  66. package/dist/src/cli/log-anchor.js.map +1 -1
  67. package/dist/src/cli/main.d.ts +0 -12
  68. package/dist/src/cli/main.js +51 -0
  69. package/dist/src/cli/main.js.map +1 -1
  70. package/dist/src/cli/muse.d.ts +2 -0
  71. package/dist/src/cli/muse.js +62 -0
  72. package/dist/src/cli/muse.js.map +1 -0
  73. package/dist/src/cli/payload.js +79 -1
  74. package/dist/src/cli/payload.js.map +1 -1
  75. package/dist/src/cli/scaffold.js +6 -0
  76. package/dist/src/cli/scaffold.js.map +1 -1
  77. package/dist/src/cli/serve.d.ts +53 -0
  78. package/dist/src/cli/serve.js +270 -0
  79. package/dist/src/cli/serve.js.map +1 -0
  80. package/dist/src/cli/up.js +21 -2
  81. package/dist/src/cli/up.js.map +1 -1
  82. package/dist/src/cli/verb-registry.d.ts +2 -0
  83. package/dist/src/cli/verb-registry.js +337 -39
  84. package/dist/src/cli/verb-registry.js.map +1 -1
  85. package/dist/src/cli/wordmark.d.ts +2 -2
  86. package/dist/src/cli/wordmark.js +2 -2
  87. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  88. package/dist/src/core/attest-bootstrap.js +135 -0
  89. package/dist/src/core/attest-bootstrap.js.map +1 -0
  90. package/dist/src/core/attest.d.ts +20 -3
  91. package/dist/src/core/attest.js +25 -0
  92. package/dist/src/core/attest.js.map +1 -1
  93. package/dist/src/core/audit.d.ts +55 -3
  94. package/dist/src/core/audit.js +52 -5
  95. package/dist/src/core/audit.js.map +1 -1
  96. package/dist/src/core/channel-lease.d.ts +322 -0
  97. package/dist/src/core/channel-lease.js +663 -0
  98. package/dist/src/core/channel-lease.js.map +1 -0
  99. package/dist/src/core/command-class.d.ts +51 -0
  100. package/dist/src/core/command-class.js +760 -8
  101. package/dist/src/core/command-class.js.map +1 -1
  102. package/dist/src/core/daemon-host.d.ts +178 -0
  103. package/dist/src/core/daemon-host.js +228 -0
  104. package/dist/src/core/daemon-host.js.map +1 -0
  105. package/dist/src/core/daemon-identity.d.ts +238 -0
  106. package/dist/src/core/daemon-identity.js +252 -0
  107. package/dist/src/core/daemon-identity.js.map +1 -0
  108. package/dist/src/core/gate.d.ts +306 -5
  109. package/dist/src/core/gate.js +772 -24
  110. package/dist/src/core/gate.js.map +1 -1
  111. package/dist/src/core/harness-version.d.ts +128 -4
  112. package/dist/src/core/harness-version.js +179 -12
  113. package/dist/src/core/harness-version.js.map +1 -1
  114. package/dist/src/core/harness-wait.d.ts +82 -3
  115. package/dist/src/core/harness-wait.js +89 -3
  116. package/dist/src/core/harness-wait.js.map +1 -1
  117. package/dist/src/core/log-subscribe.d.ts +21 -0
  118. package/dist/src/core/log-subscribe.js +16 -6
  119. package/dist/src/core/log-subscribe.js.map +1 -1
  120. package/dist/src/core/log-write-layer.d.ts +55 -0
  121. package/dist/src/core/log-write-layer.js +91 -0
  122. package/dist/src/core/log-write-layer.js.map +1 -0
  123. package/dist/src/core/log.d.ts +50 -21
  124. package/dist/src/core/log.js +197 -19
  125. package/dist/src/core/log.js.map +1 -1
  126. package/dist/src/core/payload-census.d.ts +51 -0
  127. package/dist/src/core/payload-census.js +125 -2
  128. package/dist/src/core/payload-census.js.map +1 -1
  129. package/dist/src/core/payload-store.d.ts +31 -0
  130. package/dist/src/core/payload-store.js +113 -8
  131. package/dist/src/core/payload-store.js.map +1 -1
  132. package/dist/src/core/payload.d.ts +18 -3
  133. package/dist/src/core/payload.js +20 -4
  134. package/dist/src/core/payload.js.map +1 -1
  135. package/dist/src/core/policy-diff.d.ts +10 -0
  136. package/dist/src/core/policy-diff.js +10 -2
  137. package/dist/src/core/policy-diff.js.map +1 -1
  138. package/dist/src/core/policy-explain.d.ts +7 -1
  139. package/dist/src/core/policy-explain.js +16 -1
  140. package/dist/src/core/policy-explain.js.map +1 -1
  141. package/dist/src/core/policy-load.d.ts +24 -0
  142. package/dist/src/core/policy-load.js.map +1 -1
  143. package/dist/src/core/policy-match.d.ts +38 -0
  144. package/dist/src/core/policy-match.js +35 -0
  145. package/dist/src/core/policy-match.js.map +1 -1
  146. package/dist/src/core/policy-proposal.d.ts +23 -0
  147. package/dist/src/core/policy-proposal.js +8 -2
  148. package/dist/src/core/policy-proposal.js.map +1 -1
  149. package/dist/src/core/read-scope.d.ts +10 -0
  150. package/dist/src/core/read-scope.js +4 -0
  151. package/dist/src/core/read-scope.js.map +1 -1
  152. package/dist/src/core/run-payload.d.ts +160 -0
  153. package/dist/src/core/run-payload.js +304 -0
  154. package/dist/src/core/run-payload.js.map +1 -0
  155. package/dist/src/core/sender-identity.d.ts +35 -10
  156. package/dist/src/core/sender-identity.js +48 -11
  157. package/dist/src/core/sender-identity.js.map +1 -1
  158. package/dist/src/core/state.d.ts +52 -1
  159. package/dist/src/core/state.js +52 -6
  160. package/dist/src/core/state.js.map +1 -1
  161. package/dist/src/core/telegram-config.d.ts +79 -0
  162. package/dist/src/core/telegram-config.js +108 -0
  163. package/dist/src/core/telegram-config.js.map +1 -1
  164. package/dist/src/core/verify.d.ts +23 -1
  165. package/dist/src/core/verify.js +20 -2
  166. package/dist/src/core/verify.js.map +1 -1
  167. package/dist/src/daemon/audit.d.ts +80 -5
  168. package/dist/src/daemon/audit.js +126 -6
  169. package/dist/src/daemon/audit.js.map +1 -1
  170. package/dist/src/daemon/daemon.d.ts +174 -2
  171. package/dist/src/daemon/daemon.js +298 -7
  172. package/dist/src/daemon/daemon.js.map +1 -1
  173. package/dist/src/daemon/git-evidence.d.ts +2 -2
  174. package/dist/src/daemon/git-evidence.js +1 -1
  175. package/dist/src/daemon/projection.d.ts +13 -2
  176. package/dist/src/daemon/projection.js +39 -6
  177. package/dist/src/daemon/projection.js.map +1 -1
  178. package/dist/src/mcp/server.d.ts +39 -3
  179. package/dist/src/mcp/server.js +22 -3
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/dist/src/muse/server.d.ts +16 -0
  182. package/dist/src/muse/server.js +238 -0
  183. package/dist/src/muse/server.js.map +1 -0
  184. package/dist/src/serve/archive.d.ts +158 -0
  185. package/dist/src/serve/archive.js +354 -0
  186. package/dist/src/serve/archive.js.map +1 -0
  187. package/dist/src/serve/arguments.d.ts +93 -0
  188. package/dist/src/serve/arguments.js +228 -0
  189. package/dist/src/serve/arguments.js.map +1 -0
  190. package/dist/src/serve/credentials.d.ts +91 -0
  191. package/dist/src/serve/credentials.js +143 -0
  192. package/dist/src/serve/credentials.js.map +1 -0
  193. package/dist/src/serve/follow.d.ts +86 -0
  194. package/dist/src/serve/follow.js +101 -0
  195. package/dist/src/serve/follow.js.map +1 -0
  196. package/dist/src/serve/hook-thread.d.ts +134 -0
  197. package/dist/src/serve/hook-thread.js +333 -0
  198. package/dist/src/serve/hook-thread.js.map +1 -0
  199. package/dist/src/serve/hook-worker.d.ts +134 -0
  200. package/dist/src/serve/hook-worker.js +173 -0
  201. package/dist/src/serve/hook-worker.js.map +1 -0
  202. package/dist/src/serve/server.d.ts +412 -0
  203. package/dist/src/serve/server.js +1251 -0
  204. package/dist/src/serve/server.js.map +1 -0
  205. package/docs/README-extended.md +766 -0
  206. package/docs/cli-reference.md +1633 -43
  207. package/package.json +4 -2
  208. package/schema/event.schema.json +59 -2
  209. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  210. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  211. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  212. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  213. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  214. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  215. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  216. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  217. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  218. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  219. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  220. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  221. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  222. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  223. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  224. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  225. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  226. package/schema/policy.schema.json +50 -6
@@ -37,9 +37,12 @@
37
37
  *
38
38
  * ## Lazy expiry — the named requirement
39
39
  *
40
- * A request expires when `ts > requestTs + defaults.approval_ttl`, **whether or
41
- * not** an `approval.expired` event exists. Nothing may depend on a daemon
42
- * having run: if the expiry sweep is asleep, a late grant must still be refused.
40
+ * A request expires when `ts > requestTs + effective window`, **whether or
41
+ * not** an `approval.expired` event exists. The effective window is
42
+ * `defaults.approval_ttl`, narrowed since APRV-423 by whatever harness ceiling
43
+ * the requesting hook declared on `payload.harness_cap_ms` (a self-reported
44
+ * bound that can only shorten; see `core/harness-wait.ts`). Nothing may depend
45
+ * on a daemon having run: if the expiry sweep is asleep, a late grant must still be refused.
43
46
  * {@link requestState} therefore computes expiry two ways — from the event, and
44
47
  * lazily from the arithmetic — and treats them as equivalent.
45
48
  *
@@ -124,7 +127,7 @@ import { normalizeUsd, usdOrZero } from "./money.js";
124
127
  import { isPayloadHash, payloadHash as hashOfPayload } from "./payload.js";
125
128
  import { loadPayload, payloadPath, payloadStoreDirFor, storePayload } from "./payload-store.js";
126
129
  import { loadPolicyText, policyUnreadable, POLICY_FILENAMES, tokenDeliveryOf, } from "./policy-load.js";
127
- import { harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, humanOnlyRefusal, resolve, } from "./policy-match.js";
130
+ import { agentRequestability, harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, humanOnlyRefusal, resolve, } from "./policy-match.js";
128
131
  import { DRAW_PROTOCOL_VERSION, askDaemonDraw, } from "./live-draw.js";
129
132
  import { LIVE_SELECTION, resolveLiveSelector, } from "./sampler.js";
130
133
  import { forgetPrivateKey, isRecipientKey, keyStoreDirFor, mintRecipientKeypair, RECIPIENT_KEY_FIELD, sealToken, SEALED_TOKEN_FIELD, SELF_DELIVERY_FIELD, writePrivateKey, } from "./seal.js";
@@ -533,6 +536,50 @@ export const GATE_REFUSAL_CODES = [
533
536
  * nothing can ever open. Nothing is appended; the next attempt asks again.
534
537
  */
535
538
  "token-delivery-unavailable",
539
+ /**
540
+ * `approval propose` named a class the policy has not opened to agents
541
+ * (APRV-445, amended SPEC.md §5.2): the class is not an exact key of
542
+ * `policy.classes`, or neither that key nor a declared `<prefix>.*` family of
543
+ * it sets `agent_may_request: true`. Nothing is registered and nothing is
544
+ * appended. The repair is the operator's, a policy edit and a re-attestation;
545
+ * no retry by the agent changes it.
546
+ */
547
+ "class-not-agent-requestable",
548
+ /**
549
+ * `approval propose` / `approval start` carried a payload larger than
550
+ * {@link PROPOSE_PAYLOAD_MAX_BYTES} (APRV-445). Refused before the bytes are
551
+ * parsed or hashed; nothing is stored and nothing is appended.
552
+ */
553
+ "payload-too-large",
554
+ /**
555
+ * `approval request` (or a task FILE's registration) named a `propose:` task
556
+ * (APRV-445 refutation, S1). A proposal is requested only through `propose`,
557
+ * which re-checks the operator's opening of its class; a plain request on the
558
+ * same task would skip that check, and a file-registered `propose:` id would
559
+ * borrow the exclusions proposals get from the harness hook.
560
+ */
561
+ "task-is-proposal",
562
+ /**
563
+ * `approval start` named a task `propose` did not register (APRV-445, L9).
564
+ * `start` records executions of proposals and of nothing else; every other
565
+ * action executes through `approval run`, an adapter, or the hook.
566
+ */
567
+ "task-not-proposal",
568
+ /**
569
+ * `approval propose --key` does not begin with `<class>:` (APRV-445, S4). The
570
+ * key names the class it was proposed under, so a key cannot be read back
571
+ * later as belonging to a different class than its record says.
572
+ */
573
+ "key-class-mismatch",
574
+ /**
575
+ * `approval propose` found the log moving under it on every one of its
576
+ * bounded attempts (APRV-445 recheck 3): each re-read showed an answer the
577
+ * next append was refused for. Exit 1, nothing more was appended, and the
578
+ * proposal's standing is whatever the log now says; read it with `wait
579
+ * --timeout 0` or call again. Distinct from `already-decided`, which would
580
+ * tell a caller a proposal is answered when it may stand granted or pending.
581
+ */
582
+ "contended",
536
583
  ];
537
584
  function refuse(code, message, extra = {}) {
538
585
  return { ok: false, code, message, ...extra };
@@ -665,6 +712,21 @@ function requireAttestation(records, read) {
665
712
  function ttlOf(load) {
666
713
  return load.ok ? load.durations.approvalTtlMs : null;
667
714
  }
715
+ /**
716
+ * The clause a refusal adds when a harness ceiling, rather than the policy,
717
+ * fixed this request's deadline (APRV-423).
718
+ *
719
+ * Empty for every request that declared no cap, which is every request made
720
+ * outside a harness hook — so the messages a human has been reading since v0.1
721
+ * are unchanged, and the one new sentence appears exactly where it explains
722
+ * something the policy alone does not.
723
+ */
724
+ function cappedBy(derivation) {
725
+ const cap = derivation.declared.harness_cap_ms;
726
+ if (cap === null)
727
+ return "";
728
+ return ` (shortened from the policy's own TTL: the requesting hook declared a ${String(cap)}ms harness ceiling, and the window ends a margin before it so the expiry is recorded while the harness is still listening)`;
729
+ }
668
730
  function budgetScopeOf(load, resolution) {
669
731
  return {
670
732
  classLimits: resolution.limits,
@@ -910,6 +972,15 @@ function attemptRegister(logPath, source, actor, options) {
910
972
  }
911
973
  return resolved.refusal;
912
974
  }
975
+ // APRV-445 refutation (S1). The `propose:` namespace belongs to `propose`;
976
+ // a task FILE claiming an id in it would borrow the exclusions proposals get
977
+ // from the harness hook (its sweep and its carry).
978
+ // Recheck L-c: the in-memory sources too (the muse `POST /v1/registrations`
979
+ // route registers whatever task id its caller names); only `propose` itself
980
+ // passes `proposal: true`.
981
+ if (isProposalTask(resolved.task) && options.proposal !== true) {
982
+ return refuse("task-is-proposal", `task id ${resolved.task} is in the namespace \`approval propose\` derives its ids in (${PROPOSE_TASK_PREFIX}); only propose registers there. Nothing was appended.`);
983
+ }
913
984
  const validation = validate("envelope", resolved.envelope, options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
914
985
  if (!validation.ok) {
915
986
  return refuse("envelope-invalid", `the envelope failed schema validation; nothing was appended`, { errors: validation.errors });
@@ -965,13 +1036,17 @@ function attemptRegister(logPath, source, actor, options) {
965
1036
  return { ok: true, record: appended.record, task: resolved.task, actions };
966
1037
  }
967
1038
  /**
968
- * The declared action for `(task, actionKey)`, as registered in the log.
969
- *
970
- * SPEC.md §7: "an action's class MUST be declared before an execution token can
971
- * be requested for it". The declaration lives in `task.registered`, so the log —
972
- * not the file, which may have been edited since — is what the gate reads back.
1039
+ * The `task.registered` record for `task`, or the `not-registered` refusal.
1040
+ *
1041
+ * The one raise site for SPEC.md §11.2's `not-registered` ("no task.registered
1042
+ * record exists for the task id"). `request` reaches it through
1043
+ * {@link registeredAction}; `approval wait` calls it directly (APRV-428), so a
1044
+ * wait on a task the log has never seen is refused in the same words rather
1045
+ * than answered as a vacuous grant. The latest registration wins, as it always
1046
+ * has for `registeredAction`; the register path refuses a second one, so a log
1047
+ * carrying two was written by something else.
973
1048
  */
974
- export function registeredAction(records, task, actionKey) {
1049
+ export function taskRegistration(records, task) {
975
1050
  let registration = null;
976
1051
  for (const record of records) {
977
1052
  if (record.event === "task.registered" && record.task === task)
@@ -980,6 +1055,20 @@ export function registeredAction(records, task, actionKey) {
980
1055
  if (registration === null) {
981
1056
  return refuse("not-registered", `task ${task} has no task.registered record; run \`approval register <task-file>\` first`);
982
1057
  }
1058
+ return { ok: true, record: registration };
1059
+ }
1060
+ /**
1061
+ * The declared action for `(task, actionKey)`, as registered in the log.
1062
+ *
1063
+ * SPEC.md §7: "an action's class MUST be declared before an execution token can
1064
+ * be requested for it". The declaration lives in `task.registered`, so the log —
1065
+ * not the file, which may have been edited since — is what the gate reads back.
1066
+ */
1067
+ export function registeredAction(records, task, actionKey) {
1068
+ const registered = taskRegistration(records, task);
1069
+ if (!registered.ok)
1070
+ return registered;
1071
+ const registration = registered.record;
983
1072
  const declared = payloadOf(registration)["actions"];
984
1073
  const actions = Array.isArray(declared) ? declared : [];
985
1074
  for (const entry of actions) {
@@ -1340,6 +1429,21 @@ function attemptRequest(logPath, input, actor, options) {
1340
1429
  return attested;
1341
1430
  const load = parsePolicy(policyRead, options);
1342
1431
  const resolution = resolve(load, input.cls, input.reversible === undefined ? {} : { reversible: input.reversible });
1432
+ // APRV-445 refutation (S1). A `propose:` task is requested through `propose`
1433
+ // and nothing else, so its agent-requestability check cannot be skipped by
1434
+ // asking for the same registration with a plain `request`.
1435
+ if (isProposalTask(input.task) && input.agentProposal !== true) {
1436
+ return refuse("task-is-proposal", `task ${input.task} was registered by \`approval propose\` and is requested only through it; nothing was appended`);
1437
+ }
1438
+ // APRV-445. A proposal's class came from the requester, so the operator's
1439
+ // opening of that class to agents is re-read here, from the same bytes the
1440
+ // attestation above verified, before anything else can be decided for it.
1441
+ if (input.agentProposal === true) {
1442
+ const opened = agentRequestability(load, input.cls);
1443
+ if (!opened.allowed) {
1444
+ return refuse("class-not-agent-requestable", notAgentRequestable(input.cls, opened));
1445
+ }
1446
+ }
1343
1447
  // APRV-185, amended SPEC.md §5.2, and the first thing intake asks once the
1344
1448
  // class has an autonomy: a `human-only` class is not requestable. The policy
1345
1449
  // itself answers, so nothing is put in front of a human, nothing is drawn,
@@ -1547,6 +1651,17 @@ function attemptRequest(logPath, input, actor, options) {
1547
1651
  if (derivation.execution.started !== null) {
1548
1652
  return refuse("already-executed", `action ${input.actionKey} already executed (execution.started at seq ${String(derivation.execution.started)}); an idempotency key is single-use`, { state: derivation.state });
1549
1653
  }
1654
+ // APRV-445 recheck (SF1). A proposal is asked again only when its last
1655
+ // answer can no longer be used, and that is judged HERE, against the records
1656
+ // this attempt appends on, not against whatever its caller read earlier. A
1657
+ // usable grant (unexpired, under the policy in force) or a human's refusal
1658
+ // stands: a stale caller may not put a fresh question over it.
1659
+ if (input.agentProposal === true) {
1660
+ const standing = requestStanding(read.records, input.actionKey, ts, ttlOf(load), attested.sha256);
1661
+ if (standing === "granted" || standing === "rejected" || standing === "revoked") {
1662
+ return refuse("already-decided", `action ${input.actionKey} already carries a ${standing} answer that still stands at seq ${String(derivation.decisionSeq)}; a proposal is asked again only when its answer can no longer be used. Nothing was appended.`, { state: derivation.state });
1663
+ }
1664
+ }
1550
1665
  // APRV-173, SPEC.md §5.2's request-volume limits, enforced here and nowhere
1551
1666
  // else. Placed AFTER the legality checks above and BEFORE budgets, and both
1552
1667
  // halves of that placement are deliberate.
@@ -1715,6 +1830,13 @@ function attemptRequest(logPath, input, actor, options) {
1715
1830
  payload["execution"] = input.execution;
1716
1831
  if (input.wait_until !== undefined)
1717
1832
  payload["wait_until"] = input.wait_until;
1833
+ // APRV-423, on the same terms as the two above: recorded because the log is
1834
+ // the only place a later reader — the daemon's sweep, a channel, the retry
1835
+ // that adopts this question — can learn what bounded it, and it cannot
1836
+ // lengthen anything. `core/state.ts` takes the minimum of this and the
1837
+ // policy's TTL; see `RequestInput.harnessCapMs`.
1838
+ if (input.harnessCapMs !== undefined)
1839
+ payload["harness_cap_ms"] = input.harnessCapMs;
1718
1840
  const appended = append(logPath, {
1719
1841
  ts,
1720
1842
  event: "approval.requested",
@@ -1889,7 +2011,7 @@ function attemptDecide(logPath, actionKey, decision, actor, options) {
1889
2011
  materialised = logged.record;
1890
2012
  }
1891
2013
  const message = derivation.expiredLazily
1892
- ? `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(ttlMs)}ms TTL before ${ts}. The lapse is judged from the request's own timestamp, so a decision is refused whether or not an approval.expired event had been observed.`
2014
+ ? `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(derivation.effectiveTtlMs)}ms window before ${ts}${cappedBy(derivation)}. The lapse is judged from the request's own timestamp, so a decision is refused whether or not an approval.expired event had been observed.`
1893
2015
  : `action ${actionKey} expired at ${String(derivation.decisionTs)} (approval.expired, seq ${String(derivation.decisionSeq)}); an expired request is terminal`;
1894
2016
  return refuse("expired", message, materialised === undefined
1895
2017
  ? { state: derivation.state }
@@ -2227,13 +2349,20 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
2227
2349
  if (logged.ok)
2228
2350
  materialised = logged.record;
2229
2351
  }
2230
- return refuse("expired", `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(ttlMs)}ms TTL before ${ts}. A lapsed request has already ended; there is nothing left to withdraw.`, materialised === undefined
2352
+ return refuse("expired", `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(derivation.effectiveTtlMs)}ms window before ${ts}${cappedBy(derivation)}. A lapsed request has already ended; there is nothing left to withdraw.`, materialised === undefined
2231
2353
  ? { state: derivation.state }
2232
2354
  : { state: derivation.state, record: materialised });
2233
2355
  }
2234
2356
  if (derivation.state !== "requested") {
2235
2357
  return refuse("already-decided", `action ${actionKey} was already ${derivation.state} at seq ${String(derivation.decisionSeq)}; a human's answer stands, and withdrawing a question that has been answered would erase the answer`, { state: derivation.state });
2236
2358
  }
2359
+ if (options.onlyIfVoid === true) {
2360
+ const attested = requireAttestation(read.records, readPolicyOnce(options));
2361
+ const pinned = derivation.declared.policy_sha256;
2362
+ if (!attested.ok || pinned === null || pinned === attested.sha256) {
2363
+ return refuse("duplicate-request", `action ${actionKey} has a live request at seq ${String(derivation.requestSeq)} routed under the policy in force, so it is not void and was not withdrawn`, { state: derivation.state });
2364
+ }
2365
+ }
2237
2366
  if (derivation.requestActor !== actor) {
2238
2367
  return refuse("not-requester", `action ${actionKey} was requested by ${JSON.stringify(derivation.requestActor)} and cannot be withdrawn by ${JSON.stringify(actor)}; only the party that asked may take the question back. To end a pending request as someone else, reject it — that is a decision, and it is recorded as one.`, { state: derivation.state });
2239
2368
  }
@@ -2282,8 +2411,9 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
2282
2411
  * refused at the append in {@link consumeHarnessGrant}. The single-use rule
2283
2412
  * is the gate's existing one; this only stops the caller from queueing up a
2284
2413
  * write that would be refused.
2285
- * - *within the TTL*: state is derived at `ts` with `ttlMs`, so a lapsed
2286
- * request reads `expired` and carries nothing, whether or not the daemon has
2414
+ * - *within the TTL*: state is derived at `ts` with `ttlMs`, narrowed by the
2415
+ * candidate's own declared harness ceiling (APRV-423), so a lapsed request
2416
+ * reads `expired` and carries nothing, whether or not the daemon has
2287
2417
  * materialised an `approval.expired` record.
2288
2418
  *
2289
2419
  * PURE, and reads only records the caller verified — the enforcement path never
@@ -2292,7 +2422,7 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
2292
2422
  * request that came after it, exactly as {@link requestState} treats cycles.
2293
2423
  */
2294
2424
  /**
2295
- * Has a GRANTED request outlived `defaults.approval_ttl`?
2425
+ * Has a GRANTED request outlived the window it was asked under?
2296
2426
  *
2297
2427
  * `requestState` reports a decided request by its decision forever: the TTL
2298
2428
  * bounds the window in which a human may answer, not the answer's shelf life.
@@ -2305,7 +2435,14 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
2305
2435
  * Unparseable instants read as lapsed, and a policy with no TTL declares no
2306
2436
  * lapse at all — both exactly as `core/token.ts` reads them.
2307
2437
  */
2308
- function grantLapsed(derivation, ts, ttlMs) {
2438
+ function grantLapsed(derivation, ts) {
2439
+ // APRV-423: the SAME window the request was judged by, read off the
2440
+ // derivation rather than recomputed from the policy. A grant whose question
2441
+ // was bounded by a harness ceiling keeps that bound; a retry a few seconds
2442
+ // later then asks again instead of carrying an answer the request's own
2443
+ // clock had already run out on. The stricter of the two readings, and the
2444
+ // one that keeps `requestState` the single place the deadline is decided.
2445
+ const ttlMs = derivation.effectiveTtlMs;
2309
2446
  if (ttlMs === null)
2310
2447
  return false;
2311
2448
  const requestedAt = Date.parse(derivation.requestTs ?? "");
@@ -2314,7 +2451,63 @@ function grantLapsed(derivation, ts, ttlMs) {
2314
2451
  return true;
2315
2452
  return asked > requestedAt + ttlMs;
2316
2453
  }
2317
- export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
2454
+ /**
2455
+ * Was the question already abandoned when it was answered? (APRV-410.)
2456
+ *
2457
+ * The state this names, observed 2026-09-20 in this repository's own log. A
2458
+ * hook wait expired at 18:52:37 on a request opened at 18:43:37; the retry
2459
+ * grace ran out at 18:57:37 with nothing retrying; a human tapped approve at
2460
+ * 19:03:32 and `approval.granted` seq 64473 was recorded, six minutes past the
2461
+ * grace, on a question no hook process was holding. The grant authorized
2462
+ * nothing that ran on the day only because the session had already moved on. It
2463
+ * was still live: a retry of that exact command in that directory inside the
2464
+ * TTL would have found it here and proceeded on it, which is one human tap
2465
+ * authorizing an execution nobody connected to the tap.
2466
+ *
2467
+ * {@link grantLapsed} bounds a grant by the request's TTL, which is the shelf
2468
+ * life of an ANSWER. This bounds it by the life of the QUESTION: past
2469
+ * `requestTs + abandonAfterMs` the asker is gone (`core/harness-wait.ts` is
2470
+ * where that duration and its reasoning live), so an answer arriving after it
2471
+ * answered nobody. Both bounds apply; either one alone lets a case through.
2472
+ *
2473
+ * Two facts and no third. The request's own runtime-assigned timestamp and the
2474
+ * decision record's own runtime-assigned timestamp, both read from the log the
2475
+ * caller verified. Nothing self-reported enters it, and the caller's
2476
+ * `abandonAfterMs` is the window the CALLER is configured for rather than
2477
+ * anything the requester said about itself, which is why a false claim cannot
2478
+ * widen it.
2479
+ *
2480
+ * Strictly stricter, and opt-in. A caller that passes no window gets exactly
2481
+ * today's behaviour, and a caller that passes one can only lose a carry it
2482
+ * would otherwise have had. Losing one costs a fresh question; keeping one
2483
+ * costs an execution on an answer nobody was waiting for.
2484
+ *
2485
+ * An unparseable instant on either side reads as abandoned, exactly as
2486
+ * {@link grantLapsed} reads one as lapsed: the strict direction is the one a
2487
+ * corrupt byte must resolve to.
2488
+ */
2489
+ function questionAbandoned(derivation, abandonAfterMs) {
2490
+ if (abandonAfterMs === null)
2491
+ return false;
2492
+ const requestedAt = Date.parse(derivation.requestTs ?? "");
2493
+ const answeredAt = Date.parse(derivation.decisionTs ?? "");
2494
+ if (Number.isNaN(requestedAt) || Number.isNaN(answeredAt))
2495
+ return true;
2496
+ return answeredAt > requestedAt + Math.max(0, abandonAfterMs);
2497
+ }
2498
+ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs,
2499
+ /**
2500
+ * How long a question of this caller's outlives the wait that opened it
2501
+ * (APRV-410, `core/harness-wait.ts`'s `abandonedAfterMs`), or `null` for a
2502
+ * caller that declares no such window.
2503
+ *
2504
+ * Only a GRANTED candidate is bounded by it. A pending one is the question
2505
+ * this invocation is about to adopt, and the withdrawal of an abandoned
2506
+ * pending question is the requester's own business, not this function's:
2507
+ * `cli/hook.ts` sweeps those through `withdraw`, which is requester-only by
2508
+ * design (APRV-106 rule 1).
2509
+ */
2510
+ abandonAfterMs = null) {
2318
2511
  if (!isPayloadHash(payloadHash))
2319
2512
  return null;
2320
2513
  // Distinct keys, latest request first: a later question about the same bytes
@@ -2331,6 +2524,11 @@ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
2331
2524
  continue;
2332
2525
  if (keys.includes(record.action_key))
2333
2526
  continue;
2527
+ // APRV-445. A proposal's harness grant belongs to `approval start` and to
2528
+ // nothing else: a hook call that happened to present the same bytes in the
2529
+ // same class must not carry or adopt it.
2530
+ if (isProposalTask(record.task))
2531
+ continue;
2334
2532
  const payload = payloadOf(record);
2335
2533
  if (payload["execution"] !== "harness")
2336
2534
  continue;
@@ -2354,7 +2552,13 @@ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
2354
2552
  continue;
2355
2553
  if (derivation.state === "granted") {
2356
2554
  // An answer has a shelf life, and it is its request's TTL.
2357
- if (grantLapsed(derivation, ts, ttlMs))
2555
+ // APRV-423: judged by the request's own effective window, read off the
2556
+ // derivation, never the raw policy TTL.
2557
+ if (grantLapsed(derivation, ts))
2558
+ continue;
2559
+ // And a question has a life of its own: an answer that landed after the
2560
+ // asker was gone authorized nobody (APRV-410).
2561
+ if (questionAbandoned(derivation, abandonAfterMs))
2358
2562
  continue;
2359
2563
  return {
2360
2564
  actionKey,
@@ -2424,6 +2628,43 @@ export const HARNESS_GRANT_ORIGIN = "grant_origin";
2424
2628
  * every reader treats absence as "no second name" rather than as a fault.
2425
2629
  */
2426
2630
  export const HARNESS_SPENDING_TASK = "spent_by_task";
2631
+ /**
2632
+ * The policy stamp every harness `execution.started` carries (APRV-447).
2633
+ *
2634
+ * `payload.policy_sha256` names the attested policy the write boundary
2635
+ * re-checked this start's class against: the hash {@link requireAttestation}
2636
+ * returned for the one policy read the appending attempt made, which is the
2637
+ * read the class was resolved from and the attestation was judged on. On the
2638
+ * hook route the verdict itself was drawn from the hook's own, earlier read;
2639
+ * a re-attest landing between that read and this append leaves the stamp
2640
+ * naming the policy in force at the append (the two-read window predates this
2641
+ * field, and is a follow-up to close). Before this field a
2642
+ * policy-authorized start (autonomous, supervised, an unselected live draw)
2643
+ * carried no policy at all, and a reader had to reconstruct the rules in force
2644
+ * from the latest `policy.updated` before the record's seq. A granted start
2645
+ * could recover it through `grant_seq`; it is stamped too, by the same line, so
2646
+ * every harness start answers the question the same way.
2647
+ *
2648
+ * WRITTEN, never accepted. The value is passed in from the attempt's own
2649
+ * attestation check and from nowhere else: neither {@link HarnessStartInput}
2650
+ * nor {@link ConsumeHarnessOptions} has a field for it, and the payload object
2651
+ * is built here from runtime-held values, so a caller's input, option bag or
2652
+ * action payload that happens to carry the same name changes nothing (§11.1
2653
+ * invariant 1: the stamp comes from the verified attestation the gate already
2654
+ * resolved, never from an envelope). An unattested or edited policy never
2655
+ * reaches this line: both write paths refuse `policy-not-attested` first, and
2656
+ * a grant spend under a policy other than the one the grant pinned is refused
2657
+ * `policy-drift` before its payload is built.
2658
+ *
2659
+ * READ by nothing that decides. No verdict, budget, draw, loop floor or
2660
+ * single-use check consults it, and `core/state.ts` reads `policy_sha256` from
2661
+ * `approval.requested` alone. Its readers are downstream followers of the log
2662
+ * that join a start to the rules it ran under, so the field NAME is a stable
2663
+ * contract.
2664
+ */
2665
+ function harnessStartPolicyStamp(attestedSha256) {
2666
+ return { [POLICY_HASH_FIELD]: attestedSha256 };
2667
+ }
2427
2668
  /**
2428
2669
  * Spend a harness grant, exactly once (APRV-117).
2429
2670
  *
@@ -2537,8 +2778,8 @@ function attemptHarnessConsume(logPath, actionKey, actor, options) {
2537
2778
  if (derivation.declared.execution !== "harness") {
2538
2779
  return refuse("not-granted", `action ${actionKey} was granted as an ordinary request and minted an execution token; it is spent by presenting that token to \`approval run\`, not by a harness proceeding on it. Two spenders of one authorization is the property this refuses.`, { state: derivation.state });
2539
2780
  }
2540
- if (grantLapsed(derivation, ts, ttlOf(load))) {
2541
- return refuse("expired", `action ${actionKey}'s grant expired: the request at ${String(derivation.requestTs)} lapsed its TTL before ${ts}. There is no separate grant TTL — an approval lives exactly as long as its parent request, which is the rule \`core/token.ts\` applies to a token-bearing grant.`, { state: derivation.state });
2781
+ if (grantLapsed(derivation, ts)) {
2782
+ return refuse("expired", `action ${actionKey}'s grant expired: the request at ${String(derivation.requestTs)} lapsed its ${String(derivation.effectiveTtlMs)}ms window before ${ts}${cappedBy(derivation)}. There is no separate grant TTL — an approval lives exactly as long as its parent request, which is the rule \`core/token.ts\` applies to a token-bearing grant.`, { state: derivation.state });
2542
2783
  }
2543
2784
  if (derivation.task === null) {
2544
2785
  return refuse("not-registered", `action ${actionKey} has a grant but no task on its request record; an execution event names both (SPEC.md §8) and nothing here invents one`, { state: derivation.state });
@@ -2614,6 +2855,11 @@ function attemptHarnessConsume(logPath, actionKey, actor, options) {
2614
2855
  options.spendingTask === derivation.task
2615
2856
  ? "direct"
2616
2857
  : "carried"),
2858
+ // APRV-447: the attested policy this spend was judged under, the same
2859
+ // value `policy-drift` compared against the grant's pin above, so on this
2860
+ // path it equals that pin wherever the grant carries one. See
2861
+ // `harnessStartPolicyStamp` for why it is stamped and who reads it.
2862
+ ...harnessStartPolicyStamp(attested.sha256),
2617
2863
  };
2618
2864
  // APRV-287. A carried spend records WHICH tool call spent it, so the
2619
2865
  // completion counterpart can find this start from the event that reports how
@@ -2765,6 +3011,16 @@ function attemptHarnessStart(logPath, input, actor, options) {
2765
3011
  if (!isPayloadHash(bytes)) {
2766
3012
  return refuse("payload-hash-required", `recording a harness execution of ${input.actionKey} requires the payload_hash of what is about to run (amended SPEC.md §6.2, APRV-140), and this caller presented ${bytes === undefined ? "none" : JSON.stringify(bytes)}. A start event that cannot state its bytes says only that something ran; the harness computes the hash of the payload it is about to execute and passes it here. Nothing was appended.`);
2767
3013
  }
3014
+ // APRV-445 security pass. A proposal's start reaches this function only when
3015
+ // its key holds no live request, and the task id alone says it is a proposal
3016
+ // (read from the task, never from a caller flag). Re-judged here, inside the
3017
+ // attempt that appends, so a request filed or decided after `startProposed`
3018
+ // looked cannot slip past. Checked before the budget verdict, which writes.
3019
+ if (isProposalTask(input.task)) {
3020
+ const refusal = proposalPolicyStartRefusal(logPath, read.records, input.actionKey, bytes, load, resolution, attested.sha256, ts, options);
3021
+ if (refusal !== null)
3022
+ return refusal;
3023
+ }
2768
3024
  const cost = costOf(input.est_cost_usd);
2769
3025
  const budget = evaluateBudgetsWithTask(read.records, budgetScopeOf(load, resolution), { class: input.cls, est_cost_usd: cost }, ts, input.task);
2770
3026
  if (!budget.pass) {
@@ -2798,6 +3054,11 @@ function attemptHarnessStart(logPath, input, actor, options) {
2798
3054
  // APRV-146: unconditional, because a caller that states no bytes was refused
2799
3055
  // above. The record says what ran, not only that something did.
2800
3056
  payload_hash: bytes,
3057
+ // APRV-447: the attested policy this write boundary re-checked the class
3058
+ // against (see `harnessStartPolicyStamp` for the hook's two-read window).
3059
+ // Without it a reader of a policy-authorized start had to reconstruct the
3060
+ // rules in force from the latest `policy.updated` before this seq.
3061
+ ...harnessStartPolicyStamp(attested.sha256),
2801
3062
  };
2802
3063
  const appended = append(logPath, {
2803
3064
  ts,
@@ -2814,6 +3075,54 @@ function attemptHarnessStart(logPath, input, actor, options) {
2814
3075
  return appended;
2815
3076
  return { ok: true, record: appended.record };
2816
3077
  }
3078
+ /**
3079
+ * Why a proposal may NOT start on the policy path, or `null` when it may
3080
+ * (APRV-445 security pass).
3081
+ *
3082
+ * The policy path records an execution the policy itself authorizes. For a
3083
+ * proposal that is true only when no human question about these bytes is open
3084
+ * or answered no under the policy in force, and when the class would not have
3085
+ * put this action in front of a human at all. Three refusals, all `not-granted`:
3086
+ *
3087
+ * - a request for the key is pending or granted and unspent: the key is bound to
3088
+ * the grant path, and `start` must spend the grant (or wait for the decision);
3089
+ * - the key's latest request was rejected or revoked under the attested policy
3090
+ * (or pinned no policy): a human's no under the rules in force stands. After a
3091
+ * re-attest the rules that asked are gone, and the action is judged afresh;
3092
+ * - the class is `supervised-live` and the draw selects these bytes: the draw is
3093
+ * deterministic over the policy and the payload hash, so it reproduces the
3094
+ * intake answer, and a selected action is authorized by a grant, never by the
3095
+ * policy. Without this a withdrawn, expired, rejected or never-filed request
3096
+ * for a selected live action turned into a policy-authorized start.
3097
+ *
3098
+ * Asking the draw appends nothing; a draw that cannot be made gates, so this
3099
+ * fails closed.
3100
+ */
3101
+ function proposalPolicyStartRefusal(logPath, records, actionKey, payloadHash, load, resolution, attestedSha256, ts, options) {
3102
+ const derivation = requestState(records, actionKey, ts, ttlOf(load));
3103
+ if (derivation.execution.started === null &&
3104
+ (derivation.state === "requested" || derivation.state === "granted")) {
3105
+ return refuse("not-granted", `action ${actionKey} has a ${derivation.state} request (seq ${String(derivation.requestSeq)}), so it is bound to the grant path: a pending request is decided by a human and a granted one is spent by start through the grant. Start it again; nothing was appended.`);
3106
+ }
3107
+ if (derivation.state === "rejected" || derivation.state === "revoked") {
3108
+ const pinned = derivation.declared.policy_sha256;
3109
+ if (pinned === null || pinned === attestedSha256) {
3110
+ return refuse("not-granted", `action ${actionKey} was ${derivation.state} by a human (seq ${String(derivation.decisionSeq)}) under the policy in force, and a human's no is not turned into a policy-authorized start. Propose it again only after the policy changes; nothing was appended.`);
3111
+ }
3112
+ }
3113
+ if (resolution.supervision === "live") {
3114
+ const live = liveVerdict(load, resolution, payloadHash, options.env, {
3115
+ logPath,
3116
+ policyHash: attestedSha256,
3117
+ actionKey,
3118
+ ask: options.drawAsk ?? askDaemonDraw,
3119
+ });
3120
+ if (live.gated) {
3121
+ return refuse("not-granted", `class ${String(derivation.declared.class)} of ${actionKey} is supervised-live and the draw put these bytes in front of a human (${live.reason}); a selected action is authorized by a human's grant, never by the policy. Propose it and spend the grant; nothing was appended.`);
3122
+ }
3123
+ }
3124
+ return null;
3125
+ }
2817
3126
  // ---------------------------------------------------------------------------
2818
3127
  // finishHarnessExecution — the completion counterpart (APRV-145)
2819
3128
  // ---------------------------------------------------------------------------
@@ -3008,9 +3317,19 @@ function appendExpiry(logPath, derivation, load, ts, options, expectedHead) {
3008
3317
  const payload = {};
3009
3318
  if (derivation.requestTs !== null)
3010
3319
  payload["requested_ts"] = derivation.requestTs;
3011
- const ttlMs = ttlOf(load);
3320
+ // The window this request was ACTUALLY judged by, which is the policy's TTL
3321
+ // narrowed by whatever harness ceiling the requester declared (APRV-423).
3322
+ // Recording the policy's number here while expiring on a shorter one would
3323
+ // leave the record disagreeing with the arithmetic that produced it, and the
3324
+ // reader's obvious check — requested_ts + ttl_ms against ts — would fail on a
3325
+ // correct expiry. The cap rides along beside it so a reader can see WHY the
3326
+ // window was short without holding the request record too.
3327
+ const ttlMs = derivation.effectiveTtlMs;
3012
3328
  if (ttlMs !== null)
3013
3329
  payload["ttl_ms"] = ttlMs;
3330
+ if (derivation.declared.harness_cap_ms !== null) {
3331
+ payload["harness_cap_ms"] = derivation.declared.harness_cap_ms;
3332
+ }
3014
3333
  const onExpiry = load.ok ? load.policy.defaults?.on_expiry : undefined;
3015
3334
  if (onExpiry !== undefined)
3016
3335
  payload["on_expiry"] = onExpiry;
@@ -3063,9 +3382,9 @@ export function expire(logPath, actionKey, options = {}) {
3063
3382
  if (derivation.state !== "requested") {
3064
3383
  return refuse("already-decided", `action ${actionKey} was already ${derivation.state} at seq ${String(derivation.decisionSeq)}; only a live request can expire`, { state: derivation.state });
3065
3384
  }
3066
- return refuse("not-expired", ttlMs === null
3067
- ? `action ${actionKey} cannot expire: the policy declares no defaults.approval_ttl, so the request is not bounded by a TTL`
3068
- : `action ${actionKey} has not expired: the request at ${String(derivation.requestTs)} has not lapsed its ${String(ttlMs)}ms TTL as of ${ts}`, { state: derivation.state });
3385
+ return refuse("not-expired", derivation.effectiveTtlMs === null
3386
+ ? `action ${actionKey} cannot expire: the policy declares no defaults.approval_ttl and the request declares no harness cap, so it is not bounded by any deadline`
3387
+ : `action ${actionKey} has not expired: the request at ${String(derivation.requestTs)} has not lapsed its ${String(derivation.effectiveTtlMs)}ms window as of ${ts}${cappedBy(derivation)}`, { state: derivation.state });
3069
3388
  }
3070
3389
  const expired = appendExpiry(logPath, derivation, load, ts, options, read.head);
3071
3390
  if (expired.ok) {
@@ -3079,4 +3398,433 @@ export function expire(logPath, actionKey, options = {}) {
3079
3398
  }
3080
3399
  return expired;
3081
3400
  }
3401
+ // ---------------------------------------------------------------------------
3402
+ // propose (APRV-445)
3403
+ // ---------------------------------------------------------------------------
3404
+ /**
3405
+ * The largest payload `approval propose` and `approval start` accept, in UTF-8
3406
+ * bytes of the JSON text as the caller sent it: 256 KiB.
3407
+ *
3408
+ * Well under `approval serve`'s 1 MiB body cap, so the refusal a caller meets is
3409
+ * this named one rather than a transport's. A proposal is a sentence or a
3410
+ * paragraph a human will read on a phone; a payload this size is not one.
3411
+ */
3412
+ export const PROPOSE_PAYLOAD_MAX_BYTES = 256 * 1024;
3413
+ /** The longest `--key` `propose` accepts, in UTF-8 bytes (APRV-445, L6). */
3414
+ export const PROPOSE_KEY_MAX_BYTES = 1024;
3415
+ /** The longest `--summary` `propose` accepts, in UTF-8 bytes (APRV-445, L6). */
3416
+ export const PROPOSE_SUMMARY_MAX_BYTES = 4096;
3417
+ /**
3418
+ * The id after `<class>:` (APRV-445 recheck L-b): one or more printable
3419
+ * characters, no whitespace, no control, format, private-use, unassigned or
3420
+ * surrogate code points, so a key reads back the same in a log, a phone and a
3421
+ * shell.
3422
+ */
3423
+ const PROPOSE_KEY_ID = /^[^\s\p{C}]+$/u;
3424
+ /** How many times `propose` starts again on a log that moved under it (SF1). */
3425
+ const PROPOSE_MAX_ATTEMPTS = 4;
3426
+ /** `origin.app` on every envelope `propose` registers. */
3427
+ export const PROPOSE_ORIGIN_APP = "approval-propose";
3428
+ /** The task-id prefix `propose` derives under. */
3429
+ export const PROPOSE_TASK_PREFIX = "propose:";
3430
+ /**
3431
+ * Was this task registered by `propose` (APRV-445)?
3432
+ *
3433
+ * A proposal's request carries `execution: "harness"` like a hook's, so the two
3434
+ * hook-side readers of that marker (the carry and the abandoned-question sweep)
3435
+ * ask this first: a proposal waits days for a human, is spent only by `start`,
3436
+ * and is withdrawn only by its requester's own call.
3437
+ */
3438
+ export function isProposalTask(task) {
3439
+ return typeof task === "string" && task.startsWith(PROPOSE_TASK_PREFIX);
3440
+ }
3441
+ /** A concrete class, as `schema/envelope.schema.json` spells one: no wildcard. */
3442
+ const CONCRETE_CLASS = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/u;
3443
+ /**
3444
+ * The task id `propose` registers under, from who is asking, the class and the
3445
+ * key: `propose:` and the first 32 hex digits of the SHA-256 over the RFC 8785
3446
+ * canonical JSON of `[actor, class, key]`.
3447
+ *
3448
+ * Deterministic, so a retry lands on the task its first attempt registered and
3449
+ * is recognised as a retry. The ACTOR is the tenant half of the derivation: one
3450
+ * `approval serve` process serves one store as one fixed `agent:<id>`, so within
3451
+ * a store the actor is who the tenant's agent is, and two actors proposing the
3452
+ * same key derive two different tasks, the second of which `register` refuses
3453
+ * because the key is already declared under the first (`task-already-registered`).
3454
+ * The class is in it so that the same key proposed under a second class is a
3455
+ * different task rather than a silent re-declaration.
3456
+ */
3457
+ export function proposedTaskId(actor, cls, actionKey) {
3458
+ return `${PROPOSE_TASK_PREFIX}${hashOfPayload([actor, cls, actionKey]).slice(0, 32)}`;
3459
+ }
3460
+ /** The sentence a `class-not-agent-requestable` refusal says. */
3461
+ function notAgentRequestable(cls, opened) {
3462
+ const why = !opened.explicit
3463
+ ? `${JSON.stringify(cls)} is not a key of the policy's \`classes\` map, and propose takes only a class the operator declared by name (a wildcard rule that happens to match does not count)`
3464
+ : opened.pattern === null
3465
+ ? `neither ${JSON.stringify(cls)} nor any declared \`<prefix>.*\` family of it sets agent_may_request: true`
3466
+ : `${JSON.stringify(opened.pattern)} sets agent_may_request: false`;
3467
+ return `class ${cls} is not open to agent requests: ${why}. Nothing was registered and nothing was appended. Opening a class to agents is a policy edit (agent_may_request: true on the class or its family) and a human re-attestation (amended SPEC.md §5.2).`;
3468
+ }
3469
+ /**
3470
+ * `approval propose` (APRV-445, amended SPEC.md §5.2): register and request one
3471
+ * action in one call, for a requester that holds the payload inline and no file
3472
+ * on the gate's machine.
3473
+ *
3474
+ * ## Why this verb may name its class when `request` may not
3475
+ *
3476
+ * `approval request` reads the class from the log's `task.registered` record so
3477
+ * that an agent cannot call a `financial.spend` a `read.web`. Here the agent is
3478
+ * also the registrant, so that rule alone would prove nothing, and the bound is
3479
+ * the operator's instead: the class must be an EXACT key of `policy.classes`, and
3480
+ * that key or a declared `<prefix>.*` family of it must say
3481
+ * `agent_may_request: true`. A class reached only through a wildcard is refused,
3482
+ * so the set of classes an agent can propose in is a set of lines the operator
3483
+ * wrote and attested by name. What the agent chooses among them is still its own
3484
+ * statement (SPEC.md §11.1 invariant 4 is why the choice is bounded at all), and
3485
+ * an operator opening two classes of different autonomy to agents is trusting
3486
+ * the agent to pick the right one; the record names the class it picked.
3487
+ *
3488
+ * ## Order
3489
+ *
3490
+ * Attestation, then the agent-requestability gate and the class's own autonomy
3491
+ * (`human-only`, an unruled `harness.launch.*`), all BEFORE anything is
3492
+ * registered, so a refused class leaves no registration behind. Then the
3493
+ * registration, then {@link request} with `agentProposal: true`, which repeats
3494
+ * the gate against the bytes its own attempt reads. `execution: "harness"` on the
3495
+ * request: nothing ever presents a token for a proposal, the requester proceeds
3496
+ * on the grant itself and records that with `approval start`, so a minted token
3497
+ * would be a live credential with no spender.
3498
+ *
3499
+ * ## Idempotency
3500
+ *
3501
+ * The task id is {@link proposedTaskId}. A second call with the same actor,
3502
+ * class, key and payload hash finds its own registration and appends nothing
3503
+ * while the request is live or decided, answering with the request's state and
3504
+ * `idempotent: true`. A withdrawn or expired request (a re-attestation voids
3505
+ * pending requests as `policy-drift`) is asked again under the same registration.
3506
+ * The same key with DIFFERENT bytes refuses: `duplicate-request` while a request
3507
+ * is live, `payload-mismatch` otherwise, because a key is bound to the bytes it
3508
+ * was first registered with and new bytes need a new key.
3509
+ */
3510
+ export function propose(logPath, input, actor, options = {}, attempt = 0, appendedEarlier = false) {
3511
+ // APRV-445 recheck (SF1). Every decision below is re-checked by the append
3512
+ // it leads to (`withdraw` with `onlyIfVoid`, `request` with
3513
+ // `agentProposal`), so a caller that read a stale log is refused there
3514
+ // rather than writing over a concurrent caller. A refusal of that kind means
3515
+ // the log moved under this call; the call starts again from a fresh read, a
3516
+ // bounded number of times, and answers what it then finds.
3517
+ const again = (refusal) => attempt < PROPOSE_MAX_ATTEMPTS
3518
+ ? propose(logPath, input, actor, options, attempt + 1, appendedEarlier || withdrewNow || registeredNow)
3519
+ : refuse("contended", `the log moved under propose on each of its ${String(PROPOSE_MAX_ATTEMPTS + 1)} attempts for ${input.actionKey} (last refusal: ${refusal.code}); nothing more was appended. Read the proposal's standing with \`approval wait <task> --timeout 0\`, or call again.`);
3520
+ if (!isPrincipalActor(actor)) {
3521
+ return refuse("actor-invalid", `propose requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
3522
+ }
3523
+ if (!CONCRETE_CLASS.test(input.cls)) {
3524
+ return refuse("envelope-invalid", `class ${JSON.stringify(input.cls)} is not a concrete action class: lowercase dot-separated segments of [a-z0-9_-], no wildcard. Nothing was registered and nothing was appended.`);
3525
+ }
3526
+ if (input.actionKey.length === 0) {
3527
+ return refuse("envelope-invalid", "the action key is empty; nothing was registered and nothing was appended.");
3528
+ }
3529
+ // APRV-445 refutation (S4): the key carries its class, so a consumer that
3530
+ // parses the key can never attribute it to a class its record does not name.
3531
+ // Consumers read the class from the record; this keeps the key from lying.
3532
+ if (!input.actionKey.startsWith(`${input.cls}:`) ||
3533
+ !PROPOSE_KEY_ID.test(input.actionKey.slice(input.cls.length + 1))) {
3534
+ return refuse("key-class-mismatch", `key ${JSON.stringify(input.actionKey)} is not ${JSON.stringify(`${input.cls}:`)} followed by an identifier of printable characters with no whitespace or control characters; a proposal's key names the class it is proposed under. Nothing was registered and nothing was appended.`);
3535
+ }
3536
+ if (Buffer.byteLength(input.actionKey, "utf8") > PROPOSE_KEY_MAX_BYTES) {
3537
+ return refuse("envelope-invalid", `the key is over ${String(PROPOSE_KEY_MAX_BYTES)} bytes; nothing was registered and nothing was appended.`);
3538
+ }
3539
+ if (Buffer.byteLength(input.summary, "utf8") > PROPOSE_SUMMARY_MAX_BYTES) {
3540
+ return refuse("envelope-invalid", `the summary is over ${String(PROPOSE_SUMMARY_MAX_BYTES)} bytes; nothing was registered and nothing was appended.`);
3541
+ }
3542
+ let hash;
3543
+ try {
3544
+ hash = hashOfPayload(input.payload);
3545
+ }
3546
+ catch (cause) {
3547
+ return refuse("payload-store-failed", `the payload for ${input.actionKey} could not be canonicalized: ${cause instanceof Error ? cause.message : String(cause)}. Nothing was registered and nothing was appended.`);
3548
+ }
3549
+ const task = proposedTaskId(actor, input.cls, input.actionKey);
3550
+ const read = readGateRecords(logPath);
3551
+ if (!read.ok)
3552
+ return read;
3553
+ const policyRead = readPolicyOnce(options);
3554
+ const attested = requireAttestation(read.records, policyRead);
3555
+ if (!attested.ok)
3556
+ return attested;
3557
+ const load = parsePolicy(policyRead, options);
3558
+ const opened = agentRequestability(load, input.cls);
3559
+ const resolution = resolve(load, input.cls);
3560
+ if (!opened.allowed && !(opened.humanOnly && opened.explicit && opened.pattern !== null)) {
3561
+ return refuse("class-not-agent-requestable", notAgentRequestable(input.cls, opened));
3562
+ }
3563
+ if (harnessLaunchNeedsRule(input.cls, resolution)) {
3564
+ return refuse("harness-launch-unruled", harnessLaunchUnruledRefusal(input.cls, `the proposal ${input.actionKey} was not registered and nothing was appended`));
3565
+ }
3566
+ if (resolution.autonomy === "human-only") {
3567
+ return refuse("class-human-only", humanOnlyRefusal(input.cls, `the proposal ${input.actionKey} was not registered and nothing was appended`));
3568
+ }
3569
+ const answered = (decision, state, seq, idempotent) => ({
3570
+ ok: true,
3571
+ task,
3572
+ actionKey: input.actionKey,
3573
+ cls: input.cls,
3574
+ payloadHash: hash,
3575
+ decision,
3576
+ state,
3577
+ seq,
3578
+ idempotent: idempotent && !appendedEarlier,
3579
+ });
3580
+ const existing = registeredAction(read.records, task, input.actionKey);
3581
+ let registeredNow = false;
3582
+ let withdrewNow = false;
3583
+ if (existing.ok) {
3584
+ const derivation = requestState(read.records, input.actionKey, tick(options), ttlOf(load));
3585
+ if (existing.action.payload_hash !== hash) {
3586
+ if (derivation.state === "requested") {
3587
+ return refuse("duplicate-request", `action ${input.actionKey} already has a live request at seq ${String(derivation.requestSeq)} awaiting a decision, over different bytes (${String(existing.action.payload_hash)}; this call presents ${hash}). A key is bound to the bytes it was first proposed with: wait for that request, withdraw it, or propose the new bytes under a new key. Nothing was appended.`, { state: derivation.state });
3588
+ }
3589
+ return refuse("payload-mismatch", `action ${input.actionKey} is registered under task ${task} with payload ${String(existing.action.payload_hash)}, and this call presents ${hash}. A key is bound to the bytes it was first proposed with; propose new bytes under a new key. Nothing was appended.`, { state: derivation.state });
3590
+ }
3591
+ // APRV-445 refutation (B1): the retry is judged by what the request still
3592
+ // AUTHORIZES, not by what it recorded. A live question, a usable grant, a
3593
+ // human's no and a started execution are answered from the log and append
3594
+ // nothing. A question that can no longer be answered usefully is asked
3595
+ // again under the same registration: a lapsed or withdrawn request, a grant
3596
+ // whose window ran out (a tap at hour 71 of 72 authorizes nothing at 73),
3597
+ // and a grant or pending request pinned to a policy that has since been
3598
+ // re-attested (`void`: the spend and the decision both refuse it).
3599
+ const standing = requestStanding(read.records, input.actionKey, tick(options), ttlOf(load), attested.sha256);
3600
+ const seqOf = derivation.decisionSeq ?? derivation.requestSeq;
3601
+ if (standing === "executed") {
3602
+ return answered(derivation.requestSeq !== null
3603
+ ? "requested"
3604
+ : resolution.autonomy === "autonomous"
3605
+ ? "autonomous"
3606
+ : "supervised", "executed", seqOf, true);
3607
+ }
3608
+ if (standing === "requested" ||
3609
+ standing === "granted" ||
3610
+ standing === "rejected" ||
3611
+ standing === "revoked") {
3612
+ return answered("requested", standing, seqOf, true);
3613
+ }
3614
+ if (standing === "void" && derivation.state === "requested") {
3615
+ // A pending question no human may decide any more. Taken back by its own
3616
+ // requester (the gate's only way to end a request early that is not a
3617
+ // decision), then asked again below.
3618
+ const retracted = withdraw(logPath, input.actionKey, actor, {
3619
+ ...options,
3620
+ onlyIfVoid: true,
3621
+ reason: "superseded",
3622
+ note: "the policy this proposal was routed under has been re-attested, so the request is void; the same proposal is asked again under the policy in force",
3623
+ });
3624
+ if (!retracted.ok) {
3625
+ // A concurrent caller withdrew it, re-asked it, or a human answered it.
3626
+ if (retracted.code === "request-withdrawn" ||
3627
+ retracted.code === "already-decided" ||
3628
+ retracted.code === "duplicate-request" ||
3629
+ retracted.code === "expired") {
3630
+ return again(retracted);
3631
+ }
3632
+ return retracted;
3633
+ }
3634
+ withdrewNow = true;
3635
+ }
3636
+ }
3637
+ else if (existing.code === "not-registered") {
3638
+ const registered = register(logPath, {
3639
+ task,
3640
+ envelope: {
3641
+ origin: { app: PROPOSE_ORIGIN_APP, created_by: actor },
3642
+ state: "proposed",
3643
+ actions: [
3644
+ {
3645
+ class: input.cls,
3646
+ summary: input.summary,
3647
+ idempotency_key: input.actionKey,
3648
+ payload_hash: hash,
3649
+ },
3650
+ ],
3651
+ },
3652
+ }, actor, { ...options, proposal: true });
3653
+ if (!registered.ok) {
3654
+ // APRV-445 (L5). Two identical proposals racing: the other one registered
3655
+ // this exact task first. Its registration is ours byte for byte, so the
3656
+ // call is a retry and is answered as one.
3657
+ if (registered.code === "task-already-registered")
3658
+ return again(registered);
3659
+ return registered;
3660
+ }
3661
+ registeredNow = true;
3662
+ }
3663
+ else {
3664
+ // The derived task exists and declares some other key: nothing `propose`
3665
+ // registers looks like that, so somebody else registered this id.
3666
+ return refuse("task-already-registered", `task ${task} is already registered without action ${input.actionKey}; propose registers each derived task with exactly one action, so this id was registered by something else and is not reused. Nothing was appended.`);
3667
+ }
3668
+ const result = request(logPath, {
3669
+ task,
3670
+ actionKey: input.actionKey,
3671
+ cls: input.cls,
3672
+ summary: input.summary,
3673
+ payload_hash: hash,
3674
+ payload: { value: input.payload },
3675
+ execution: "harness",
3676
+ agentProposal: true,
3677
+ }, actor, options);
3678
+ if (!result.ok) {
3679
+ if (result.code === "duplicate-request" || result.code === "already-decided" || result.code === "already-executed") {
3680
+ return again(result);
3681
+ }
3682
+ return result;
3683
+ }
3684
+ if (result.record === null) {
3685
+ return answered(result.autonomy === "autonomous" ? "autonomous" : "supervised", null, null,
3686
+ // APRV-445 recheck (L-a): a call that withdrew a void request appended
3687
+ // something, so it is not an idempotent answer.
3688
+ !registeredNow && !withdrewNow);
3689
+ }
3690
+ return answered("requested", "requested", result.record.seq, false);
3691
+ }
3692
+ /**
3693
+ * `approval start <task> --action <key> --payload-json <json>` (APRV-445): record
3694
+ * the `execution.started` for an action its requester is about to carry out
3695
+ * itself, outside any harness hook.
3696
+ *
3697
+ * Until this verb an agent holding only the serve agent credential could ask and
3698
+ * wait, and could not say that it then acted: `approval run` spawns argv on the
3699
+ * gate's machine, and the harness hook writes starts only for tool calls it
3700
+ * classified. A proposal the policy authorizes outright (`autonomous`,
3701
+ * `supervised`) therefore left no record at all, and one a human granted left a
3702
+ * grant nobody spent, so a second publication of the same key was indistinguishable
3703
+ * from the first.
3704
+ *
3705
+ * It adds no authority. It writes through the two existing spenders and through
3706
+ * nothing else: {@link consumeHarnessGrant} when the key carries a request (it
3707
+ * refuses unless that request is granted, unexpired, harness-executed and
3708
+ * unspent, under the policy it was asked under), and {@link startHarnessExecution}
3709
+ * when it carries none (it refuses a `manual` or `human-only` class, an escalated
3710
+ * loop and a spent key, and charges the budgets). Both pin the single-use rule:
3711
+ * a second start of one key is `already-executed`.
3712
+ *
3713
+ * Two checks of its own, both before either spender runs. REQUESTER-ONLY: the
3714
+ * actor must be the one that registered the task (`not-requester` otherwise), as
3715
+ * `withdraw` is, so an agent cannot spend or exhaust a key someone else declared.
3716
+ * And the bytes: the caller presents the payload it is about to act on, and its
3717
+ * hash must equal the registered `payload_hash` (`payload-mismatch` otherwise),
3718
+ * so the thing done is the thing that was approved, which the grant path checks
3719
+ * again against the grant's own binding.
3720
+ */
3721
+ export function startProposed(logPath, task, actionKey, payload, actor, options = {}) {
3722
+ if (!isPrincipalActor(actor)) {
3723
+ return refuse("actor-invalid", `start requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
3724
+ }
3725
+ let hash;
3726
+ try {
3727
+ hash = hashOfPayload(payload);
3728
+ }
3729
+ catch (cause) {
3730
+ return refuse("payload-store-failed", `the payload for ${actionKey} could not be canonicalized: ${cause instanceof Error ? cause.message : String(cause)}. Nothing was appended.`);
3731
+ }
3732
+ if (!isProposalTask(task)) {
3733
+ return refuse("task-not-proposal", `task ${task} was not registered by \`approval propose\`; start records the executions of proposals only. Nothing was appended.`);
3734
+ }
3735
+ const read = readGateRecords(logPath);
3736
+ if (!read.ok)
3737
+ return read;
3738
+ const declared = registeredAction(read.records, task, actionKey);
3739
+ if (!declared.ok)
3740
+ return declared;
3741
+ let registrant = null;
3742
+ for (const record of read.records) {
3743
+ if (record.event === "task.registered" && record.task === task)
3744
+ registrant = record.actor;
3745
+ }
3746
+ if (registrant !== actor) {
3747
+ return refuse("not-requester", `task ${task} was registered by ${String(registrant)}, and only its registrant may record that it is carrying the action out; ${actor} may not. Nothing was appended.`);
3748
+ }
3749
+ if (declared.action.payload_hash !== hash) {
3750
+ return refuse("payload-mismatch", `the payload presented for ${actionKey} hashes to ${hash}, but the action is registered with ${String(declared.action.payload_hash)}. What is carried out must be the bytes that were proposed; nothing was appended.`);
3751
+ }
3752
+ // APRV-445 refutation (S5). Only a request that can still answer binds the
3753
+ // key to the grant path: one that is pending (spending it refuses until a
3754
+ // human decides) or granted and unspent. A withdrawn, expired, rejected or
3755
+ // revoked request says nothing about an action the policy authorizes on its
3756
+ // own now, so after a re-tiering to autonomous the start is the policy's.
3757
+ // This read only routes; the policy path re-judges inside its own append
3758
+ // (`proposalPolicyStartRefusal`): a live request, a human's no under the
3759
+ // policy in force, and a selected supervised-live draw each refuse there.
3760
+ const derivation = requestState(read.records, actionKey, tick(options), ttlOf(parsePolicy(readPolicyOnce(options), options)));
3761
+ const binding = derivation.execution.started === null &&
3762
+ (derivation.state === "requested" || derivation.state === "granted");
3763
+ if (binding) {
3764
+ const spent = consumeHarnessGrant(logPath, actionKey, actor, {
3765
+ ...options,
3766
+ presentedPayloadHash: hash,
3767
+ spendingTask: task,
3768
+ });
3769
+ if (!spent.ok)
3770
+ return spent;
3771
+ return { ok: true, task, actionKey, cls: declared.action.class, authorization: "grant", record: spent.record };
3772
+ }
3773
+ const started = startHarnessExecution(logPath, {
3774
+ task,
3775
+ actionKey,
3776
+ cls: declared.action.class,
3777
+ payload_hash: hash,
3778
+ ...(declared.action.est_cost_usd === undefined ? {} : { est_cost_usd: declared.action.est_cost_usd }),
3779
+ }, actor, options);
3780
+ if (!started.ok)
3781
+ return started;
3782
+ return { ok: true, task, actionKey, cls: declared.action.class, authorization: "policy", record: started.record };
3783
+ }
3784
+ export function requestStanding(records, actionKey, ts, ttlMs, attestedSha256) {
3785
+ const derivation = requestState(records, actionKey, ts, ttlMs);
3786
+ if (derivation.execution.started !== null)
3787
+ return "executed";
3788
+ if (derivation.state === "granted") {
3789
+ if (grantLapsed(derivation, ts))
3790
+ return "expired";
3791
+ // APRV-445 recheck (B). A grant is void only where its SPEND refuses
3792
+ // `policy-drift`, which is the harness spend (`consumeHarnessGrant`) and
3793
+ // therefore every proposal. A token grant is spent by `approval run`
3794
+ // through `consumeToken`, which does not compare policy hashes, so after a
3795
+ // re-attest that grant still runs: calling it void would abort the
3796
+ // documented `wait && run` flow and send an agent to ask again over a live
3797
+ // human grant.
3798
+ const driftEnforced = derivation.declared.execution === "harness" || isProposalTask(derivation.task);
3799
+ const pinned = grantedPolicyHash(records, derivation.decisionSeq) ?? derivation.declared.policy_sha256;
3800
+ if (driftEnforced && attestedSha256 !== null && pinned !== null && pinned !== attestedSha256) {
3801
+ return "void";
3802
+ }
3803
+ return "granted";
3804
+ }
3805
+ if (derivation.state === "requested") {
3806
+ const pinned = derivation.declared.policy_sha256;
3807
+ if (attestedSha256 !== null && pinned !== null && pinned !== attestedSha256)
3808
+ return "void";
3809
+ return "requested";
3810
+ }
3811
+ if (derivation.state === "withdrawn" && derivation.decisionSeq !== null) {
3812
+ for (const record of records) {
3813
+ if (record.seq !== derivation.decisionSeq)
3814
+ continue;
3815
+ if (payloadOf(record)["reason"] === "policy-drift")
3816
+ return "void";
3817
+ }
3818
+ }
3819
+ return derivation.state;
3820
+ }
3821
+ /**
3822
+ * The SHA-256 the live policy bytes are attested under, or `null` when they are
3823
+ * not attested (unread, unattested, or changed since). One read of the file
3824
+ * through the same seam every gate operation uses.
3825
+ */
3826
+ export function attestedPolicySha256(records, options = {}) {
3827
+ const attested = requireAttestation(records, readPolicyOnce(options));
3828
+ return attested.ok ? attested.sha256 : null;
3829
+ }
3082
3830
  //# sourceMappingURL=gate.js.map