approval-md 0.3.0 → 0.4.1

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 (270) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +101 -13
  3. package/cli.js +134 -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 +72 -0
  8. package/dist/src/channels/contract.js +161 -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.d.ts +25 -0
  17. package/dist/src/channels/tagging.js +63 -6
  18. package/dist/src/channels/tagging.js.map +1 -1
  19. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  20. package/dist/src/channels/telegram-minimal.js +618 -0
  21. package/dist/src/channels/telegram-minimal.js.map +1 -0
  22. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  23. package/dist/src/channels/telegram-webhook.js +536 -0
  24. package/dist/src/channels/telegram-webhook.js.map +1 -0
  25. package/dist/src/channels/telegram.d.ts +725 -20
  26. package/dist/src/channels/telegram.js +1332 -123
  27. package/dist/src/channels/telegram.js.map +1 -1
  28. package/dist/src/cli/amend.js +358 -5
  29. package/dist/src/cli/amend.js.map +1 -1
  30. package/dist/src/cli/attest.js +43 -6
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/audit-card.d.ts +1 -1
  33. package/dist/src/cli/audit-card.js +40 -1
  34. package/dist/src/cli/audit-card.js.map +1 -1
  35. package/dist/src/cli/audit.d.ts +7 -5
  36. package/dist/src/cli/audit.js +22 -10
  37. package/dist/src/cli/audit.js.map +1 -1
  38. package/dist/src/cli/channel-relay.d.ts +108 -0
  39. package/dist/src/cli/channel-relay.js +334 -0
  40. package/dist/src/cli/channel-relay.js.map +1 -0
  41. package/dist/src/cli/channel-telegram-webhook.d.ts +275 -0
  42. package/dist/src/cli/channel-telegram-webhook.js +716 -0
  43. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  44. package/dist/src/cli/channel-telegram.d.ts +347 -28
  45. package/dist/src/cli/channel-telegram.js +648 -56
  46. package/dist/src/cli/channel-telegram.js.map +1 -1
  47. package/dist/src/cli/channel.d.ts +1 -1
  48. package/dist/src/cli/channel.js +5 -1
  49. package/dist/src/cli/channel.js.map +1 -1
  50. package/dist/src/cli/daemon.js +37 -5
  51. package/dist/src/cli/daemon.js.map +1 -1
  52. package/dist/src/cli/doctor.d.ts +63 -0
  53. package/dist/src/cli/doctor.js +856 -111
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +29 -1
  56. package/dist/src/cli/execute.js +387 -21
  57. package/dist/src/cli/execute.js.map +1 -1
  58. package/dist/src/cli/exit-codes.d.ts +12 -0
  59. package/dist/src/cli/exit-codes.js +13 -0
  60. package/dist/src/cli/exit-codes.js.map +1 -1
  61. package/dist/src/cli/gate.d.ts +2 -0
  62. package/dist/src/cli/gate.js +233 -2
  63. package/dist/src/cli/gate.js.map +1 -1
  64. package/dist/src/cli/help.d.ts +10 -1
  65. package/dist/src/cli/help.js +283 -54
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook.d.ts +485 -6
  68. package/dist/src/cli/hook.js +2370 -70
  69. package/dist/src/cli/hook.js.map +1 -1
  70. package/dist/src/cli/init.js +16 -7
  71. package/dist/src/cli/init.js.map +1 -1
  72. package/dist/src/cli/instructions.js +24 -3
  73. package/dist/src/cli/instructions.js.map +1 -1
  74. package/dist/src/cli/log-anchor.d.ts +55 -0
  75. package/dist/src/cli/log-anchor.js +199 -60
  76. package/dist/src/cli/log-anchor.js.map +1 -1
  77. package/dist/src/cli/log-unlock.d.ts +23 -0
  78. package/dist/src/cli/log-unlock.js +109 -0
  79. package/dist/src/cli/log-unlock.js.map +1 -0
  80. package/dist/src/cli/main.d.ts +0 -12
  81. package/dist/src/cli/main.js +72 -4
  82. package/dist/src/cli/main.js.map +1 -1
  83. package/dist/src/cli/muse.d.ts +2 -0
  84. package/dist/src/cli/muse.js +62 -0
  85. package/dist/src/cli/muse.js.map +1 -0
  86. package/dist/src/cli/payload.js +79 -1
  87. package/dist/src/cli/payload.js.map +1 -1
  88. package/dist/src/cli/scaffold.js +6 -0
  89. package/dist/src/cli/scaffold.js.map +1 -1
  90. package/dist/src/cli/serve.d.ts +53 -0
  91. package/dist/src/cli/serve.js +270 -0
  92. package/dist/src/cli/serve.js.map +1 -0
  93. package/dist/src/cli/up.js +26 -2
  94. package/dist/src/cli/up.js.map +1 -1
  95. package/dist/src/cli/verb-registry.d.ts +2 -0
  96. package/dist/src/cli/verb-registry.js +338 -39
  97. package/dist/src/cli/verb-registry.js.map +1 -1
  98. package/dist/src/cli/wordmark.d.ts +2 -2
  99. package/dist/src/cli/wordmark.js +2 -2
  100. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  101. package/dist/src/core/attest-bootstrap.js +135 -0
  102. package/dist/src/core/attest-bootstrap.js.map +1 -0
  103. package/dist/src/core/attest.d.ts +20 -3
  104. package/dist/src/core/attest.js +25 -0
  105. package/dist/src/core/attest.js.map +1 -1
  106. package/dist/src/core/audit.d.ts +153 -12
  107. package/dist/src/core/audit.js +415 -20
  108. package/dist/src/core/audit.js.map +1 -1
  109. package/dist/src/core/channel-lease.d.ts +322 -0
  110. package/dist/src/core/channel-lease.js +663 -0
  111. package/dist/src/core/channel-lease.js.map +1 -0
  112. package/dist/src/core/command-class.d.ts +51 -0
  113. package/dist/src/core/command-class.js +766 -8
  114. package/dist/src/core/command-class.js.map +1 -1
  115. package/dist/src/core/daemon-host.d.ts +178 -0
  116. package/dist/src/core/daemon-host.js +228 -0
  117. package/dist/src/core/daemon-host.js.map +1 -0
  118. package/dist/src/core/daemon-identity.d.ts +238 -0
  119. package/dist/src/core/daemon-identity.js +252 -0
  120. package/dist/src/core/daemon-identity.js.map +1 -0
  121. package/dist/src/core/gate.d.ts +364 -6
  122. package/dist/src/core/gate.js +802 -27
  123. package/dist/src/core/gate.js.map +1 -1
  124. package/dist/src/core/harness-version.d.ts +128 -4
  125. package/dist/src/core/harness-version.js +179 -12
  126. package/dist/src/core/harness-version.js.map +1 -1
  127. package/dist/src/core/harness-wait.d.ts +82 -3
  128. package/dist/src/core/harness-wait.js +89 -3
  129. package/dist/src/core/harness-wait.js.map +1 -1
  130. package/dist/src/core/log-lock.d.ts +435 -0
  131. package/dist/src/core/log-lock.js +1093 -0
  132. package/dist/src/core/log-lock.js.map +1 -0
  133. package/dist/src/core/log-subscribe.d.ts +21 -0
  134. package/dist/src/core/log-subscribe.js +16 -6
  135. package/dist/src/core/log-subscribe.js.map +1 -1
  136. package/dist/src/core/log-write-layer.d.ts +55 -0
  137. package/dist/src/core/log-write-layer.js +91 -0
  138. package/dist/src/core/log-write-layer.js.map +1 -0
  139. package/dist/src/core/log.d.ts +130 -22
  140. package/dist/src/core/log.js +473 -50
  141. package/dist/src/core/log.js.map +1 -1
  142. package/dist/src/core/payload-census.d.ts +51 -0
  143. package/dist/src/core/payload-census.js +125 -2
  144. package/dist/src/core/payload-census.js.map +1 -1
  145. package/dist/src/core/payload-store.d.ts +31 -0
  146. package/dist/src/core/payload-store.js +113 -8
  147. package/dist/src/core/payload-store.js.map +1 -1
  148. package/dist/src/core/payload.d.ts +18 -3
  149. package/dist/src/core/payload.js +20 -4
  150. package/dist/src/core/payload.js.map +1 -1
  151. package/dist/src/core/policy-diff.d.ts +10 -0
  152. package/dist/src/core/policy-diff.js +10 -2
  153. package/dist/src/core/policy-diff.js.map +1 -1
  154. package/dist/src/core/policy-explain.d.ts +7 -1
  155. package/dist/src/core/policy-explain.js +16 -1
  156. package/dist/src/core/policy-explain.js.map +1 -1
  157. package/dist/src/core/policy-load.d.ts +24 -0
  158. package/dist/src/core/policy-load.js.map +1 -1
  159. package/dist/src/core/policy-match.d.ts +38 -0
  160. package/dist/src/core/policy-match.js +35 -0
  161. package/dist/src/core/policy-match.js.map +1 -1
  162. package/dist/src/core/policy-proposal.d.ts +40 -0
  163. package/dist/src/core/policy-proposal.js +37 -16
  164. package/dist/src/core/policy-proposal.js.map +1 -1
  165. package/dist/src/core/prompt-layout.d.ts +186 -1
  166. package/dist/src/core/prompt-layout.js +345 -3
  167. package/dist/src/core/prompt-layout.js.map +1 -1
  168. package/dist/src/core/read-scope.d.ts +10 -0
  169. package/dist/src/core/read-scope.js +4 -0
  170. package/dist/src/core/read-scope.js.map +1 -1
  171. package/dist/src/core/run-payload.d.ts +160 -0
  172. package/dist/src/core/run-payload.js +304 -0
  173. package/dist/src/core/run-payload.js.map +1 -0
  174. package/dist/src/core/sender-identity.d.ts +59 -11
  175. package/dist/src/core/sender-identity.js +79 -11
  176. package/dist/src/core/sender-identity.js.map +1 -1
  177. package/dist/src/core/state.d.ts +52 -1
  178. package/dist/src/core/state.js +52 -6
  179. package/dist/src/core/state.js.map +1 -1
  180. package/dist/src/core/telegram-config.d.ts +79 -0
  181. package/dist/src/core/telegram-config.js +108 -0
  182. package/dist/src/core/telegram-config.js.map +1 -1
  183. package/dist/src/core/validate.d.ts +20 -6
  184. package/dist/src/core/validate.js +0 -0
  185. package/dist/src/core/validate.js.map +1 -1
  186. package/dist/src/core/verify.d.ts +23 -1
  187. package/dist/src/core/verify.js +20 -2
  188. package/dist/src/core/verify.js.map +1 -1
  189. package/dist/src/daemon/audit.d.ts +80 -5
  190. package/dist/src/daemon/audit.js +126 -6
  191. package/dist/src/daemon/audit.js.map +1 -1
  192. package/dist/src/daemon/daemon.d.ts +174 -2
  193. package/dist/src/daemon/daemon.js +298 -7
  194. package/dist/src/daemon/daemon.js.map +1 -1
  195. package/dist/src/daemon/git-evidence.d.ts +2 -2
  196. package/dist/src/daemon/git-evidence.js +1 -1
  197. package/dist/src/daemon/projection.d.ts +13 -2
  198. package/dist/src/daemon/projection.js +39 -6
  199. package/dist/src/daemon/projection.js.map +1 -1
  200. package/dist/src/mcp/server.d.ts +39 -3
  201. package/dist/src/mcp/server.js +22 -3
  202. package/dist/src/mcp/server.js.map +1 -1
  203. package/dist/src/muse/server.d.ts +16 -0
  204. package/dist/src/muse/server.js +238 -0
  205. package/dist/src/muse/server.js.map +1 -0
  206. package/dist/src/serve/archive.d.ts +166 -0
  207. package/dist/src/serve/archive.js +363 -0
  208. package/dist/src/serve/archive.js.map +1 -0
  209. package/dist/src/serve/arguments.d.ts +93 -0
  210. package/dist/src/serve/arguments.js +228 -0
  211. package/dist/src/serve/arguments.js.map +1 -0
  212. package/dist/src/serve/credentials.d.ts +91 -0
  213. package/dist/src/serve/credentials.js +143 -0
  214. package/dist/src/serve/credentials.js.map +1 -0
  215. package/dist/src/serve/follow.d.ts +86 -0
  216. package/dist/src/serve/follow.js +101 -0
  217. package/dist/src/serve/follow.js.map +1 -0
  218. package/dist/src/serve/hook-thread.d.ts +134 -0
  219. package/dist/src/serve/hook-thread.js +333 -0
  220. package/dist/src/serve/hook-thread.js.map +1 -0
  221. package/dist/src/serve/hook-worker.d.ts +134 -0
  222. package/dist/src/serve/hook-worker.js +173 -0
  223. package/dist/src/serve/hook-worker.js.map +1 -0
  224. package/dist/src/serve/server.d.ts +412 -0
  225. package/dist/src/serve/server.js +1251 -0
  226. package/dist/src/serve/server.js.map +1 -0
  227. package/docs/README-extended.md +768 -0
  228. package/docs/cli-reference.md +2139 -67
  229. package/package.json +4 -2
  230. package/schema/event.schema.json +413 -30
  231. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  232. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  233. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  234. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  235. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  236. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  237. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  238. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  239. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  240. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  241. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  242. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  243. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  244. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  245. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  246. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  247. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  248. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  249. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  250. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  251. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  252. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  253. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  254. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  255. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  256. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  257. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  258. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  259. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  260. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  261. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  262. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  263. package/schema/fixtures/event/valid/daemon-identity.json +16 -0
  264. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  265. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  266. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  267. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  268. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  269. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  270. package/schema/policy.schema.json +102 -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";
@@ -161,8 +164,12 @@ const HUMAN_ACTOR = /^human:.+/u;
161
164
  * language. An empty list names nobody and therefore matches nobody;
162
165
  * `approvers` carries `minItems: 1`, so a valid policy cannot produce one, and
163
166
  * that branch stays a fail-closed backstop rather than a reachable path.
167
+ *
168
+ * Exported for `core/audit.ts` (APRV-483), whose review check is this one
169
+ * comparison applied to a reviewer, so a grant and a review can never disagree
170
+ * about who is on a roster.
164
171
  */
165
- function namesApprover(approvers, actor) {
172
+ export function namesApprover(approvers, actor) {
166
173
  const bare = actor.startsWith("human:") ? actor.slice("human:".length) : actor;
167
174
  return approvers.some((name) => name === actor || name === bare);
168
175
  }
@@ -533,6 +540,50 @@ export const GATE_REFUSAL_CODES = [
533
540
  * nothing can ever open. Nothing is appended; the next attempt asks again.
534
541
  */
535
542
  "token-delivery-unavailable",
543
+ /**
544
+ * `approval propose` named a class the policy has not opened to agents
545
+ * (APRV-445, amended SPEC.md §5.2): the class is not an exact key of
546
+ * `policy.classes`, or neither that key nor a declared `<prefix>.*` family of
547
+ * it sets `agent_may_request: true`. Nothing is registered and nothing is
548
+ * appended. The repair is the operator's, a policy edit and a re-attestation;
549
+ * no retry by the agent changes it.
550
+ */
551
+ "class-not-agent-requestable",
552
+ /**
553
+ * `approval propose` / `approval start` carried a payload larger than
554
+ * {@link PROPOSE_PAYLOAD_MAX_BYTES} (APRV-445). Refused before the bytes are
555
+ * parsed or hashed; nothing is stored and nothing is appended.
556
+ */
557
+ "payload-too-large",
558
+ /**
559
+ * `approval request` (or a task FILE's registration) named a `propose:` task
560
+ * (APRV-445 refutation, S1). A proposal is requested only through `propose`,
561
+ * which re-checks the operator's opening of its class; a plain request on the
562
+ * same task would skip that check, and a file-registered `propose:` id would
563
+ * borrow the exclusions proposals get from the harness hook.
564
+ */
565
+ "task-is-proposal",
566
+ /**
567
+ * `approval start` named a task `propose` did not register (APRV-445, L9).
568
+ * `start` records executions of proposals and of nothing else; every other
569
+ * action executes through `approval run`, an adapter, or the hook.
570
+ */
571
+ "task-not-proposal",
572
+ /**
573
+ * `approval propose --key` does not begin with `<class>:` (APRV-445, S4). The
574
+ * key names the class it was proposed under, so a key cannot be read back
575
+ * later as belonging to a different class than its record says.
576
+ */
577
+ "key-class-mismatch",
578
+ /**
579
+ * `approval propose` found the log moving under it on every one of its
580
+ * bounded attempts (APRV-445 recheck 3): each re-read showed an answer the
581
+ * next append was refused for. Exit 1, nothing more was appended, and the
582
+ * proposal's standing is whatever the log now says; read it with `wait
583
+ * --timeout 0` or call again. Distinct from `already-decided`, which would
584
+ * tell a caller a proposal is answered when it may stand granted or pending.
585
+ */
586
+ "contended",
536
587
  ];
537
588
  function refuse(code, message, extra = {}) {
538
589
  return { ok: false, code, message, ...extra };
@@ -571,7 +622,13 @@ export function readGateRecords(logPath, schemaDir) {
571
622
  * `checkAttestation` reports `unreadable` and the gate refuses — a missing
572
623
  * policy is never a pass.
573
624
  */
574
- function policyPathOf(options) {
625
+ /**
626
+ * The policy file a gate operation reads: `policy.file`, or the first policy
627
+ * filename found in `policy.dir` (default: the working directory). Exported for
628
+ * `core/audit.ts` (APRV-483), whose roster check must read the same file a
629
+ * grant would and hold it to the same attestation.
630
+ */
631
+ export function policyPathOf(options) {
575
632
  const policy = options.policy ?? {};
576
633
  if (policy.file !== undefined)
577
634
  return policy.file;
@@ -665,6 +722,21 @@ function requireAttestation(records, read) {
665
722
  function ttlOf(load) {
666
723
  return load.ok ? load.durations.approvalTtlMs : null;
667
724
  }
725
+ /**
726
+ * The clause a refusal adds when a harness ceiling, rather than the policy,
727
+ * fixed this request's deadline (APRV-423).
728
+ *
729
+ * Empty for every request that declared no cap, which is every request made
730
+ * outside a harness hook — so the messages a human has been reading since v0.1
731
+ * are unchanged, and the one new sentence appears exactly where it explains
732
+ * something the policy alone does not.
733
+ */
734
+ function cappedBy(derivation) {
735
+ const cap = derivation.declared.harness_cap_ms;
736
+ if (cap === null)
737
+ return "";
738
+ 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)`;
739
+ }
668
740
  function budgetScopeOf(load, resolution) {
669
741
  return {
670
742
  classLimits: resolution.limits,
@@ -910,6 +982,15 @@ function attemptRegister(logPath, source, actor, options) {
910
982
  }
911
983
  return resolved.refusal;
912
984
  }
985
+ // APRV-445 refutation (S1). The `propose:` namespace belongs to `propose`;
986
+ // a task FILE claiming an id in it would borrow the exclusions proposals get
987
+ // from the harness hook (its sweep and its carry).
988
+ // Recheck L-c: the in-memory sources too (the muse `POST /v1/registrations`
989
+ // route registers whatever task id its caller names); only `propose` itself
990
+ // passes `proposal: true`.
991
+ if (isProposalTask(resolved.task) && options.proposal !== true) {
992
+ 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.`);
993
+ }
913
994
  const validation = validate("envelope", resolved.envelope, options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
914
995
  if (!validation.ok) {
915
996
  return refuse("envelope-invalid", `the envelope failed schema validation; nothing was appended`, { errors: validation.errors });
@@ -965,13 +1046,17 @@ function attemptRegister(logPath, source, actor, options) {
965
1046
  return { ok: true, record: appended.record, task: resolved.task, actions };
966
1047
  }
967
1048
  /**
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.
1049
+ * The `task.registered` record for `task`, or the `not-registered` refusal.
1050
+ *
1051
+ * The one raise site for SPEC.md §11.2's `not-registered` ("no task.registered
1052
+ * record exists for the task id"). `request` reaches it through
1053
+ * {@link registeredAction}; `approval wait` calls it directly (APRV-428), so a
1054
+ * wait on a task the log has never seen is refused in the same words rather
1055
+ * than answered as a vacuous grant. The latest registration wins, as it always
1056
+ * has for `registeredAction`; the register path refuses a second one, so a log
1057
+ * carrying two was written by something else.
973
1058
  */
974
- export function registeredAction(records, task, actionKey) {
1059
+ export function taskRegistration(records, task) {
975
1060
  let registration = null;
976
1061
  for (const record of records) {
977
1062
  if (record.event === "task.registered" && record.task === task)
@@ -980,6 +1065,20 @@ export function registeredAction(records, task, actionKey) {
980
1065
  if (registration === null) {
981
1066
  return refuse("not-registered", `task ${task} has no task.registered record; run \`approval register <task-file>\` first`);
982
1067
  }
1068
+ return { ok: true, record: registration };
1069
+ }
1070
+ /**
1071
+ * The declared action for `(task, actionKey)`, as registered in the log.
1072
+ *
1073
+ * SPEC.md §7: "an action's class MUST be declared before an execution token can
1074
+ * be requested for it". The declaration lives in `task.registered`, so the log —
1075
+ * not the file, which may have been edited since — is what the gate reads back.
1076
+ */
1077
+ export function registeredAction(records, task, actionKey) {
1078
+ const registered = taskRegistration(records, task);
1079
+ if (!registered.ok)
1080
+ return registered;
1081
+ const registration = registered.record;
983
1082
  const declared = payloadOf(registration)["actions"];
984
1083
  const actions = Array.isArray(declared) ? declared : [];
985
1084
  for (const entry of actions) {
@@ -1340,6 +1439,21 @@ function attemptRequest(logPath, input, actor, options) {
1340
1439
  return attested;
1341
1440
  const load = parsePolicy(policyRead, options);
1342
1441
  const resolution = resolve(load, input.cls, input.reversible === undefined ? {} : { reversible: input.reversible });
1442
+ // APRV-445 refutation (S1). A `propose:` task is requested through `propose`
1443
+ // and nothing else, so its agent-requestability check cannot be skipped by
1444
+ // asking for the same registration with a plain `request`.
1445
+ if (isProposalTask(input.task) && input.agentProposal !== true) {
1446
+ return refuse("task-is-proposal", `task ${input.task} was registered by \`approval propose\` and is requested only through it; nothing was appended`);
1447
+ }
1448
+ // APRV-445. A proposal's class came from the requester, so the operator's
1449
+ // opening of that class to agents is re-read here, from the same bytes the
1450
+ // attestation above verified, before anything else can be decided for it.
1451
+ if (input.agentProposal === true) {
1452
+ const opened = agentRequestability(load, input.cls);
1453
+ if (!opened.allowed) {
1454
+ return refuse("class-not-agent-requestable", notAgentRequestable(input.cls, opened));
1455
+ }
1456
+ }
1343
1457
  // APRV-185, amended SPEC.md §5.2, and the first thing intake asks once the
1344
1458
  // class has an autonomy: a `human-only` class is not requestable. The policy
1345
1459
  // itself answers, so nothing is put in front of a human, nothing is drawn,
@@ -1547,6 +1661,17 @@ function attemptRequest(logPath, input, actor, options) {
1547
1661
  if (derivation.execution.started !== null) {
1548
1662
  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
1663
  }
1664
+ // APRV-445 recheck (SF1). A proposal is asked again only when its last
1665
+ // answer can no longer be used, and that is judged HERE, against the records
1666
+ // this attempt appends on, not against whatever its caller read earlier. A
1667
+ // usable grant (unexpired, under the policy in force) or a human's refusal
1668
+ // stands: a stale caller may not put a fresh question over it.
1669
+ if (input.agentProposal === true) {
1670
+ const standing = requestStanding(read.records, input.actionKey, ts, ttlOf(load), attested.sha256);
1671
+ if (standing === "granted" || standing === "rejected" || standing === "revoked") {
1672
+ 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 });
1673
+ }
1674
+ }
1550
1675
  // APRV-173, SPEC.md §5.2's request-volume limits, enforced here and nowhere
1551
1676
  // else. Placed AFTER the legality checks above and BEFORE budgets, and both
1552
1677
  // halves of that placement are deliberate.
@@ -1715,6 +1840,13 @@ function attemptRequest(logPath, input, actor, options) {
1715
1840
  payload["execution"] = input.execution;
1716
1841
  if (input.wait_until !== undefined)
1717
1842
  payload["wait_until"] = input.wait_until;
1843
+ // APRV-423, on the same terms as the two above: recorded because the log is
1844
+ // the only place a later reader — the daemon's sweep, a channel, the retry
1845
+ // that adopts this question — can learn what bounded it, and it cannot
1846
+ // lengthen anything. `core/state.ts` takes the minimum of this and the
1847
+ // policy's TTL; see `RequestInput.harnessCapMs`.
1848
+ if (input.harnessCapMs !== undefined)
1849
+ payload["harness_cap_ms"] = input.harnessCapMs;
1718
1850
  const appended = append(logPath, {
1719
1851
  ts,
1720
1852
  event: "approval.requested",
@@ -1889,7 +2021,7 @@ function attemptDecide(logPath, actionKey, decision, actor, options) {
1889
2021
  materialised = logged.record;
1890
2022
  }
1891
2023
  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.`
2024
+ ? `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
2025
  : `action ${actionKey} expired at ${String(derivation.decisionTs)} (approval.expired, seq ${String(derivation.decisionSeq)}); an expired request is terminal`;
1894
2026
  return refuse("expired", message, materialised === undefined
1895
2027
  ? { state: derivation.state }
@@ -2001,6 +2133,16 @@ function attemptDecide(logPath, actionKey, decision, actor, options) {
2001
2133
  if (options.senderSource !== undefined)
2002
2134
  payload["sender_source"] = options.senderSource;
2003
2135
  }
2136
+ // APRV-489. Grant and reject only, the two answers a prompt collects, and only
2137
+ // when the surface drew (or was asked for) a non-default layout. Copied in a
2138
+ // fixed shape so nothing else a caller hangs on the object reaches the log.
2139
+ if (decision !== "revoke" && options.rendering !== undefined) {
2140
+ payload["rendering"] = {
2141
+ style: options.rendering.style,
2142
+ ...(options.rendering.fallback === undefined ? {} : { fallback: options.rendering.fallback }),
2143
+ ...(options.rendering.say_does_ignored === true ? { say_does_ignored: true } : {}),
2144
+ };
2145
+ }
2004
2146
  if (decision === "grant") {
2005
2147
  const cls = derivation.declared.class ?? "";
2006
2148
  const resolution = resolve(load, cls, derivation.declared.reversible === null ? {} : { reversible: derivation.declared.reversible });
@@ -2227,13 +2369,20 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
2227
2369
  if (logged.ok)
2228
2370
  materialised = logged.record;
2229
2371
  }
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
2372
+ 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
2373
  ? { state: derivation.state }
2232
2374
  : { state: derivation.state, record: materialised });
2233
2375
  }
2234
2376
  if (derivation.state !== "requested") {
2235
2377
  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
2378
  }
2379
+ if (options.onlyIfVoid === true) {
2380
+ const attested = requireAttestation(read.records, readPolicyOnce(options));
2381
+ const pinned = derivation.declared.policy_sha256;
2382
+ if (!attested.ok || pinned === null || pinned === attested.sha256) {
2383
+ 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 });
2384
+ }
2385
+ }
2237
2386
  if (derivation.requestActor !== actor) {
2238
2387
  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
2388
  }
@@ -2282,8 +2431,9 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
2282
2431
  * refused at the append in {@link consumeHarnessGrant}. The single-use rule
2283
2432
  * is the gate's existing one; this only stops the caller from queueing up a
2284
2433
  * 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
2434
+ * - *within the TTL*: state is derived at `ts` with `ttlMs`, narrowed by the
2435
+ * candidate's own declared harness ceiling (APRV-423), so a lapsed request
2436
+ * reads `expired` and carries nothing, whether or not the daemon has
2287
2437
  * materialised an `approval.expired` record.
2288
2438
  *
2289
2439
  * PURE, and reads only records the caller verified — the enforcement path never
@@ -2292,7 +2442,7 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
2292
2442
  * request that came after it, exactly as {@link requestState} treats cycles.
2293
2443
  */
2294
2444
  /**
2295
- * Has a GRANTED request outlived `defaults.approval_ttl`?
2445
+ * Has a GRANTED request outlived the window it was asked under?
2296
2446
  *
2297
2447
  * `requestState` reports a decided request by its decision forever: the TTL
2298
2448
  * bounds the window in which a human may answer, not the answer's shelf life.
@@ -2305,7 +2455,14 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
2305
2455
  * Unparseable instants read as lapsed, and a policy with no TTL declares no
2306
2456
  * lapse at all — both exactly as `core/token.ts` reads them.
2307
2457
  */
2308
- function grantLapsed(derivation, ts, ttlMs) {
2458
+ function grantLapsed(derivation, ts) {
2459
+ // APRV-423: the SAME window the request was judged by, read off the
2460
+ // derivation rather than recomputed from the policy. A grant whose question
2461
+ // was bounded by a harness ceiling keeps that bound; a retry a few seconds
2462
+ // later then asks again instead of carrying an answer the request's own
2463
+ // clock had already run out on. The stricter of the two readings, and the
2464
+ // one that keeps `requestState` the single place the deadline is decided.
2465
+ const ttlMs = derivation.effectiveTtlMs;
2309
2466
  if (ttlMs === null)
2310
2467
  return false;
2311
2468
  const requestedAt = Date.parse(derivation.requestTs ?? "");
@@ -2314,7 +2471,63 @@ function grantLapsed(derivation, ts, ttlMs) {
2314
2471
  return true;
2315
2472
  return asked > requestedAt + ttlMs;
2316
2473
  }
2317
- export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
2474
+ /**
2475
+ * Was the question already abandoned when it was answered? (APRV-410.)
2476
+ *
2477
+ * The state this names, observed 2026-09-20 in this repository's own log. A
2478
+ * hook wait expired at 18:52:37 on a request opened at 18:43:37; the retry
2479
+ * grace ran out at 18:57:37 with nothing retrying; a human tapped approve at
2480
+ * 19:03:32 and `approval.granted` seq 64473 was recorded, six minutes past the
2481
+ * grace, on a question no hook process was holding. The grant authorized
2482
+ * nothing that ran on the day only because the session had already moved on. It
2483
+ * was still live: a retry of that exact command in that directory inside the
2484
+ * TTL would have found it here and proceeded on it, which is one human tap
2485
+ * authorizing an execution nobody connected to the tap.
2486
+ *
2487
+ * {@link grantLapsed} bounds a grant by the request's TTL, which is the shelf
2488
+ * life of an ANSWER. This bounds it by the life of the QUESTION: past
2489
+ * `requestTs + abandonAfterMs` the asker is gone (`core/harness-wait.ts` is
2490
+ * where that duration and its reasoning live), so an answer arriving after it
2491
+ * answered nobody. Both bounds apply; either one alone lets a case through.
2492
+ *
2493
+ * Two facts and no third. The request's own runtime-assigned timestamp and the
2494
+ * decision record's own runtime-assigned timestamp, both read from the log the
2495
+ * caller verified. Nothing self-reported enters it, and the caller's
2496
+ * `abandonAfterMs` is the window the CALLER is configured for rather than
2497
+ * anything the requester said about itself, which is why a false claim cannot
2498
+ * widen it.
2499
+ *
2500
+ * Strictly stricter, and opt-in. A caller that passes no window gets exactly
2501
+ * today's behaviour, and a caller that passes one can only lose a carry it
2502
+ * would otherwise have had. Losing one costs a fresh question; keeping one
2503
+ * costs an execution on an answer nobody was waiting for.
2504
+ *
2505
+ * An unparseable instant on either side reads as abandoned, exactly as
2506
+ * {@link grantLapsed} reads one as lapsed: the strict direction is the one a
2507
+ * corrupt byte must resolve to.
2508
+ */
2509
+ function questionAbandoned(derivation, abandonAfterMs) {
2510
+ if (abandonAfterMs === null)
2511
+ return false;
2512
+ const requestedAt = Date.parse(derivation.requestTs ?? "");
2513
+ const answeredAt = Date.parse(derivation.decisionTs ?? "");
2514
+ if (Number.isNaN(requestedAt) || Number.isNaN(answeredAt))
2515
+ return true;
2516
+ return answeredAt > requestedAt + Math.max(0, abandonAfterMs);
2517
+ }
2518
+ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs,
2519
+ /**
2520
+ * How long a question of this caller's outlives the wait that opened it
2521
+ * (APRV-410, `core/harness-wait.ts`'s `abandonedAfterMs`), or `null` for a
2522
+ * caller that declares no such window.
2523
+ *
2524
+ * Only a GRANTED candidate is bounded by it. A pending one is the question
2525
+ * this invocation is about to adopt, and the withdrawal of an abandoned
2526
+ * pending question is the requester's own business, not this function's:
2527
+ * `cli/hook.ts` sweeps those through `withdraw`, which is requester-only by
2528
+ * design (APRV-106 rule 1).
2529
+ */
2530
+ abandonAfterMs = null) {
2318
2531
  if (!isPayloadHash(payloadHash))
2319
2532
  return null;
2320
2533
  // Distinct keys, latest request first: a later question about the same bytes
@@ -2331,6 +2544,11 @@ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
2331
2544
  continue;
2332
2545
  if (keys.includes(record.action_key))
2333
2546
  continue;
2547
+ // APRV-445. A proposal's harness grant belongs to `approval start` and to
2548
+ // nothing else: a hook call that happened to present the same bytes in the
2549
+ // same class must not carry or adopt it.
2550
+ if (isProposalTask(record.task))
2551
+ continue;
2334
2552
  const payload = payloadOf(record);
2335
2553
  if (payload["execution"] !== "harness")
2336
2554
  continue;
@@ -2354,7 +2572,13 @@ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
2354
2572
  continue;
2355
2573
  if (derivation.state === "granted") {
2356
2574
  // An answer has a shelf life, and it is its request's TTL.
2357
- if (grantLapsed(derivation, ts, ttlMs))
2575
+ // APRV-423: judged by the request's own effective window, read off the
2576
+ // derivation, never the raw policy TTL.
2577
+ if (grantLapsed(derivation, ts))
2578
+ continue;
2579
+ // And a question has a life of its own: an answer that landed after the
2580
+ // asker was gone authorized nobody (APRV-410).
2581
+ if (questionAbandoned(derivation, abandonAfterMs))
2358
2582
  continue;
2359
2583
  return {
2360
2584
  actionKey,
@@ -2424,6 +2648,43 @@ export const HARNESS_GRANT_ORIGIN = "grant_origin";
2424
2648
  * every reader treats absence as "no second name" rather than as a fault.
2425
2649
  */
2426
2650
  export const HARNESS_SPENDING_TASK = "spent_by_task";
2651
+ /**
2652
+ * The policy stamp every harness `execution.started` carries (APRV-447).
2653
+ *
2654
+ * `payload.policy_sha256` names the attested policy the write boundary
2655
+ * re-checked this start's class against: the hash {@link requireAttestation}
2656
+ * returned for the one policy read the appending attempt made, which is the
2657
+ * read the class was resolved from and the attestation was judged on. On the
2658
+ * hook route the verdict itself was drawn from the hook's own, earlier read;
2659
+ * a re-attest landing between that read and this append leaves the stamp
2660
+ * naming the policy in force at the append (the two-read window predates this
2661
+ * field, and is a follow-up to close). Before this field a
2662
+ * policy-authorized start (autonomous, supervised, an unselected live draw)
2663
+ * carried no policy at all, and a reader had to reconstruct the rules in force
2664
+ * from the latest `policy.updated` before the record's seq. A granted start
2665
+ * could recover it through `grant_seq`; it is stamped too, by the same line, so
2666
+ * every harness start answers the question the same way.
2667
+ *
2668
+ * WRITTEN, never accepted. The value is passed in from the attempt's own
2669
+ * attestation check and from nowhere else: neither {@link HarnessStartInput}
2670
+ * nor {@link ConsumeHarnessOptions} has a field for it, and the payload object
2671
+ * is built here from runtime-held values, so a caller's input, option bag or
2672
+ * action payload that happens to carry the same name changes nothing (§11.1
2673
+ * invariant 1: the stamp comes from the verified attestation the gate already
2674
+ * resolved, never from an envelope). An unattested or edited policy never
2675
+ * reaches this line: both write paths refuse `policy-not-attested` first, and
2676
+ * a grant spend under a policy other than the one the grant pinned is refused
2677
+ * `policy-drift` before its payload is built.
2678
+ *
2679
+ * READ by nothing that decides. No verdict, budget, draw, loop floor or
2680
+ * single-use check consults it, and `core/state.ts` reads `policy_sha256` from
2681
+ * `approval.requested` alone. Its readers are downstream followers of the log
2682
+ * that join a start to the rules it ran under, so the field NAME is a stable
2683
+ * contract.
2684
+ */
2685
+ function harnessStartPolicyStamp(attestedSha256) {
2686
+ return { [POLICY_HASH_FIELD]: attestedSha256 };
2687
+ }
2427
2688
  /**
2428
2689
  * Spend a harness grant, exactly once (APRV-117).
2429
2690
  *
@@ -2537,8 +2798,8 @@ function attemptHarnessConsume(logPath, actionKey, actor, options) {
2537
2798
  if (derivation.declared.execution !== "harness") {
2538
2799
  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
2800
  }
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 });
2801
+ if (grantLapsed(derivation, ts)) {
2802
+ 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
2803
  }
2543
2804
  if (derivation.task === null) {
2544
2805
  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 +2875,11 @@ function attemptHarnessConsume(logPath, actionKey, actor, options) {
2614
2875
  options.spendingTask === derivation.task
2615
2876
  ? "direct"
2616
2877
  : "carried"),
2878
+ // APRV-447: the attested policy this spend was judged under, the same
2879
+ // value `policy-drift` compared against the grant's pin above, so on this
2880
+ // path it equals that pin wherever the grant carries one. See
2881
+ // `harnessStartPolicyStamp` for why it is stamped and who reads it.
2882
+ ...harnessStartPolicyStamp(attested.sha256),
2617
2883
  };
2618
2884
  // APRV-287. A carried spend records WHICH tool call spent it, so the
2619
2885
  // completion counterpart can find this start from the event that reports how
@@ -2765,6 +3031,16 @@ function attemptHarnessStart(logPath, input, actor, options) {
2765
3031
  if (!isPayloadHash(bytes)) {
2766
3032
  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
3033
  }
3034
+ // APRV-445 security pass. A proposal's start reaches this function only when
3035
+ // its key holds no live request, and the task id alone says it is a proposal
3036
+ // (read from the task, never from a caller flag). Re-judged here, inside the
3037
+ // attempt that appends, so a request filed or decided after `startProposed`
3038
+ // looked cannot slip past. Checked before the budget verdict, which writes.
3039
+ if (isProposalTask(input.task)) {
3040
+ const refusal = proposalPolicyStartRefusal(logPath, read.records, input.actionKey, bytes, load, resolution, attested.sha256, ts, options);
3041
+ if (refusal !== null)
3042
+ return refusal;
3043
+ }
2768
3044
  const cost = costOf(input.est_cost_usd);
2769
3045
  const budget = evaluateBudgetsWithTask(read.records, budgetScopeOf(load, resolution), { class: input.cls, est_cost_usd: cost }, ts, input.task);
2770
3046
  if (!budget.pass) {
@@ -2787,7 +3063,14 @@ function attemptHarnessStart(logPath, input, actor, options) {
2787
3063
  .join(", ")}`;
2788
3064
  return logged.ok
2789
3065
  ? refuse("budget-exceeded", message, { verdicts: failed, record: logged.record })
2790
- : refuse("budget-exceeded", `${message}; the budget.exceeded event could not be appended: ${logged.message}`, { verdicts: failed });
3066
+ : refuse("budget-exceeded", `${message}; the budget.exceeded event could not be appended: ${logged.message}`,
3067
+ // APRV-478: the writer's own error rides along, so a caller that
3068
+ // waits for the log's lock on its event loop (the harness hook's
3069
+ // `spendUnderLock`) can see that the evidence lost a lock race,
3070
+ // re-run the whole attempt and give the record the same chance to
3071
+ // land that a synchronous lock wait gave it. The verdict is the
3072
+ // budget refusal either way.
3073
+ logged.append === undefined ? { verdicts: failed } : { verdicts: failed, append: logged.append });
2791
3074
  }
2792
3075
  const payload = {
2793
3076
  // The budgets contract: class and est_cost_usd on every start event.
@@ -2798,6 +3081,11 @@ function attemptHarnessStart(logPath, input, actor, options) {
2798
3081
  // APRV-146: unconditional, because a caller that states no bytes was refused
2799
3082
  // above. The record says what ran, not only that something did.
2800
3083
  payload_hash: bytes,
3084
+ // APRV-447: the attested policy this write boundary re-checked the class
3085
+ // against (see `harnessStartPolicyStamp` for the hook's two-read window).
3086
+ // Without it a reader of a policy-authorized start had to reconstruct the
3087
+ // rules in force from the latest `policy.updated` before this seq.
3088
+ ...harnessStartPolicyStamp(attested.sha256),
2801
3089
  };
2802
3090
  const appended = append(logPath, {
2803
3091
  ts,
@@ -2814,6 +3102,54 @@ function attemptHarnessStart(logPath, input, actor, options) {
2814
3102
  return appended;
2815
3103
  return { ok: true, record: appended.record };
2816
3104
  }
3105
+ /**
3106
+ * Why a proposal may NOT start on the policy path, or `null` when it may
3107
+ * (APRV-445 security pass).
3108
+ *
3109
+ * The policy path records an execution the policy itself authorizes. For a
3110
+ * proposal that is true only when no human question about these bytes is open
3111
+ * or answered no under the policy in force, and when the class would not have
3112
+ * put this action in front of a human at all. Three refusals, all `not-granted`:
3113
+ *
3114
+ * - a request for the key is pending or granted and unspent: the key is bound to
3115
+ * the grant path, and `start` must spend the grant (or wait for the decision);
3116
+ * - the key's latest request was rejected or revoked under the attested policy
3117
+ * (or pinned no policy): a human's no under the rules in force stands. After a
3118
+ * re-attest the rules that asked are gone, and the action is judged afresh;
3119
+ * - the class is `supervised-live` and the draw selects these bytes: the draw is
3120
+ * deterministic over the policy and the payload hash, so it reproduces the
3121
+ * intake answer, and a selected action is authorized by a grant, never by the
3122
+ * policy. Without this a withdrawn, expired, rejected or never-filed request
3123
+ * for a selected live action turned into a policy-authorized start.
3124
+ *
3125
+ * Asking the draw appends nothing; a draw that cannot be made gates, so this
3126
+ * fails closed.
3127
+ */
3128
+ function proposalPolicyStartRefusal(logPath, records, actionKey, payloadHash, load, resolution, attestedSha256, ts, options) {
3129
+ const derivation = requestState(records, actionKey, ts, ttlOf(load));
3130
+ if (derivation.execution.started === null &&
3131
+ (derivation.state === "requested" || derivation.state === "granted")) {
3132
+ 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.`);
3133
+ }
3134
+ if (derivation.state === "rejected" || derivation.state === "revoked") {
3135
+ const pinned = derivation.declared.policy_sha256;
3136
+ if (pinned === null || pinned === attestedSha256) {
3137
+ 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.`);
3138
+ }
3139
+ }
3140
+ if (resolution.supervision === "live") {
3141
+ const live = liveVerdict(load, resolution, payloadHash, options.env, {
3142
+ logPath,
3143
+ policyHash: attestedSha256,
3144
+ actionKey,
3145
+ ask: options.drawAsk ?? askDaemonDraw,
3146
+ });
3147
+ if (live.gated) {
3148
+ 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.`);
3149
+ }
3150
+ }
3151
+ return null;
3152
+ }
2817
3153
  // ---------------------------------------------------------------------------
2818
3154
  // finishHarnessExecution — the completion counterpart (APRV-145)
2819
3155
  // ---------------------------------------------------------------------------
@@ -3008,9 +3344,19 @@ function appendExpiry(logPath, derivation, load, ts, options, expectedHead) {
3008
3344
  const payload = {};
3009
3345
  if (derivation.requestTs !== null)
3010
3346
  payload["requested_ts"] = derivation.requestTs;
3011
- const ttlMs = ttlOf(load);
3347
+ // The window this request was ACTUALLY judged by, which is the policy's TTL
3348
+ // narrowed by whatever harness ceiling the requester declared (APRV-423).
3349
+ // Recording the policy's number here while expiring on a shorter one would
3350
+ // leave the record disagreeing with the arithmetic that produced it, and the
3351
+ // reader's obvious check — requested_ts + ttl_ms against ts — would fail on a
3352
+ // correct expiry. The cap rides along beside it so a reader can see WHY the
3353
+ // window was short without holding the request record too.
3354
+ const ttlMs = derivation.effectiveTtlMs;
3012
3355
  if (ttlMs !== null)
3013
3356
  payload["ttl_ms"] = ttlMs;
3357
+ if (derivation.declared.harness_cap_ms !== null) {
3358
+ payload["harness_cap_ms"] = derivation.declared.harness_cap_ms;
3359
+ }
3014
3360
  const onExpiry = load.ok ? load.policy.defaults?.on_expiry : undefined;
3015
3361
  if (onExpiry !== undefined)
3016
3362
  payload["on_expiry"] = onExpiry;
@@ -3063,9 +3409,9 @@ export function expire(logPath, actionKey, options = {}) {
3063
3409
  if (derivation.state !== "requested") {
3064
3410
  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
3411
  }
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 });
3412
+ return refuse("not-expired", derivation.effectiveTtlMs === null
3413
+ ? `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`
3414
+ : `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
3415
  }
3070
3416
  const expired = appendExpiry(logPath, derivation, load, ts, options, read.head);
3071
3417
  if (expired.ok) {
@@ -3079,4 +3425,433 @@ export function expire(logPath, actionKey, options = {}) {
3079
3425
  }
3080
3426
  return expired;
3081
3427
  }
3428
+ // ---------------------------------------------------------------------------
3429
+ // propose (APRV-445)
3430
+ // ---------------------------------------------------------------------------
3431
+ /**
3432
+ * The largest payload `approval propose` and `approval start` accept, in UTF-8
3433
+ * bytes of the JSON text as the caller sent it: 256 KiB.
3434
+ *
3435
+ * Well under `approval serve`'s 1 MiB body cap, so the refusal a caller meets is
3436
+ * this named one rather than a transport's. A proposal is a sentence or a
3437
+ * paragraph a human will read on a phone; a payload this size is not one.
3438
+ */
3439
+ export const PROPOSE_PAYLOAD_MAX_BYTES = 256 * 1024;
3440
+ /** The longest `--key` `propose` accepts, in UTF-8 bytes (APRV-445, L6). */
3441
+ export const PROPOSE_KEY_MAX_BYTES = 1024;
3442
+ /** The longest `--summary` `propose` accepts, in UTF-8 bytes (APRV-445, L6). */
3443
+ export const PROPOSE_SUMMARY_MAX_BYTES = 4096;
3444
+ /**
3445
+ * The id after `<class>:` (APRV-445 recheck L-b): one or more printable
3446
+ * characters, no whitespace, no control, format, private-use, unassigned or
3447
+ * surrogate code points, so a key reads back the same in a log, a phone and a
3448
+ * shell.
3449
+ */
3450
+ const PROPOSE_KEY_ID = /^[^\s\p{C}]+$/u;
3451
+ /** How many times `propose` starts again on a log that moved under it (SF1). */
3452
+ const PROPOSE_MAX_ATTEMPTS = 4;
3453
+ /** `origin.app` on every envelope `propose` registers. */
3454
+ export const PROPOSE_ORIGIN_APP = "approval-propose";
3455
+ /** The task-id prefix `propose` derives under. */
3456
+ export const PROPOSE_TASK_PREFIX = "propose:";
3457
+ /**
3458
+ * Was this task registered by `propose` (APRV-445)?
3459
+ *
3460
+ * A proposal's request carries `execution: "harness"` like a hook's, so the two
3461
+ * hook-side readers of that marker (the carry and the abandoned-question sweep)
3462
+ * ask this first: a proposal waits days for a human, is spent only by `start`,
3463
+ * and is withdrawn only by its requester's own call.
3464
+ */
3465
+ export function isProposalTask(task) {
3466
+ return typeof task === "string" && task.startsWith(PROPOSE_TASK_PREFIX);
3467
+ }
3468
+ /** A concrete class, as `schema/envelope.schema.json` spells one: no wildcard. */
3469
+ const CONCRETE_CLASS = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/u;
3470
+ /**
3471
+ * The task id `propose` registers under, from who is asking, the class and the
3472
+ * key: `propose:` and the first 32 hex digits of the SHA-256 over the RFC 8785
3473
+ * canonical JSON of `[actor, class, key]`.
3474
+ *
3475
+ * Deterministic, so a retry lands on the task its first attempt registered and
3476
+ * is recognised as a retry. The ACTOR is the tenant half of the derivation: one
3477
+ * `approval serve` process serves one store as one fixed `agent:<id>`, so within
3478
+ * a store the actor is who the tenant's agent is, and two actors proposing the
3479
+ * same key derive two different tasks, the second of which `register` refuses
3480
+ * because the key is already declared under the first (`task-already-registered`).
3481
+ * The class is in it so that the same key proposed under a second class is a
3482
+ * different task rather than a silent re-declaration.
3483
+ */
3484
+ export function proposedTaskId(actor, cls, actionKey) {
3485
+ return `${PROPOSE_TASK_PREFIX}${hashOfPayload([actor, cls, actionKey]).slice(0, 32)}`;
3486
+ }
3487
+ /** The sentence a `class-not-agent-requestable` refusal says. */
3488
+ function notAgentRequestable(cls, opened) {
3489
+ const why = !opened.explicit
3490
+ ? `${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)`
3491
+ : opened.pattern === null
3492
+ ? `neither ${JSON.stringify(cls)} nor any declared \`<prefix>.*\` family of it sets agent_may_request: true`
3493
+ : `${JSON.stringify(opened.pattern)} sets agent_may_request: false`;
3494
+ 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).`;
3495
+ }
3496
+ /**
3497
+ * `approval propose` (APRV-445, amended SPEC.md §5.2): register and request one
3498
+ * action in one call, for a requester that holds the payload inline and no file
3499
+ * on the gate's machine.
3500
+ *
3501
+ * ## Why this verb may name its class when `request` may not
3502
+ *
3503
+ * `approval request` reads the class from the log's `task.registered` record so
3504
+ * that an agent cannot call a `financial.spend` a `read.web`. Here the agent is
3505
+ * also the registrant, so that rule alone would prove nothing, and the bound is
3506
+ * the operator's instead: the class must be an EXACT key of `policy.classes`, and
3507
+ * that key or a declared `<prefix>.*` family of it must say
3508
+ * `agent_may_request: true`. A class reached only through a wildcard is refused,
3509
+ * so the set of classes an agent can propose in is a set of lines the operator
3510
+ * wrote and attested by name. What the agent chooses among them is still its own
3511
+ * statement (SPEC.md §11.1 invariant 4 is why the choice is bounded at all), and
3512
+ * an operator opening two classes of different autonomy to agents is trusting
3513
+ * the agent to pick the right one; the record names the class it picked.
3514
+ *
3515
+ * ## Order
3516
+ *
3517
+ * Attestation, then the agent-requestability gate and the class's own autonomy
3518
+ * (`human-only`, an unruled `harness.launch.*`), all BEFORE anything is
3519
+ * registered, so a refused class leaves no registration behind. Then the
3520
+ * registration, then {@link request} with `agentProposal: true`, which repeats
3521
+ * the gate against the bytes its own attempt reads. `execution: "harness"` on the
3522
+ * request: nothing ever presents a token for a proposal, the requester proceeds
3523
+ * on the grant itself and records that with `approval start`, so a minted token
3524
+ * would be a live credential with no spender.
3525
+ *
3526
+ * ## Idempotency
3527
+ *
3528
+ * The task id is {@link proposedTaskId}. A second call with the same actor,
3529
+ * class, key and payload hash finds its own registration and appends nothing
3530
+ * while the request is live or decided, answering with the request's state and
3531
+ * `idempotent: true`. A withdrawn or expired request (a re-attestation voids
3532
+ * pending requests as `policy-drift`) is asked again under the same registration.
3533
+ * The same key with DIFFERENT bytes refuses: `duplicate-request` while a request
3534
+ * is live, `payload-mismatch` otherwise, because a key is bound to the bytes it
3535
+ * was first registered with and new bytes need a new key.
3536
+ */
3537
+ export function propose(logPath, input, actor, options = {}, attempt = 0, appendedEarlier = false) {
3538
+ // APRV-445 recheck (SF1). Every decision below is re-checked by the append
3539
+ // it leads to (`withdraw` with `onlyIfVoid`, `request` with
3540
+ // `agentProposal`), so a caller that read a stale log is refused there
3541
+ // rather than writing over a concurrent caller. A refusal of that kind means
3542
+ // the log moved under this call; the call starts again from a fresh read, a
3543
+ // bounded number of times, and answers what it then finds.
3544
+ const again = (refusal) => attempt < PROPOSE_MAX_ATTEMPTS
3545
+ ? propose(logPath, input, actor, options, attempt + 1, appendedEarlier || withdrewNow || registeredNow)
3546
+ : 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.`);
3547
+ if (!isPrincipalActor(actor)) {
3548
+ return refuse("actor-invalid", `propose requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
3549
+ }
3550
+ if (!CONCRETE_CLASS.test(input.cls)) {
3551
+ 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.`);
3552
+ }
3553
+ if (input.actionKey.length === 0) {
3554
+ return refuse("envelope-invalid", "the action key is empty; nothing was registered and nothing was appended.");
3555
+ }
3556
+ // APRV-445 refutation (S4): the key carries its class, so a consumer that
3557
+ // parses the key can never attribute it to a class its record does not name.
3558
+ // Consumers read the class from the record; this keeps the key from lying.
3559
+ if (!input.actionKey.startsWith(`${input.cls}:`) ||
3560
+ !PROPOSE_KEY_ID.test(input.actionKey.slice(input.cls.length + 1))) {
3561
+ 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.`);
3562
+ }
3563
+ if (Buffer.byteLength(input.actionKey, "utf8") > PROPOSE_KEY_MAX_BYTES) {
3564
+ return refuse("envelope-invalid", `the key is over ${String(PROPOSE_KEY_MAX_BYTES)} bytes; nothing was registered and nothing was appended.`);
3565
+ }
3566
+ if (Buffer.byteLength(input.summary, "utf8") > PROPOSE_SUMMARY_MAX_BYTES) {
3567
+ return refuse("envelope-invalid", `the summary is over ${String(PROPOSE_SUMMARY_MAX_BYTES)} bytes; nothing was registered and nothing was appended.`);
3568
+ }
3569
+ let hash;
3570
+ try {
3571
+ hash = hashOfPayload(input.payload);
3572
+ }
3573
+ catch (cause) {
3574
+ 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.`);
3575
+ }
3576
+ const task = proposedTaskId(actor, input.cls, input.actionKey);
3577
+ const read = readGateRecords(logPath);
3578
+ if (!read.ok)
3579
+ return read;
3580
+ const policyRead = readPolicyOnce(options);
3581
+ const attested = requireAttestation(read.records, policyRead);
3582
+ if (!attested.ok)
3583
+ return attested;
3584
+ const load = parsePolicy(policyRead, options);
3585
+ const opened = agentRequestability(load, input.cls);
3586
+ const resolution = resolve(load, input.cls);
3587
+ if (!opened.allowed && !(opened.humanOnly && opened.explicit && opened.pattern !== null)) {
3588
+ return refuse("class-not-agent-requestable", notAgentRequestable(input.cls, opened));
3589
+ }
3590
+ if (harnessLaunchNeedsRule(input.cls, resolution)) {
3591
+ return refuse("harness-launch-unruled", harnessLaunchUnruledRefusal(input.cls, `the proposal ${input.actionKey} was not registered and nothing was appended`));
3592
+ }
3593
+ if (resolution.autonomy === "human-only") {
3594
+ return refuse("class-human-only", humanOnlyRefusal(input.cls, `the proposal ${input.actionKey} was not registered and nothing was appended`));
3595
+ }
3596
+ const answered = (decision, state, seq, idempotent) => ({
3597
+ ok: true,
3598
+ task,
3599
+ actionKey: input.actionKey,
3600
+ cls: input.cls,
3601
+ payloadHash: hash,
3602
+ decision,
3603
+ state,
3604
+ seq,
3605
+ idempotent: idempotent && !appendedEarlier,
3606
+ });
3607
+ const existing = registeredAction(read.records, task, input.actionKey);
3608
+ let registeredNow = false;
3609
+ let withdrewNow = false;
3610
+ if (existing.ok) {
3611
+ const derivation = requestState(read.records, input.actionKey, tick(options), ttlOf(load));
3612
+ if (existing.action.payload_hash !== hash) {
3613
+ if (derivation.state === "requested") {
3614
+ 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 });
3615
+ }
3616
+ 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 });
3617
+ }
3618
+ // APRV-445 refutation (B1): the retry is judged by what the request still
3619
+ // AUTHORIZES, not by what it recorded. A live question, a usable grant, a
3620
+ // human's no and a started execution are answered from the log and append
3621
+ // nothing. A question that can no longer be answered usefully is asked
3622
+ // again under the same registration: a lapsed or withdrawn request, a grant
3623
+ // whose window ran out (a tap at hour 71 of 72 authorizes nothing at 73),
3624
+ // and a grant or pending request pinned to a policy that has since been
3625
+ // re-attested (`void`: the spend and the decision both refuse it).
3626
+ const standing = requestStanding(read.records, input.actionKey, tick(options), ttlOf(load), attested.sha256);
3627
+ const seqOf = derivation.decisionSeq ?? derivation.requestSeq;
3628
+ if (standing === "executed") {
3629
+ return answered(derivation.requestSeq !== null
3630
+ ? "requested"
3631
+ : resolution.autonomy === "autonomous"
3632
+ ? "autonomous"
3633
+ : "supervised", "executed", seqOf, true);
3634
+ }
3635
+ if (standing === "requested" ||
3636
+ standing === "granted" ||
3637
+ standing === "rejected" ||
3638
+ standing === "revoked") {
3639
+ return answered("requested", standing, seqOf, true);
3640
+ }
3641
+ if (standing === "void" && derivation.state === "requested") {
3642
+ // A pending question no human may decide any more. Taken back by its own
3643
+ // requester (the gate's only way to end a request early that is not a
3644
+ // decision), then asked again below.
3645
+ const retracted = withdraw(logPath, input.actionKey, actor, {
3646
+ ...options,
3647
+ onlyIfVoid: true,
3648
+ reason: "superseded",
3649
+ 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",
3650
+ });
3651
+ if (!retracted.ok) {
3652
+ // A concurrent caller withdrew it, re-asked it, or a human answered it.
3653
+ if (retracted.code === "request-withdrawn" ||
3654
+ retracted.code === "already-decided" ||
3655
+ retracted.code === "duplicate-request" ||
3656
+ retracted.code === "expired") {
3657
+ return again(retracted);
3658
+ }
3659
+ return retracted;
3660
+ }
3661
+ withdrewNow = true;
3662
+ }
3663
+ }
3664
+ else if (existing.code === "not-registered") {
3665
+ const registered = register(logPath, {
3666
+ task,
3667
+ envelope: {
3668
+ origin: { app: PROPOSE_ORIGIN_APP, created_by: actor },
3669
+ state: "proposed",
3670
+ actions: [
3671
+ {
3672
+ class: input.cls,
3673
+ summary: input.summary,
3674
+ idempotency_key: input.actionKey,
3675
+ payload_hash: hash,
3676
+ },
3677
+ ],
3678
+ },
3679
+ }, actor, { ...options, proposal: true });
3680
+ if (!registered.ok) {
3681
+ // APRV-445 (L5). Two identical proposals racing: the other one registered
3682
+ // this exact task first. Its registration is ours byte for byte, so the
3683
+ // call is a retry and is answered as one.
3684
+ if (registered.code === "task-already-registered")
3685
+ return again(registered);
3686
+ return registered;
3687
+ }
3688
+ registeredNow = true;
3689
+ }
3690
+ else {
3691
+ // The derived task exists and declares some other key: nothing `propose`
3692
+ // registers looks like that, so somebody else registered this id.
3693
+ 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.`);
3694
+ }
3695
+ const result = request(logPath, {
3696
+ task,
3697
+ actionKey: input.actionKey,
3698
+ cls: input.cls,
3699
+ summary: input.summary,
3700
+ payload_hash: hash,
3701
+ payload: { value: input.payload },
3702
+ execution: "harness",
3703
+ agentProposal: true,
3704
+ }, actor, options);
3705
+ if (!result.ok) {
3706
+ if (result.code === "duplicate-request" || result.code === "already-decided" || result.code === "already-executed") {
3707
+ return again(result);
3708
+ }
3709
+ return result;
3710
+ }
3711
+ if (result.record === null) {
3712
+ return answered(result.autonomy === "autonomous" ? "autonomous" : "supervised", null, null,
3713
+ // APRV-445 recheck (L-a): a call that withdrew a void request appended
3714
+ // something, so it is not an idempotent answer.
3715
+ !registeredNow && !withdrewNow);
3716
+ }
3717
+ return answered("requested", "requested", result.record.seq, false);
3718
+ }
3719
+ /**
3720
+ * `approval start <task> --action <key> --payload-json <json>` (APRV-445): record
3721
+ * the `execution.started` for an action its requester is about to carry out
3722
+ * itself, outside any harness hook.
3723
+ *
3724
+ * Until this verb an agent holding only the serve agent credential could ask and
3725
+ * wait, and could not say that it then acted: `approval run` spawns argv on the
3726
+ * gate's machine, and the harness hook writes starts only for tool calls it
3727
+ * classified. A proposal the policy authorizes outright (`autonomous`,
3728
+ * `supervised`) therefore left no record at all, and one a human granted left a
3729
+ * grant nobody spent, so a second publication of the same key was indistinguishable
3730
+ * from the first.
3731
+ *
3732
+ * It adds no authority. It writes through the two existing spenders and through
3733
+ * nothing else: {@link consumeHarnessGrant} when the key carries a request (it
3734
+ * refuses unless that request is granted, unexpired, harness-executed and
3735
+ * unspent, under the policy it was asked under), and {@link startHarnessExecution}
3736
+ * when it carries none (it refuses a `manual` or `human-only` class, an escalated
3737
+ * loop and a spent key, and charges the budgets). Both pin the single-use rule:
3738
+ * a second start of one key is `already-executed`.
3739
+ *
3740
+ * Two checks of its own, both before either spender runs. REQUESTER-ONLY: the
3741
+ * actor must be the one that registered the task (`not-requester` otherwise), as
3742
+ * `withdraw` is, so an agent cannot spend or exhaust a key someone else declared.
3743
+ * And the bytes: the caller presents the payload it is about to act on, and its
3744
+ * hash must equal the registered `payload_hash` (`payload-mismatch` otherwise),
3745
+ * so the thing done is the thing that was approved, which the grant path checks
3746
+ * again against the grant's own binding.
3747
+ */
3748
+ export function startProposed(logPath, task, actionKey, payload, actor, options = {}) {
3749
+ if (!isPrincipalActor(actor)) {
3750
+ return refuse("actor-invalid", `start requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
3751
+ }
3752
+ let hash;
3753
+ try {
3754
+ hash = hashOfPayload(payload);
3755
+ }
3756
+ catch (cause) {
3757
+ return refuse("payload-store-failed", `the payload for ${actionKey} could not be canonicalized: ${cause instanceof Error ? cause.message : String(cause)}. Nothing was appended.`);
3758
+ }
3759
+ if (!isProposalTask(task)) {
3760
+ return refuse("task-not-proposal", `task ${task} was not registered by \`approval propose\`; start records the executions of proposals only. Nothing was appended.`);
3761
+ }
3762
+ const read = readGateRecords(logPath);
3763
+ if (!read.ok)
3764
+ return read;
3765
+ const declared = registeredAction(read.records, task, actionKey);
3766
+ if (!declared.ok)
3767
+ return declared;
3768
+ let registrant = null;
3769
+ for (const record of read.records) {
3770
+ if (record.event === "task.registered" && record.task === task)
3771
+ registrant = record.actor;
3772
+ }
3773
+ if (registrant !== actor) {
3774
+ 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.`);
3775
+ }
3776
+ if (declared.action.payload_hash !== hash) {
3777
+ 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.`);
3778
+ }
3779
+ // APRV-445 refutation (S5). Only a request that can still answer binds the
3780
+ // key to the grant path: one that is pending (spending it refuses until a
3781
+ // human decides) or granted and unspent. A withdrawn, expired, rejected or
3782
+ // revoked request says nothing about an action the policy authorizes on its
3783
+ // own now, so after a re-tiering to autonomous the start is the policy's.
3784
+ // This read only routes; the policy path re-judges inside its own append
3785
+ // (`proposalPolicyStartRefusal`): a live request, a human's no under the
3786
+ // policy in force, and a selected supervised-live draw each refuse there.
3787
+ const derivation = requestState(read.records, actionKey, tick(options), ttlOf(parsePolicy(readPolicyOnce(options), options)));
3788
+ const binding = derivation.execution.started === null &&
3789
+ (derivation.state === "requested" || derivation.state === "granted");
3790
+ if (binding) {
3791
+ const spent = consumeHarnessGrant(logPath, actionKey, actor, {
3792
+ ...options,
3793
+ presentedPayloadHash: hash,
3794
+ spendingTask: task,
3795
+ });
3796
+ if (!spent.ok)
3797
+ return spent;
3798
+ return { ok: true, task, actionKey, cls: declared.action.class, authorization: "grant", record: spent.record };
3799
+ }
3800
+ const started = startHarnessExecution(logPath, {
3801
+ task,
3802
+ actionKey,
3803
+ cls: declared.action.class,
3804
+ payload_hash: hash,
3805
+ ...(declared.action.est_cost_usd === undefined ? {} : { est_cost_usd: declared.action.est_cost_usd }),
3806
+ }, actor, options);
3807
+ if (!started.ok)
3808
+ return started;
3809
+ return { ok: true, task, actionKey, cls: declared.action.class, authorization: "policy", record: started.record };
3810
+ }
3811
+ export function requestStanding(records, actionKey, ts, ttlMs, attestedSha256) {
3812
+ const derivation = requestState(records, actionKey, ts, ttlMs);
3813
+ if (derivation.execution.started !== null)
3814
+ return "executed";
3815
+ if (derivation.state === "granted") {
3816
+ if (grantLapsed(derivation, ts))
3817
+ return "expired";
3818
+ // APRV-445 recheck (B). A grant is void only where its SPEND refuses
3819
+ // `policy-drift`, which is the harness spend (`consumeHarnessGrant`) and
3820
+ // therefore every proposal. A token grant is spent by `approval run`
3821
+ // through `consumeToken`, which does not compare policy hashes, so after a
3822
+ // re-attest that grant still runs: calling it void would abort the
3823
+ // documented `wait && run` flow and send an agent to ask again over a live
3824
+ // human grant.
3825
+ const driftEnforced = derivation.declared.execution === "harness" || isProposalTask(derivation.task);
3826
+ const pinned = grantedPolicyHash(records, derivation.decisionSeq) ?? derivation.declared.policy_sha256;
3827
+ if (driftEnforced && attestedSha256 !== null && pinned !== null && pinned !== attestedSha256) {
3828
+ return "void";
3829
+ }
3830
+ return "granted";
3831
+ }
3832
+ if (derivation.state === "requested") {
3833
+ const pinned = derivation.declared.policy_sha256;
3834
+ if (attestedSha256 !== null && pinned !== null && pinned !== attestedSha256)
3835
+ return "void";
3836
+ return "requested";
3837
+ }
3838
+ if (derivation.state === "withdrawn" && derivation.decisionSeq !== null) {
3839
+ for (const record of records) {
3840
+ if (record.seq !== derivation.decisionSeq)
3841
+ continue;
3842
+ if (payloadOf(record)["reason"] === "policy-drift")
3843
+ return "void";
3844
+ }
3845
+ }
3846
+ return derivation.state;
3847
+ }
3848
+ /**
3849
+ * The SHA-256 the live policy bytes are attested under, or `null` when they are
3850
+ * not attested (unread, unattested, or changed since). One read of the file
3851
+ * through the same seam every gate operation uses.
3852
+ */
3853
+ export function attestedPolicySha256(records, options = {}) {
3854
+ const attested = requireAttestation(records, readPolicyOnce(options));
3855
+ return attested.ok ? attested.sha256 : null;
3856
+ }
3082
3857
  //# sourceMappingURL=gate.js.map