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
@@ -65,11 +65,20 @@ tampering.
65
65
  ```
66
66
  clean {"status":"clean","records":3,"head":{"seq":3,"hash":"<64 hex>"}}
67
67
  torn-tail {"status":"torn-tail","records":3,"head":null,
68
- "intactThroughSeq":3,"message":"..."}
68
+ "intactThroughSeq":3,"tear":"nul-filled","tornBytes":456,
69
+ "intactBytes":2817,"message":"..."}
69
70
  corrupt {"status":"corrupt","records":null,"head":null,
70
71
  "firstBadSeq":2,"reason":"hash-mismatch","message":"..."}
71
72
  ```
72
73
 
74
+ On a torn tail, `tear` says which crash left it (APRV-440). `nul-filled` means
75
+ every byte of the unterminated tail is NUL: the crash-before-writeback
76
+ signature, where the file grew but its data never reached the disk, so nothing
77
+ was tampered and no record was half-written. `partial-line` is a writer that
78
+ died mid-line. `intactBytes` is where the verified records end, which is the
79
+ length to truncate the file to; `tornBytes` is what that removes. Nothing is
80
+ truncated by this verb.
81
+
73
82
  `head` is null for an empty log. `reason` is one of `malformed-line`,
74
83
  `schema-invalid`, `bad-alg`, `hash-mismatch`, `prev-mismatch`, `seq-gap`,
75
84
  `seq-duplicate`, `not-genesis`, `head-mismatch`. `anomalies` is ADDITIVE and
@@ -302,6 +311,64 @@ that is due to a refusal of anything: it is a warning on `log verify`, a
302
311
  doctor`'s `checkpoint` row. A gate that held up an action for want of a tap is a
303
312
  gate whose operator turns the check off.
304
313
 
314
+ ## log unlock
315
+
316
+ Takes over `events.jsonl.lock` from a writer that died holding it, when no
317
+ writer could do that itself, and records it.
318
+
319
+ ```
320
+ approval log unlock --pid <n|none> --as human:<id> [--log <path>] [--json]
321
+ ```
322
+
323
+ **Why a person.** Every writer that finds the lock held waits out its whole
324
+ lock timeout (two seconds) and then judges the holder once. It takes the lock
325
+ back by itself only when it can prove the holder gone: the holder ran in this
326
+ writer's own pid namespace and boot, and `kill(pid, 0)` answers ESRCH (on Linux
327
+ also with `/proc/<pid>` absent or a zombie, or the pid now names a process that
328
+ started later), or the lockfile names no holder, is ten minutes old, and sat
329
+ unchanged through the writer's whole wait (a try-once caller never takes it). Any
330
+ other holder (another container, another boot, a pid answering EPERM, a `/proc`
331
+ it cannot read) is never taken automatically, because a wrong reclaim forks the
332
+ chain. The writer's `lock-timeout` refusal then names the pid and this command.
333
+
334
+ **What it does.** It reads the lockfile strictly, refuses a `--pid` that is not
335
+ the one the record names (`none` for a lockfile with no holder record), refuses
336
+ a holder it sees running (Linux: the same start time in this namespace;
337
+ elsewhere: the pid on this host in this boot), and refuses beside a `log sync`
338
+ snapshot. It then takes the lock exactly as a writer's reclaim does (an
339
+ exclusive `link(2)` to `<lock>.stale.<pid>.<created ms>`, then a `rename(2)` of
340
+ its own lockfile over the lock's path) and appends `audit.lock_reclaimed` as the
341
+ first record under it, with your `human:` actor, reason `operator-cleared` (your
342
+ word, which the log never presents as a writer's proof), the strictly parsed
343
+ holder and the lock's age. A holder `created` the log cannot vouch for (more
344
+ than a day before the log's last record, or more than five minutes ahead of
345
+ this clock) is recorded as `null`, with no age. A stale name a dead reclaimer or
346
+ anyone else left is cleared first, and a dead reclaimer's own
347
+ `<lock>.take.<pid>.*` file with it. Anything at the lock's path that is not a
348
+ lockfile (a FIFO, a link, a malformed file) is not taken: the refusal says to
349
+ `rm -v` it.
350
+
351
+ It also refuses, touching nothing:
352
+
353
+ - while a reclaim is in flight: a writer writes its own
354
+ `<lock>.take.<pid>.<nonce>` before it claims, and a running one is named, so
355
+ a reclaimer that is stopped (SIGSTOP, a debugger) is never unlocked around;
356
+ - when the record could not be appended: a daemon whose id the attested
357
+ policy refuses, or a torn tail (repair the tail first);
358
+ - when the claim is refused EPERM or EACCES (Linux `fs.protected_hardlinks`:
359
+ the lockfile is another user's): the refusal names the owner's uid; run it as
360
+ that user or root, or `rm -v` the lockfile once no writer runs;
361
+ - when a directory sits at the stale name: the refusal names `rm -r` for it.
362
+
363
+ **Run it only when no writer is running.** The verb asserts, on your word, that
364
+ the holder is gone. Human-only: the actor comes from `--as` or `APPROVAL_HUMAN`,
365
+ the schema refuses an `agent:` actor on the record, and `core/command-class.ts`
366
+ classifies `approval log unlock` (and `node <cli> log unlock`) `policy.core`, so the harness hook denies an agent
367
+ that tries it that way. Other spellings (`npx approval log unlock`, `npm exec`,
368
+ a wrapper script) classify as workspace writes, and `--as` is the caller's to
369
+ set, so the classification is a speed bump rather than the boundary (APRV-491).
370
+ Exit 0 when it unlocked or there was no lock; 4 when it refused.
371
+
305
372
  ## log follow
306
373
 
307
374
  `approval log follow --from <seq> --json` is the channel-independent decision
@@ -498,6 +565,11 @@ advance is the one that may carry `log.advance.daemon` instead, and only from
498
565
  inside the daemon process: see the `--advance` paragraph under `daemon run`
499
566
  (APRV-382).
500
567
 
568
+ An advance publishes the WHOLE log, so while its branch is open on origin it is
569
+ the log's publisher and `policy amend` stands aside: an amendment opened in that
570
+ window carries only the policy bytes and cannot conflict with the records. See
571
+ "Who publishes the log" under `policy amend` (APRV-420).
572
+
501
573
  `--co-author "Name <email>"` adds one validated `Co-authored-by` trailer to the
502
574
  generated records commit and to the pull request body. When the day's pull
503
575
  request already exists, the verb preserves its body and adds the trailer once.
@@ -669,9 +741,18 @@ channel; policy-authorized execution is never represented as a human grant.
669
741
  "autonomy":"autonomous","winner":true,
670
742
  "tieBreak":"specificity"|"strictest-autonomy"|
671
743
  "lexicographic"|"tied-specificity"}],
744
+ "agentRequest":{"allowed":false,"explicit":true,"pattern":null},
672
745
  "decisionPath":["...","..."]}
673
746
  ```
674
747
 
748
+ `agentRequest` (APRV-445) answers whether `approval propose` would admit this
749
+ class: `explicit` is whether the class is an exact key of `classes`, `pattern`
750
+ is the key whose `agent_may_request` decided (the exact key, or the most
751
+ specific declared `<prefix>.*` family that states one), and `allowed` is both.
752
+ It is read without the resolution above, because a wildcard match never makes a
753
+ class proposable. A `decisionPath` line appears only when some key states the
754
+ flag for this class.
755
+
675
756
  `specificity` is [literalSegments, wildcardSegments, totalSegments] (SPEC §5.2).
676
757
  `overridden.pattern` is null when the floor overrode `defaults.autonomy` rather
677
758
  than a rule.
@@ -741,6 +822,62 @@ existed still validate and verify; a reader treats the absent field as the
741
822
  pre-amendment state, where the in-force bytes are unrecoverable and the
742
823
  fail-closed fallback applies.
743
824
 
825
+ **No terminal is needed.** The identity is declared, so the verb runs the same
826
+ from a provisioning script with stdin at `/dev/null` as from a shell. The
827
+ `attest-requires-terminal` refusal belongs to the channel path (a tap cannot
828
+ attest a policy that maps no sender for that channel), never to this verb.
829
+
830
+ ### `--bootstrap`: a store's first policy, safe to re-run (APRV-449)
831
+
832
+ The plain verb is an unconditional assertion: it reads no log and appends a
833
+ `policy.updated` every time it runs. That is right for a human at a terminal and
834
+ wrong for a provisioning step that runs again on every update and recreate of a
835
+ hosted tenant, where a re-run would add an attestation per redeploy and, once
836
+ the tenant has attested or amended their own policy, would put the operator back
837
+ on record as its attester. `--bootstrap` reads the VERIFIED log first:
838
+
839
+ ```
840
+ approval policy attest --bootstrap --as human:<operator> [--json]
841
+ ```
842
+
843
+ | the log carries | answer | exit |
844
+ |---|---|---|
845
+ | no attestation | attests, exactly as the plain verb | 0 |
846
+ | an attestation of these exact bytes | `policy-already-attested`, nothing appended | 1 |
847
+ | an attestation of other bytes | `policy-amendment-required`, nothing appended | 1 |
848
+
849
+ Both refusals add `seq` and `attested_by` (the attestation in force and who made
850
+ it) to the error object:
851
+
852
+ ```
853
+ refusal {"ok":false,"error":{"code":"policy-already-attested","message":"...",
854
+ "seq":1,"attested_by":"human:carter"}} on stderr
855
+ ```
856
+
857
+ `policy-already-attested` is the expected answer on a re-run, so a provisioning
858
+ step branches on `error.code` and treats that one code as done.
859
+ `policy-amendment-required` means the bytes on disk moved after the store was
860
+ attested. The operator does not attest that change: a change to an attested
861
+ policy is an amendment, and an amendment is the approver's act through a channel
862
+ (SPEC.md §10.3). The append is compare-and-append against the head the decision
863
+ was read from, and binds the digest it checked, so an attestation landing in
864
+ between, or a file rewritten in between, refuses (`head-moved`, or `io` naming
865
+ the changed bytes) rather than stacking an operator attestation on top. A torn
866
+ log is exit 3 and a log that does not verify is `log-corrupt` at exit 1.
867
+
868
+ With `--bootstrap` the log is the store's: it resolves under `--dir`
869
+ (`<dir>/.approval/log/events.jsonl`) unless `--log` names one, so `approval
870
+ policy attest --bootstrap --dir "$STORE"` attests the store's own log from any
871
+ working directory. The plain verb keeps its working-directory default. A
872
+ refused append can leave one unbound copy of the policy text in the payload
873
+ store, because the text is stored before the append (APRV-356). It is not
874
+ removed: the record that moved the head may be another attestation of the same
875
+ bytes, which binds that very file. An unbound file is inert, and `approval
876
+ status` counts it under `payload_store.orphans`.
877
+
878
+ `--bootstrap` beside `--organ` or `--path` is a usage error. The whole
879
+ provisioning sequence is [docs/hosted-provisioning.md](hosted-provisioning.md).
880
+
744
881
  ### `--organ <path>`: the gate's organs (APRV-272)
745
882
 
746
883
  The ORGANS are the harness files that install the hook: `.claude/settings*`,
@@ -1035,6 +1172,69 @@ base carries it, and a pins edit somebody else landed is not reverted by this
1035
1172
  ceremony. The pin deltas print in the semantic diff beside the class deltas, ride
1036
1173
  in the commit subject, and appear in `--json` as `pins`.
1037
1174
 
1175
+ **Who publishes the log (APRV-420).** The log is in that file set only when this
1176
+ amendment is the thing publishing it. On 2026-09-21 it always was, and PR 530
1177
+ paid for it: two records advances merged first carrying the same records (the
1178
+ attestation at seq 65736 and its payload reached main through PR 531), and git
1179
+ does not care that one side's appended lines are a prefix of the other's, so the
1180
+ amendment went DIRTY, lost its arm, and the repair was a hand merge of
1181
+ `events.jsonl` in a throwaway worktree.
1182
+
1183
+ The cause was two publishers carrying the same bytes, so the ceremony now leaves
1184
+ one of them holding it. It asks two questions of the base it is about to commit
1185
+ on, in this order:
1186
+
1187
+ 1. does the base's log already carry every record this attestation added? Then
1188
+ the log is published and the amendment has nothing to add to it.
1189
+ 2. is a records advance live, meaning origin carries a `records-log-*` branch?
1190
+ That advance publishes the log, so the amendment has nothing to add to it
1191
+ either. Whether the branch already carries THIS attestation is checked
1192
+ rather than assumed: the ceremony fetches the branch and compares chains.
1193
+ An advance pushed before the human signed does not carry it, which is the
1194
+ ordinary order; the ceremony then says so, the next advance publishes the
1195
+ record, and the pull request's protected-path guard holds the policy
1196
+ change until a records branch or main carries it.
1197
+
1198
+ Either way the commit is the policy bytes, the attested text and the pins, and a
1199
+ commit that does not touch `events.jsonl` cannot conflict on `events.jsonl`,
1200
+ whichever side lands first. Otherwise the amendment is the only publisher and
1201
+ carries the log, exactly as it always has. The ceremony prints which of the three
1202
+ happened, so the pull request's file list is never a surprise.
1203
+
1204
+ The question is asked with `git ls-remote`, not `gh`, so it answers the same on a
1205
+ box with no GitHub CLI and no token. And it fails toward CARRYING: anything the
1206
+ check cannot establish leaves the amendment holding the log. A dirty pull request
1207
+ is a nuisance; an attestation that reaches `main` in nobody's commit is a policy
1208
+ in force that the committed log does not record.
1209
+
1210
+ **A re-run repairs the pull request it finds (APRV-420).** A second run used to
1211
+ stop at `nothing to amend`, which is true and useless: the live policy does match
1212
+ its attestation, and the pull request carrying that attestation is open,
1213
+ unmergeable and unarmed. So when `--commit` or `--pr` is asked for and origin
1214
+ already carries the amendment branch, the re-run fetches the default branch and
1215
+ computes what it still LACKS — the policy bytes, the attested text, the pins, and
1216
+ the log under the rule above — and then:
1217
+
1218
+ - nothing lacking: the amendment landed in full. It says so and prints `gh pr
1219
+ close <branch> --delete-branch`.
1220
+ - something lacking: it rebuilds that commit on the CURRENT default branch and
1221
+ force-updates the branch, then re-arms `gh pr merge <branch> --merge --auto`.
1222
+ The trunk's log is a superset of the amendment's by construction, so rebuilding
1223
+ on it is the re-merge without a merge.
1224
+
1225
+ The rebuild is `commitOnBase`'s scratch index like every other commit this verb
1226
+ makes, so nothing is checked out and no live appender has the log moved
1227
+ underneath it. The force-push is the one in this codebase, and it is bounded: the
1228
+ remote branch must be a single commit on top of a commit the base already
1229
+ contains, which is the shape this ceremony creates and nothing else. A branch
1230
+ carrying anything more is reported with `git log --oneline <base>..<tip>` and
1231
+ left alone. `--dry-run` and `--no-publish` compute the repair, print the
1232
+ commands and run none of it. `--json` carries a `repair` object (`branch`,
1233
+ `state` of `landed`/`repaired`/`owed`/`failed`, `base`, `carried`, `commit`,
1234
+ `pushed`, `autoMerge`, `commands`, `message`), additively and always present:
1235
+ `null` on every run that had an amendment to make and on every no-op that found
1236
+ no branch standing. Only `failed` is a nonzero exit.
1237
+
1038
1238
  It refuses outside a git repository (`commit-preconditions`), and refuses
1039
1239
  `staged-unrelated` when the index holds staged changes to anything beyond those
1040
1240
  three: a commit that swept in an unrelated staged edit would make "this commit is
@@ -1524,6 +1724,24 @@ hash must equal the declared `payload_hash` and it is filed in
1524
1724
  bytes from. Supply it here once and no channel needs `--payload-dir` or
1525
1725
  `--payloads` at all.
1526
1726
 
1727
+ For an action `approval run` will execute, produce those bytes with `approval
1728
+ payload run -- <cmd…>` rather than by hand. Since APRV-401 the payload carries
1729
+ the size and digest of the script the argv names, and a hand-written
1730
+ `{"argv":…,"cwd":…}` for a command that names a script is a declaration the
1731
+ execution will refuse `payload-mismatch`, correctly, because it describes bytes
1732
+ nobody bound:
1733
+
1734
+ ```
1735
+ approval payload run --hash -- bash scripts/install.sh # the declaration
1736
+ approval payload run -- bash scripts/install.sh \
1737
+ | approval request <task> --action <key> --as agent:<id> --payload -
1738
+ ```
1739
+
1740
+ Material supplied here is checked against the declared hash and never against
1741
+ the filesystem: this verb does not know where the run will happen, and the
1742
+ guarantee is that `approval run` recomputes the same value from the tree it is
1743
+ about to spawn into and refuses on any difference.
1744
+
1527
1745
  When the policy permits an unattended action, supplied material is still checked
1528
1746
  against the registered action's task, class and payload hash and retained before
1529
1747
  `proceed:true` is returned. This creates no approval event and does not require
@@ -1545,6 +1763,129 @@ refusal {"ok":false,"error":{"code":"...","message":"...",
1545
1763
  The refusal goes to stderr, and `seq` is the `budget.exceeded` record that WAS
1546
1764
  appended.
1547
1765
 
1766
+ ## propose
1767
+
1768
+ `approval propose --class <c> --key <k> --summary <s> --payload-json <json>`
1769
+ registers one action and requests it in one call (APRV-445, amended SPEC.md
1770
+ §10.1). It exists for a requester that holds its payload in memory and has no
1771
+ file on the gate's machine: an agent in a sandbox reaching `approval serve`
1772
+ with the agent credential, which refuses every path flag. `--payload-json` is
1773
+ the payload as the flag's VALUE, a JSON object of at most 262144 bytes of UTF-8
1774
+ as sent; an array, string, number or `null` is a usage error, and a larger
1775
+ payload is refused `payload-too-large` at exit 2 before it is parsed.
1776
+
1777
+ **Why this verb may name its class.** `request` never does, so that an agent
1778
+ cannot call a `financial.spend` a `read.web`. Here the agent is also the
1779
+ registrant, so the bound is the operator's: the class must be an EXACT key of
1780
+ the policy's `classes` (a wildcard rule that happens to match does not count),
1781
+ and that key or a declared `<prefix>.*` family of it must set
1782
+ `agent_may_request: true` (SPEC.md §5.2). Anything else refuses
1783
+ `class-not-agent-requestable` and registers nothing. A `human-only` member
1784
+ under an open family refuses `class-human-only`, also before anything is
1785
+ registered. The check runs again inside the request's own read-check-append
1786
+ cycle, so a re-attestation in between cannot slip a closed class through. What
1787
+ the agent picks AMONG the opened classes is still its own statement: an
1788
+ operator who opens a manual class and an autonomous one under the same family
1789
+ is trusting the agent to pick the right one, and the record names the class it
1790
+ picked.
1791
+
1792
+ ```yaml approval-policy
1793
+ classes:
1794
+ intent.publish.*: { autonomy: manual, agent_may_request: true }
1795
+ intent.publish.inferred.index: { autonomy: manual }
1796
+ intent.publish.stated.index: { autonomy: autonomous }
1797
+ ```
1798
+
1799
+ **The key** must begin with `<class>:` and an id of printable characters with
1800
+ no whitespace or control characters (refused
1801
+ `key-class-mismatch`, exit 2): `intent.publish.inferred.index:<intention id>`.
1802
+ It is recorded verbatim as `action_key`, and a consumer reads the class from the
1803
+ record rather than parsing it back out of the key. The key is at most 1024 bytes
1804
+ and `--summary` at most 4096; a lone UTF-16 surrogate in either or anywhere in
1805
+ the payload, and a number JSON parses to Infinity (`1e400`), are usage errors. An
1806
+ integer beyond 2^53 is hashed as the double it parses to, so send large ids as
1807
+ strings.
1808
+
1809
+ **Do not open a `supervised-live` class to agents.** The live draw would decide,
1810
+ per proposal, whether a human is asked at all, which the requester can neither
1811
+ predict nor explain to the person whose words it is proposing.
1812
+
1813
+ **The task id** is `propose:` and the first 32 hex digits of the SHA-256 over
1814
+ the RFC 8785 canonical JSON of `[actor, class, key]`. The actor is the tenant
1815
+ half: one `approval serve` process is one store and one fixed `agent:<id>`. Two
1816
+ actors proposing one key derive two tasks, and the second registration is
1817
+ refused `task-already-registered` because the key is already declared under the
1818
+ first.
1819
+
1820
+ **Records.** One `task.registered` (actor = the caller, envelope `origin.app:
1821
+ approval-propose`, one action: the class, the summary, `idempotency_key` = the
1822
+ key, `payload_hash`) and one `approval.requested` exactly as `request` writes it,
1823
+ with `action_key` = the key verbatim, `summary`, `payload_hash`, `display_hash`,
1824
+ `policy_sha256` and `execution: "harness"` (a grant mints no token; the
1825
+ requester proceeds on the grant and records it with `start`). The summary is in
1826
+ the log in cleartext, twice; the payload is never in the log, only in the
1827
+ payload store (`.approval/payloads/<hash>.json`, mode 0600 in a 0700 directory),
1828
+ which the tenant credential's `GET /export` includes and the agent credential
1829
+ can never read back. Set `payload_retention` so the daemon prunes bytes whose
1830
+ action has finished.
1831
+
1832
+ **Idempotency.** The same class, key and bytes append nothing and answer the
1833
+ request's standing with `idempotent: true` while it still means something:
1834
+ pending, granted and spendable, rejected, revoked, or executed (`state:
1835
+ "executed"`, after `start`). The same call ASKS AGAIN, under the same
1836
+ registration, when the request can no longer be answered usefully: withdrawn,
1837
+ expired, a grant whose request window lapsed (a grant has no window of its own,
1838
+ so a tap at hour 71 of 72 authorizes nothing at hour 73), or a grant or pending
1839
+ request pinned to a policy that has since been re-attested (a pending one is
1840
+ first withdrawn, reason `superseded`). Two identical calls racing to register
1841
+ are one proposal. The same key with different bytes refuses
1842
+ `duplicate-request` while a request is live and `payload-mismatch` otherwise: a
1843
+ key is bound to the bytes it was first proposed with. A plain `approval request`
1844
+ on a `propose:` task is refused `task-is-proposal`.
1845
+
1846
+ **Off the manual path.** A class that resolves `autonomous` or `supervised` is
1847
+ registered and answered at once with `decision: "autonomous"` (or
1848
+ `"supervised"`) and no approval record. Act, then record it with `start`.
1849
+
1850
+ **`--json`** (one object on stdout):
1851
+
1852
+ ```
1853
+ manual {"ok":true,"task":"propose:<32 hex>","action_key":"<k>","class":"<c>",
1854
+ "payload_hash":"<64 hex>","decision":"requested","state":"requested",
1855
+ "seq":7,"idempotent":false}
1856
+ retry {...,"state":"granted","seq":9,"idempotent":true}
1857
+ autonomous {...,"decision":"autonomous","state":null,"seq":null,"idempotent":false}
1858
+ ```
1859
+
1860
+ Poll a `requested` proposal with `approval wait <task> --timeout 0`.
1861
+
1862
+ ## start
1863
+
1864
+ `approval start <task> --action <key> --payload-json <json>` appends the one
1865
+ `execution.started` for an action its requester is about to carry out itself,
1866
+ outside any harness hook (APRV-445). Proposals only: any other task is refused
1867
+ `task-not-proposal`. REQUESTER-ONLY: the caller must be the actor
1868
+ that registered the task (`not-requester`). The bytes presented must hash to the
1869
+ registered `payload_hash` (`payload-mismatch`), so what is done is what was
1870
+ proposed.
1871
+
1872
+ It writes through the two existing harness spenders and nothing else. A key
1873
+ whose request is pending or granted and unspent spends its harness grant: refused `not-granted` while
1874
+ pending or after a rejection, `expired` after the window, `policy-drift` under
1875
+ a re-attested policy. A key with no such request (none, or one withdrawn,
1876
+ expired, rejected or revoked, which binds nothing) is recorded as authorized by
1877
+ the policy itself: refused `not-granted` for a manual class and `class-human-only`
1878
+ for a human-only one, and charged against the budgets. A second start of one key
1879
+ is `already-executed`. The record carries `execution: "harness"`, which is why no
1880
+ `execution.completed` ever follows it.
1881
+
1882
+ **`--json`**:
1883
+
1884
+ ```
1885
+ {"ok":true,"task":"propose:<32 hex>","action_key":"<k>","class":"<c>",
1886
+ "authorization":"grant"|"policy","seq":12}
1887
+ ```
1888
+
1548
1889
  ## grant
1549
1890
 
1550
1891
  Legal only on a request that is awaiting a decision. A second decision is
@@ -1719,8 +2060,8 @@ refusal {"ok":false,"error":{"code":"...","message":"...","state"?:"..."}}
1719
2060
 
1720
2061
  ## gate refusal codes
1721
2062
 
1722
- The vocabulary every gate verb (register, request, grant, reject, revoke,
1723
- withdraw, expire) returns in `error.code` with `--json`. Frozen public API: an agent branches on it
2063
+ The vocabulary every gate verb (register, request, propose, start, grant,
2064
+ reject, revoke, withdraw, expire) returns in `error.code` with `--json`. Frozen public API: an agent branches on it
1724
2065
  to decide whether to fix itself, stop retrying, or ask a human.
1725
2066
 
1726
2067
  - `policy-not-attested` — policy unattested or its bytes changed since
@@ -1784,7 +2125,8 @@ to decide whether to fix itself, stop retrying, or ask a human.
1784
2125
  it, or is withdrawing one it already withdrew. Distinct from `already-decided`:
1785
2126
  nobody answered, and nobody can now. Request the action again.
1786
2127
  - `not-requester` — a withdrawal was attempted by an actor other than the one
1787
- that opened the request. Only the party that asked may take the question back;
2128
+ that opened the request, or a `start` by an actor other than the one that
2129
+ registered the task. Only the party that asked may take the question back;
1788
2130
  anyone else who wants it gone rejects it.
1789
2131
  - `expired` — the TTL lapsed, judged from the request's own `ts`.
1790
2132
  - `not-expired` — expire was called before the TTL lapsed, or the policy declares
@@ -1822,6 +2164,20 @@ to decide whether to fix itself, stop retrying, or ask a human.
1822
2164
  at the verb (exit 2) rather than a member of this union.
1823
2165
  - `log-unreadable` (exit 4) / `log-torn-tail` (exit 3) / `log-corrupt` (exit 1):
1824
2166
  nothing is authorized from a log that does not verify.
2167
+ - `class-not-agent-requestable` — `propose` named a class the policy has not
2168
+ opened to agents: not an exact key of `classes`, or neither that key nor a
2169
+ declared `<prefix>.*` family sets `agent_may_request: true`. Nothing is
2170
+ registered or appended; the repair is a policy edit and a human attestation.
2171
+ - `payload-too-large` (exit 2) — a `propose` or `start` payload over 262144
2172
+ bytes of UTF-8. Refused before it is parsed; nothing is stored.
2173
+ - `task-is-proposal` — a plain `request` on a task `propose` registered, or a
2174
+ task file claiming an id in the `propose:` namespace. Nothing is appended.
2175
+ - `task-not-proposal` — `start` on a task `propose` did not register.
2176
+ - `key-class-mismatch` (exit 2) — a `propose` key that does not begin with
2177
+ `<class>:` and an identifier.
2178
+ - `contended` — `propose` restarted its bounded number of times on a log that
2179
+ kept moving under it and stopped; nothing more was appended. Read the
2180
+ standing with `wait --timeout 0`, or call again.
1825
2181
  - `append-failed` — the append itself failed; the exit code follows the cause.
1826
2182
  `head-moved` means the log grew between this command's read and its write, so
1827
2183
  nothing was written. Since APRV-236 you see it only after the command has
@@ -1953,9 +2309,23 @@ measurement.
1953
2309
  Content binding (amended SPEC.md §6.2, §10.4): run computes the hash of the argv
1954
2310
  and cwd it is about to spawn, always, and presents that. The command IS the
1955
2311
  action here, and an executor that had to be told what it was running could be
1956
- told wrong. `--payload-hash` is consequently a CHECK and never a substitute
2312
+ told wrong.
2313
+
2314
+ Since APRV-401 that computation includes THE SCRIPT THE ARGV NAMES. A known
2315
+ interpreter followed by a path operand, or a path at `argv[0]`, carries that
2316
+ file's size and SHA-256 inside the hashed payload, so a grant over `bash
2317
+ install.sh` binds the script's bytes rather than its path: a script edited
2318
+ between the declaration and the run hashes differently and is refused
2319
+ `payload-mismatch`, with nothing appended and the grant still live for the
2320
+ approved bytes. An argv naming no script hashes exactly as it always did.
2321
+ `approval payload run` prints the same value, which is how a requester obtains
2322
+ the declaration in the first place; `docs/run-payload-binding.md` states the
2323
+ rule and the six things it does not reach.
2324
+
2325
+ `--payload-hash` is consequently a CHECK and never a substitute
1957
2326
  (APRV-140): a value differing from the recomputed one is refused
1958
- `payload-mismatch` before the child exists and before anything is appended. An
2327
+ `payload-mismatch` before the child exists and before anything is appended, and
2328
+ the refusal names the script it bound so the repair is visible. An
1959
2329
  action whose payload is content rather than an argv (an email body, a record
1960
2330
  write, a message and its recipients) is executed through the adapter contract of
1961
2331
  §10.4, `approval adapter email`, which hashes those bytes itself; a token for
@@ -2154,6 +2524,33 @@ costs, and the carve-outs.
2154
2524
 
2155
2525
  ## wait
2156
2526
 
2527
+ `--timeout 0` (also `0s`, `0ms`) reads the current state once and never sleeps
2528
+ (APRV-445): an undecided request answers `timeout`, exit 6, at once, and a
2529
+ decided one answers as it always would. Use it to poll through `approval serve`,
2530
+ which runs every verb call whole under the store lock that hook calls take for
2531
+ their appends (APRV-427), so a wait that sleeps there holds the tenant's hook
2532
+ traffic for as long as it sleeps (APRV-441). The
2533
+ duration grammar has no zero anywhere else. `--withdraw-on-timeout` is refused
2534
+ beside it (exit 2): a status read that withdrew would retract every question it
2535
+ looked at.
2536
+
2537
+ **What a waiter is told is what the request still authorizes** (APRV-445). A
2538
+ grant whose request window has lapsed reads `expired` (exit 3), not `granted`. A
2539
+ pending request pinned to a policy hash that is no longer the attested one (any
2540
+ task), a HARNESS grant so pinned (`execution: "harness"`, every proposal
2541
+ included: its spend refuses `policy-drift`), or a request the runtime withdrew
2542
+ for `policy-drift`, reads `void`, **exit 7**: the answer can never be used, and
2543
+ the repair is to ask again (for a proposal, the same `propose` call re-files). A
2544
+ token grant is NOT void after a re-attest: `approval run` spends it without
2545
+ comparing policy hashes, so `wait && run` keeps working and `wait` reads
2546
+ `granted`. Precedence: rejected > withdrawn >
2547
+ void > expired > granted > executed > nothing-to-wait-for. An action that ran
2548
+ on the policy's own authority after its request ended (a `start` after a
2549
+ withdrawal and a re-tiering) reads `executed`, status `executed`, exit 0. A
2550
+ registered task with no requests answers `nothing-to-wait-for` at exit 0, and an
2551
+ unknown task id is refused `not-registered` at exit 1 (APRV-428), so poll the
2552
+ task id `propose` returned.
2553
+
2157
2554
  Polls the log and writes nothing — not even the `approval.expired` event it may
2158
2555
  derive: expiry is judged lazily from the request's own timestamp, and
2159
2556
  materialising it is `approval expire`'s job, not a reader's. The one exception is
@@ -2162,9 +2559,27 @@ materialising it is `approval expire`'s job, not a reader's. The one exception i
2162
2559
  For `approval wait` the exit code IS the decision (SPEC.md §10.1). The
2163
2560
  overloading of 1 (integrity / rejected) and 3 (torn tail / expired) is
2164
2561
  deliberate: wait appends nothing and cannot fail a chain verification of its
2165
- own, and `--json` names the outcome exactly (`granted | rejected | withdrawn |
2166
- expired | timeout`) for callers that need more than a number. Flagged for human
2167
- review.
2562
+ own, and `--json` names the outcome exactly (`granted | executed | rejected |
2563
+ withdrawn | void | expired | nothing-to-wait-for | timeout`) for callers that need more than a
2564
+ number. Flagged for human review.
2565
+
2566
+ **Nothing to wait for, and nothing registered** (APRV-428). Exit 0 has always
2567
+ answered at once for a task with no requests, because only the manual path
2568
+ produces requests and the supervised path tells an agent its `proceed: true`
2569
+ needs no wait. Its `status` is `nothing-to-wait-for`, never `granted`: a
2570
+ registered task with no `approval.requested` at all (no action declared, none
2571
+ reached the manual path, or none requested yet), or one whose every granted
2572
+ action has already started executing, so every grant is spent. `granted` means
2573
+ at least one granted action has not executed yet, and only that. The per-action
2574
+ rows are unchanged; an executed grant still reads `granted` there.
2575
+
2576
+ A task with no `task.registered` record is refused before anything is derived:
2577
+ `not-registered`, the gate union's code for exactly that condition (SPEC.md
2578
+ §11.2), at exit 1, the code `approval request` exits for the same refusal.
2579
+ Before APRV-428 such a wait answered `{"ok":true,"status":"granted","actions":[]}`,
2580
+ so a typo read as a grant. Through `approval serve` the verb's refusal passes
2581
+ through unchanged: the body's `stderr` carries the same object and `exit_code`
2582
+ is 1.
2168
2583
 
2169
2584
  `withdrawn` (APRV-106) reuses exit **1** rather than claiming a new number. The
2170
2585
  exit table in `src/cli/exit-codes.ts` is frozen public API and agents already
@@ -2192,10 +2607,11 @@ itself fails is reported on stderr and leaves the request live; the exit code is
2192
2607
 
2193
2608
  ```
2194
2609
  decided {"ok":true,"task":"task-042",
2195
- "status":"granted"|"rejected"|"withdrawn"|"expired",
2610
+ "status":"granted"|"rejected"|"withdrawn"|"expired"|"nothing-to-wait-for",
2196
2611
  "actions":[{"action_key":"...","state":"granted","seq":4}]}
2197
2612
  timeout {"ok":false,"task":"task-042","status":"timeout",
2198
2613
  "actions":[{"action_key":"...","state":"requested","seq":3}]}
2614
+ refused {"ok":false,"error":{"code":"not-registered","message":"..."}} (stderr, exit 1)
2199
2615
  ```
2200
2616
 
2201
2617
  **Sealed token delivery** (APRV-105). Under policy `defaults.token_delivery:
@@ -2227,8 +2643,8 @@ so the paste path is preserved rather than replaced.
2227
2643
  passed, listing the keys actually retracted; the default shape is unchanged.
2228
2644
 
2229
2645
  `state` is the per-action derived state; `status` is the whole task's outcome,
2230
- with rejected/revoked outranking withdrawn, withdrawn outranking expired, and
2231
- expired outranking granted. `--timeout` and `--interval` take the SPEC.md §5.2
2646
+ with rejected/revoked outranking withdrawn, withdrawn outranking expired,
2647
+ expired outranking granted, and granted outranking nothing-to-wait-for. `--timeout` and `--interval` take the SPEC.md §5.2
2232
2648
  duration grammar, `<positive integer><ms|s|m|h|d|w>`.
2233
2649
 
2234
2650
  ## queue
@@ -2252,11 +2668,16 @@ requested, and how much of the TTL is left.
2252
2668
  {"ok":true,"pending":[{"action_key":"task-042:chaser","task":"task-042",
2253
2669
  "class":"communicate.email.external","est_cost_usd":"0.02",
2254
2670
  "requested_ts":"2026-08-06T10:00:00.000Z","seq":3,
2255
- "ttl_remaining_ms":3599000}]}
2671
+ "ttl_remaining_ms":3599000,"ttl_ms":3600000}]}
2256
2672
  ```
2257
2673
 
2258
- `pending` is `[]` for an empty inbox. `ttl_remaining_ms` is null when the policy
2259
- declares no `defaults.approval_ttl` (no TTL means no lapse).
2674
+ `pending` is `[]` for an empty inbox. Both TTL fields are measured against the
2675
+ window the gate judges the request by (APRV-423): the policy's
2676
+ `defaults.approval_ttl`, narrowed by the harness ceiling the requesting hook
2677
+ declared, if any. `ttl_ms` is that whole window and `ttl_remaining_ms` is what is
2678
+ left of it; both are null only when nothing bounds the request (no TTL and no
2679
+ cap means no lapse). A hook-opened request under a policy with no TTL therefore
2680
+ shows its real remaining window here rather than `null`.
2260
2681
 
2261
2682
  ## gate
2262
2683
 
@@ -2397,9 +2818,47 @@ informational: it moves neither the health verdict nor the exit code. An empty
2397
2818
  store is the normal state of a repo that has never made a request carrying
2398
2819
  `--payload`. (`approval doctor` is where an unwritable store is a failure.)
2399
2820
 
2821
+ **attestation** names the policy in force: its state, the seq of the attestation
2822
+ the log last recorded, and `attested_by`, the actor of that record (APRV-449).
2823
+ The identity is read from the verified record and never from the file, so a
2824
+ hosted tenant can see whether their starting policy is the operator's
2825
+ bootstrap or their own later act (docs/hosted-provisioning.md). It is the
2826
+ identity the attesting process declared, which the runtime does not
2827
+ authenticate. `attested_by` is `null` when nothing was attested; on
2828
+ `hash-mismatch` it names who attested the bytes the live file no longer
2829
+ matches. The text row reads `attested (seq 1, by human:carter)`, and `approval
2830
+ doctor`'s attestation row carries the same name.
2831
+
2400
2832
  **anomalies** are informational for the same reason `approval log verify`
2401
2833
  declined to refuse on them: status does not get to overrule that.
2402
2834
 
2835
+ **refusals** is the two refusal families, counted, with the newest five of each
2836
+ (APRV-376). `decision` counts `audit.decision_refused`: a human decided and the
2837
+ gate would not take it, which is APRV-235's record. `gesture` counts
2838
+ `audit.gesture_refused`: a human made a gesture that is not a decision (a
2839
+ checkpoint signature, a review, a review note) and the surface refused it before
2840
+ any verb ran, which is APRV-355's. Until this field existed neither was reported
2841
+ anywhere, and an operator could reach them only through `approval log tail` and
2842
+ `approval log export`. The question the field answers is small enough that the
2843
+ chain was a poor place to answer it: told that taps from an account they did not
2844
+ map are being refused, an operator wants to know how many and from which account.
2845
+
2846
+ Each entry carries the `seq`, the surface's own refusal code verbatim, and the
2847
+ observed `sender` when the record carries one, in the form the record carries it
2848
+ (`hashed: true` marks APRV-370's keyed digest rather than a raw account id). The
2849
+ count is every record of that family in the log; the listing is the newest five,
2850
+ newest first, because a row that grew with the log would push the rest of the
2851
+ report off a terminal for a fact that decides nothing.
2852
+
2853
+ It is informational, exactly as `coverage` and `anomalies` are: it moves neither
2854
+ `healthy` nor the exit code. A refusal is the gate having worked, and a report
2855
+ that went red because the gate turned away a stranger's tap would teach an
2856
+ operator to stop reading it. It reads only verified records (SPEC.md §11.1
2857
+ invariant 1), which is also why a log that does not verify reports no refusals
2858
+ rather than reporting some it cannot stand behind. A family with no records is
2859
+ absent rather than zero, and the whole field is absent when both families are, so
2860
+ a repository where nothing has been refused emits the object it always emitted.
2861
+
2403
2862
  **coverage** is one line of `approval coverage` (APRV-245): the commits git
2404
2863
  recorded on THIS branch, counted against the verified log. The range is the
2405
2864
  merge base with `origin/main` to `HEAD`, so what it measures is what this branch
@@ -2420,7 +2879,7 @@ does not know the flag exists.
2420
2879
  **What it reports**, in one object:
2421
2880
 
2422
2881
  - `attestation` — attested | hash-mismatch | not-attested | unreadable, with the
2423
- seq of the governing `policy.updated` record.
2882
+ seq of the governing `policy.updated` record and `attested_by`, its actor.
2424
2883
  - `verification` — the latest chain verdict, and the record count (null when
2425
2884
  corrupt).
2426
2885
  - `dangling` — executions the runtime meant to watch and never closed. Not
@@ -2443,12 +2902,25 @@ does not know the flag exists.
2443
2902
  how many the log records as pruned, and how many are unbound. Informational.
2444
2903
  - `anomalies` — additive and present only when non-empty: gate-typed events whose
2445
2904
  `ts` steps backwards by more than 2s. Informational.
2905
+ - `refusals` — additive and present only when the log carries a refused decision
2906
+ or a refused gesture: `{count, recent}` per family, each recent entry naming the
2907
+ `seq`, the refusal `code` and the observed `sender` where there was one.
2908
+ Informational.
2909
+ - `daemon` — `{id, source, allowed}`, always present (APRV-383): the daemon
2910
+ instance id a daemon run against THIS log would write onto every record it
2911
+ appends, whether it came from `APPROVAL_DAEMON_ID` (`source: "environment"`) or
2912
+ was derived from the instance (`source: "derived"`), and the `daemons` allowlist
2913
+ the attested policy puts in force. `id: null` means `APPROVAL_DAEMON_ID` holds
2914
+ something that is not a usable id, so a daemon started here would be refused
2915
+ every append. `allowed: null` means no restriction, which is not the same fact as
2916
+ an empty list. Informational: nothing here moves `healthy` or the exit code, and
2917
+ being listed grants a daemon nothing beyond the ability to write.
2446
2918
 
2447
2919
  **`--json`** (one object on stdout):
2448
2920
 
2449
2921
  ```
2450
2922
  {"ok":true,"healthy":false,
2451
- "attestation":{"state":"attested","seq":1},
2923
+ "attestation":{"state":"attested","seq":1,"attested_by":"human:carter"},
2452
2924
  "verification":{"status":"clean","records":6},
2453
2925
  "dangling":[{"action_key":"...","task":"...","ts":"...","seq":5}],
2454
2926
  "budgets":[{"limit":"global.daily_usd","scope":"global",
@@ -2460,12 +2932,16 @@ does not know the flag exists.
2460
2932
  "reconciliation":[{"seq":18,"ts":"...","action_key":"...","task":"...",
2461
2933
  "class":"records.write","obligation":"gated-revert","review_seq":17}],
2462
2934
  "payload_store":{"present":true,"files":2,"pruned":0,"orphans":0,
2463
- "note":"..."}}
2935
+ "note":"..."},
2936
+ "refusals":{"decision":{"count":2,"recent":[{"seq":21,"code":"policy-drift"}]},
2937
+ "gesture":{"count":1,"recent":[{"seq":24,"code":"sender-unmapped",
2938
+ "sender":{"channel":"telegram","id":"5551234567"}}]}},
2939
+ "daemon":{"id":"daemon-3f2a9c11","source":"derived","allowed":null}}
2464
2940
  ```
2465
2941
 
2466
- `ok` is true whenever status ran; `healthy` is the verdict. `attestation.seq` is
2467
- null for not-attested and unreadable. `note` carries the unrebuildable warning
2468
- verbatim.
2942
+ `ok` is true whenever status ran; `healthy` is the verdict. `attestation.seq` and
2943
+ `attestation.attested_by` are null for not-attested and unreadable. `note`
2944
+ carries the unrebuildable warning verbatim.
2469
2945
 
2470
2946
  ## coverage
2471
2947
 
@@ -2657,7 +3133,8 @@ The checks, at length:
2657
3133
  `--as`: this reports what the next command will find.
2658
3134
  - **attestation** — anything other than "the live bytes match" makes every gated
2659
3135
  operation refuse, and that refusal reads like "the policy says no" when it
2660
- means "the policy is unverified".
3136
+ means "the policy is unverified". A pass names the seq and the attester read
3137
+ from that record (`attested at seq 1 by human:carter`, APRV-449).
2661
3138
  - **log** — a torn tail and a corrupt log are both failures here; neither is
2662
3139
  repaired, and doctor never truncates a torn line.
2663
3140
  - **telegram** — `getMe` against `--api-base`, when both variables are set;
@@ -2674,7 +3151,17 @@ The checks, at length:
2674
3151
  `--payload` request creates it); an existing directory this process cannot
2675
3152
  write FAILS, because a request already accepted by the gate would refuse
2676
3153
  `payload-store-failed` mid ceremony. The probe creates and removes one empty
2677
- file and reads no payload.
3154
+ file. Since APRV-457 the row also reads back every payload a verified record
3155
+ binds (pruned ones excepted) and FAILS on two distinct readings. An empty or
3156
+ all-NUL file, or a missing file whose record proves the store held it
3157
+ (`policy.proposed`, `policy.updated`, or `approval.requested` carrying
3158
+ `display_hash`), is the crash-before-writeback signature: nothing was
3159
+ tampered and the bytes are gone from this store; the fix moves a torn file
3160
+ aside and restores a copy that `approval payload hash` confirms. A file
3161
+ holding any non-NUL byte that does not verify is NOT the crash signature
3162
+ (a partial writeback leaving a valid prefix and a NUL tail lands here too, the stricter reading) and reads as tampering or
3163
+ corruption; the fix moves it aside as `.suspect`. Doctor moves, restores and
3164
+ deletes nothing.
2678
3165
  - **audit-sampling** — sampling fails open by design (SPEC.md §5.2), so an
2679
3166
  unconfigured sampler silently audits nothing; this states the disabled reason
2680
3167
  out loud. A sampler nobody configured skips; a half-configured one fails,
@@ -2749,17 +3236,25 @@ The checks, at length:
2749
3236
  of the gate, so the repair is a line for a human to commit, printed by
2750
3237
  `approval instructions hook`.
2751
3238
  - **harness-hook-wiring** — whether THIS checkout's `.claude/settings.json`
2752
- registers `approval hook` for PreToolUse over every gated tool (Bash, Edit,
2753
- Write, NotebookEdit). SKIP, named, when the file is absent, unreadable, or
3239
+ registers `approval hook claude-code` for PreToolUse over every gated tool.
3240
+ The roster is read from the adapter that answers the calls (Bash, Edit, Write,
3241
+ MultiEdit, NotebookEdit since APRV-408, where it was a hand list that had
3242
+ drifted behind them). SKIP, named, when the file is absent, unreadable, or
2754
3243
  registers the hook for only some tools: a spawned-agent worktree without the
2755
3244
  entry is how the APRV-151 bypasses happened, and a session started elsewhere
2756
3245
  may still be hooked, so this row can only speak for the checkout it runs in.
2757
3246
  PASS means the entry is present on disk, and says so plainly: it is not proof
2758
- the running session loaded it. The check that trusts no session is the
2759
- CI-side grant cross-check (`scripts/protected-path-guard.mjs`) over the
2760
- committed log, which since APRV-202 requires every added and removed line of a
2761
- protected path to trace to the bound material of a grant, rather than only
2762
- that the path was granted at some point in the week.
3247
+ the running session loaded it. The one FAIL is a handler whose `--dir` names a
3248
+ checkout other than this one, which answers from another policy, another log
3249
+ and another open window. Two informational lines never decide anything: the
3250
+ adapter read tools the matcher leaves out (Read, Glob, Grep) are named as the
3251
+ documented default they are, and a matcher tool the adapter handles as none of
3252
+ its own is named as the unclassified `allow` it will actually receive. The
3253
+ check that trusts no session is the CI-side grant cross-check
3254
+ (`scripts/protected-path-guard.mjs`) over the committed log, which since
3255
+ APRV-202 requires every added and removed line of a protected path to trace to
3256
+ the bound material of a grant, rather than only that the path was granted at
3257
+ some point in the week.
2763
3258
  - **keychain-scope** — whose keystore items this instance's `.approval/env`
2764
3259
  names, answered from the NAMES alone so that it too can never block on an
2765
3260
  unlock dialog. FAIL for an item whose eight-hex scope suffix belongs to
@@ -2863,7 +3358,14 @@ The checks, at length:
2863
3358
  the `fix` is the promptless self-test in `docs/claude-code-hook.md`, one
2864
3359
  supervised-class tool call, after which the row is green. PASS says only that
2865
3360
  the versions match — the field is self-reported (SPEC.md §11.1 invariant 4)
2866
- and reduces nothing anywhere, so a match is not proof the hook fired.
3361
+ and reduces nothing anywhere, so a match is not proof the hook fired. Since
3362
+ APRV-415 the row carries a second finding for one harness: a Hermes below the
3363
+ **fail-closed version floor** (a build at or after `main` `118984d7` of
3364
+ 2026-09-20) FAILS, because `v0.21.3` ignores `fail_closed` silently and every
3365
+ broken hook on it proceeds. That half is read from `hermes --version` rather than
3366
+ from a record — Hermes prints its build stamp behind a non-ASCII separator the
3367
+ write boundary refuses, so no record can carry it — and it compares the build
3368
+ date and the upstream commit, because the two builds report the same semver.
2867
3369
  - **live-draw** — whether a daemon is answering `supervised-live` draws for this
2868
3370
  log (APRV-208). SKIP when the policy declares no live class: no draw is ever
2869
3371
  made, and a missing socket is nothing. It FAILS in three shapes, all of them
@@ -2997,6 +3499,22 @@ The checks, at length:
2997
3499
  would find nothing and report green, which is the worst direction a health
2998
3500
  check can fail in. The `fix` points at the harness's own configuration,
2999
3501
  because nothing here can turn another system's reviewer off.
3502
+ - **daemon-identity** — which daemon id a daemon run against this log would
3503
+ write onto every record it appends, and whether the attested policy admits it
3504
+ (APRV-383). The id is `APPROVAL_DAEMON_ID` from this shell where it is set and
3505
+ otherwise `daemon-` plus the instance id the `keychain-scope` row names, so the
3506
+ two rows describe one gate. A FAIL where `APPROVAL_DAEMON_ID` is set to
3507
+ something that is not an id: a daemon started from this shell would be refused
3508
+ `daemon-id-invalid` on every append, so it would read, render and report while
3509
+ writing nothing, and the `fix` is that one variable. A loud SKIP where the
3510
+ attested policy's `daemons` list does not admit the id, naming what the list
3511
+ does admit — expected where the daemon for this log runs on another machine,
3512
+ which is the hosted case the key exists for, and wrong if that daemon is meant
3513
+ to be this one. A PASS where the list admits it, and where there is no list at
3514
+ all, which is every installation that never adopts the key. Being listed grants
3515
+ nothing beyond the ability to write: no verdict, budget, floor, draw or token
3516
+ reads the id. The row reads the policy and one variable's SHAPE, prints no
3517
+ value on any path, and asks no running daemon anything.
3000
3518
 
3001
3519
 
3002
3520
 
@@ -3078,20 +3596,113 @@ carries in the open.
3078
3596
 
3079
3597
  ## audit review
3080
3598
 
3599
+ **The reviewer must be on the class's roster (APRV-483).** Where the policy rule
3600
+ that resolves the sampled action's class names `approvers`, the reviewer must be
3601
+ one of them, compared exactly as a grant's approver is (`namesApprover` in
3602
+ `core/gate.ts`): a bare id on the roster matches `human:<id>`. Anyone else is
3603
+ refused `actor-not-approver` (exit 1) and nothing is appended. This binds every
3604
+ surface, because the check is in `reviewSample` itself: `--as human:<id>` at a
3605
+ terminal and a sender-mapped tap on a Telegram card are held to the same list.
3606
+ A rule that names no `approvers` restricts nobody, exactly as for grants. The
3607
+ class comes from the action's registration (a sample naming no class is refused
3608
+ `actor-not-approver`, never read as "no roster"), and the roster is read from
3609
+ the policy file a grant would read, only when its bytes are the latest
3610
+ attestation's: an unattested, edited, unreadable or `--policy`-substituted file
3611
+ is refused `policy-not-attested` (exit 1), because a roster read from a file
3612
+ nobody attested is one the reviewer could have chosen. Attested bytes that do not
3613
+ load (a schema-invalid rule, a YAML error) are refused `policy-invalid` (exit 1):
3614
+ `policy attest` hashes bytes without parsing them, and the fail-closed reading
3615
+ of a broken policy names no roster, which for a review would mean anyone. Nothing
3616
+ resolves from a broken file, so an on-roster reviewer is refused too; the repair
3617
+ is a corrected policy, attested.
3618
+
3619
+ **The roster is the one in force when the action ran (PR #614 refutation F2,
3620
+ recheck NF-3).** Every `audit.sampled` the runtime writes pins the attested
3621
+ policy the sampled execution ran under, as `payload.policy_sha256`: the
3622
+ `sha256` named by the latest `policy.updated` attestation before the
3623
+ `execution.started` record, which is the policy the gate checked when it
3624
+ recorded the start (a harness start carries the same hash as its own
3625
+ `policy_sha256`). A policy attested between the run and the sweep therefore
3626
+ changes neither who may review the sample nor whether anyone can. Only an
3627
+ execution older than every attestation in the log pins the latest attestation
3628
+ at sampling instead. A review of that sample reads the
3629
+ roster only from policy bytes that hash to the pin, and only when an
3630
+ attestation before the sample names that hash. The bytes come from the payload
3631
+ store beside the log, where every attestation since APRV-356 keeps the text it
3632
+ attested (PR #614 fix round 3), so editing and re-attesting the policy, a
3633
+ settings save from the app included, leaves older open samples reviewable. The
3634
+ policy file on disk is the fallback, used only when its bytes hash to the pin
3635
+ (a chain last attested before APRV-356 stored none). Bytes in neither place are
3636
+ refused `policy-not-attested`, and the message names the hash it needs. A later
3637
+ re-attestation that renames or drops the class's rule therefore cannot leave
3638
+ the sample with no roster, and one that names a different roster cannot open
3639
+ it to a reviewer the action's policy did not name. A class that matches no rule in the pinned policy
3640
+ (it reached supervised through `defaults.autonomy`) is refused
3641
+ `actor-not-approver` with that reason, because the defaults carry no roster
3642
+ and for a review "no roster" would mean anyone. A sample written before samples
3643
+ pinned a policy has no `policy_sha256` and keeps the latest-attestation reading
3644
+ above.
3645
+
3646
+ Exactly one of `--ok` and `--deny` is required (APRV-482). A bare review, or one
3647
+ carrying only `--reaction`, is refused `verdict-required` (exit 1) and writes
3648
+ nothing: under supervised-retro the review counts as the approval, and an
3649
+ approval is something the reviewer says, never a default. Both flags together
3650
+ is a usage error (exit 2). The verdict is judged after the roster (PR #614
3651
+ refutation N6): a reviewer off the roster is told `actor-not-approver` whether
3652
+ or not they gave a verdict, and a grade with no verdict is not judged for its
3653
+ note until the verdict comes.
3654
+
3081
3655
  `--note` is optional — unlike `execution resolve`, this event records only that a
3082
3656
  person looked, and the runtime is not relying on the note for a fact it does not
3083
3657
  otherwise have. Human-only: a runtime that could mark its own samples reviewed
3084
3658
  would be a supervision backlog that empties itself.
3085
3659
 
3086
- No attestation is required, for the reason `execution resolve` states: review
3087
- records an observation, exercises no policy authority, authorizes nothing, and
3088
- spends no budget. A review blocked because a policy file was edited afterwards
3089
- would be a supervision backlog held open by an unrelated fact.
3090
-
3091
- What it appends is `audit.reviewed`, naming the sample's action key and task, with
3092
- payload `{"subject_seq":<seq of the audit.sampled>,"reviewed":true,"note"?:"...",
3093
- "reaction"?:"disliked"|"indifferent"|"liked"|"loved"}`.
3094
- An action key with several open samples refuses `ambiguous-subject`.
3660
+ Since APRV-483 a review needs the attested policy, because its roster check
3661
+ reads the class's `approvers` from it (below). Before that no attestation was
3662
+ required, for the reason `execution resolve` states: review recorded an
3663
+ observation and exercised no policy authority. Under supervised-retro the
3664
+ review is the approval, and the trade is stated plainly: a policy edited and not
3665
+ yet re-attested now holds the review backlog open until a human re-attests.
3666
+
3667
+ What it appends is `audit.reviewed`, naming the sample's action key and task; its
3668
+ payload is listed field by field under [the review record](#the-review-record),
3669
+ which is the one place those names are defined. An action key with several open
3670
+ samples refuses `ambiguous-subject`.
3671
+
3672
+ ### The review record
3673
+
3674
+ The field names a follower reads (the data pipeline's follower and the dbt
3675
+ models built on it read reviews as approvals under supervised-retro). This
3676
+ table is the single definition; other docs link here rather than restating it.
3677
+ Every `audit.reviewed` written since APRV-481 carries the three required fields,
3678
+ and since PR #614's F5 fix a fourth, `verdict_source`; the write boundary refuses
3679
+ one that does not. The verdict alone cannot say whether the reviewer gave it:
3680
+ before APRV-482 the runtime wrote `ok` for a grade-only tap or a bare
3681
+ `audit review`, and such a record may carry all three APRV-481 fields, so
3682
+ `verdict_source` is the field that separates the two. A review written before then
3683
+ may lack them and still verifies (the read boundary is the schema's
3684
+ `audit_reviewed_record_historical`), so a follower treats their absence on an
3685
+ old record as "not recorded", never as a default.
3686
+
3687
+ | Field | Where | Required | Meaning |
3688
+ | --- | --- | --- | --- |
3689
+ | `event` | record | yes | `audit.reviewed`. |
3690
+ | `seq`, `ts`, `hash` | record | yes | The review's own chain position and runtime-assigned time. |
3691
+ | `actor` | record | yes | `human:<id>`, the reviewer. Since APRV-483 a person on the class's `approvers` roster wherever the rule names one. |
3692
+ | `action_key`, `task` | record | when the sample named them | The reviewed action and its task. |
3693
+ | `channel` | record | no | The surface, when the writer recorded one. |
3694
+ | `payload.subject_seq` | payload | yes (APRV-481) | The `seq` of the `audit.sampled` record this review answers. Not the execution's seq. |
3695
+ | `payload.sampled_subject_hash` | payload | yes (APRV-481) | The `hash` of the `execution.started` record the sample named: the join key from a review to the execution it judged. |
3696
+ | `payload.verdict` | payload | yes (APRV-481) | `ok` or `denied`. The enforcement field. Explicit only on records that carry `verdict_source`. |
3697
+ | `payload.verdict_source` | payload | yes (PR #614 F5) | Always `explicit`: the reviewer said the verdict (`--ok`/`--deny`, a card's OK or second Deny). The discriminator for a follower: a review WITHOUT it predates APRV-482 or was written by an older build, and its `ok` may have been defaulted for a grade-only tap or a bare terminal review. Count only reviews carrying it as explicit approvals. |
3698
+ | `payload.payload_hash` | payload | no | The SHA-256/JCS binding of the bytes the execution ran, present ONLY when the surface showed those bytes whole before the verdict (a Telegram card's `bytes` view). Absent when the reviewer saw a hash, nothing, or a terminal. Checked against the execution's binding before it is written. |
3699
+ | `payload.reaction` | payload | no | `disliked`, `indifferent`, `liked` or `loved`. Guidance, never enforcement; absent is absent. |
3700
+ | `payload.note` | payload | with `loved`/`disliked` | The reviewer's words, verbatim. |
3701
+ | `payload.sender`, `payload.sender_source` | payload | no | The authenticated account the review arrived from, when a transport authenticated one. |
3702
+ | `payload.subject_event`, `payload.reviewed` | payload | no | Constants (`audit.sampled`, `true`) kept for older readers. |
3703
+
3704
+ A denial is followed by a runtime-authored `reconciliation.required` whose
3705
+ `payload.review_seq` is the review's `seq`.
3095
3706
 
3096
3707
  **`--json`** (one object on stdout):
3097
3708
 
@@ -3460,6 +4071,224 @@ turn on was already on the `ChannelRequest`, and `--json`, `approval queue` and
3460
4071
  the web page carried it all along; rendering stays a pure function of
3461
4072
  (request, layout).
3462
4073
 
4074
+ ### The minimal card: `channels.telegram.prompt.style` (APRV-489)
4075
+
4076
+ The technical card is three messages written for an engineer. A resident
4077
+ approving on a phone needs to know what their agent wants to do, the exact
4078
+ words it will act on, how long they have, and where the buttons are.
4079
+ `style: minimal` sends that as ONE message, with the whole technical card
4080
+ collapsed inside it:
4081
+
4082
+ ```yaml
4083
+ channels:
4084
+ telegram:
4085
+ prompt:
4086
+ always: [ttl_remaining_ms]
4087
+ style: minimal # technical (the default) | minimal
4088
+ say:
4089
+ digest.share:
4090
+ does: "share a note about you with other people"
4091
+ quote: { scope: "Shared with", expires_at: "Until", text: "Note", digest_id: "Reference" }
4092
+ note: none
4093
+ ```
4094
+
4095
+ **For the approver, in plain words.** The bold first line says what your agent
4096
+ wants to do. The box under it holds the agent's own words, copied exactly from
4097
+ what will be posted, shared or run; approval.md writes nothing inside the box
4098
+ except the labels. If a line under the box starts with ⚠ or says "Not shown
4099
+ here", the box is not the whole story: open "Full details" before deciding. A
4100
+ line marked "not checked" is a description (by an AI model or by your agent)
4101
+ that nobody verified. The next line says how long you have; if you do nothing,
4102
+ nothing happens. Tap "Full details" to see everything the runtime checked,
4103
+ including the exact text this approval is bound to. Approve says yes; Deny says
4104
+ no. For anything that deletes, sends files or money, or talks to outside
4105
+ services, open "Full details" every time.
4106
+
4107
+ **The exact layout** (HTML; `[ ]` are the inline buttons):
4108
+
4109
+ ```
4110
+ <b>Your agent wants to <phrase></b>
4111
+ <blockquote><b>Label:</b> quoted value one payload value per line, verbatim
4112
+ …</blockquote>
4113
+ ⚠ This runs at least 3 commands. Only the beginning is shown above: … a command, only when cut
4114
+ ⚠ There is more than fits here: open Full details before deciding. when any other value was cut
4115
+ Not shown here: <key>, <key>. Open Full details before deciding. when the say entry leaves a field off (~)
4116
+ <i>AI summary (not checked):</i> … only when a model sentence is attached
4117
+ <i>Your agent says (not checked):</i> … only when the class's say entry has note: summary
4118
+ <i>Your agent estimates the cost (not checked):</i> $0.00 only when the estimate is above zero
4119
+ Open for about 3 days. If you don't answer, your agent will not do this.
4120
+ <blockquote expandable><b>Full details (tap to open)</b>
4121
+ … the technical card's three messages, in order, character for character …</blockquote>
4122
+ [✅ Approve] [✋ Deny]
4123
+ ```
4124
+
4125
+ A tool call the agent is blocked in says "Your agent is waiting: about 4
4126
+ minutes left. If you don't answer, it will not do this." instead. The time is
4127
+ the gate's own window for the request (the policy's TTL, narrowed by a
4128
+ harness hook's cap), from the verified log, as it stood when the card was
4129
+ drawn; the line is not refreshed afterwards. A request with no time left says
4130
+ "Time is up: this request has closed, and an answer now will not count."
4131
+
4132
+ **What is computed, what is quoted, what is described.**
4133
+
4134
+ - *Computed by the runtime:* the first line, chosen by the exact class the LOG
4135
+ records: core's own phrase for a class core emits (`network.call`,
4136
+ `read.web`, `browser.exec`, `cron.manage`, `process.write`, `skill.manage`,
4137
+ `agent.delegate`, `message.send`, `files.delete.scratch`); else, for a
4138
+ payload the runtime reads itself, its structural kind ("run a command",
4139
+ "change a file", "send an email", followed by `(type: <class>)`), whatever
4140
+ the class's `say` entry says; else, for an opaque payload only, the attested
4141
+ `say.<class>.does`. The notices under the box; the deadline line; everything
4142
+ in "Full details"; the buttons.
4143
+ - *Quoted from the bound payload:* the box, and only payload values. Each line
4144
+ is one payload value, verbatim (marked as below, and visibly cut when long),
4145
+ under a label (the operator's attested label, or the runtime's fixed label
4146
+ for a field of a shape it knows), or alone when it is the only quotation.
4147
+ Nothing computed, abbreviated, joined or paraphrased is ever inside the box;
4148
+ a list of addresses is one line per address. Each value sits inside the
4149
+ runtime's own Unicode isolate (U+2068 … U+2069), so right-to-left letters in
4150
+ it cannot reorder its label or the lines around it. The cut mark is drawn
4151
+ inside the value's isolate, so a cut right-to-left value shows it at its left
4152
+ edge, still visible; the notice under the box says the same thing outside
4153
+ any isolate. The operator's `does` phrase in the headline is not isolated.
4154
+ The bytes were hash-checked against the request's
4155
+ `payload_hash` before the channel saw them, so they are what will be acted
4156
+ on; their content was written by the agent. A command is shown up to 160
4157
+ characters (under `Command:` when its `cwd` is quoted as `In folder:`); a
4158
+ longer one is shown from its start and cut, and only then does a computed
4159
+ line under the box say so ("⚠ This runs at least 3 commands. Only the
4160
+ beginning is shown above", the classifier's count as a lower bound, since it
4161
+ does not see commands started inside other commands such as a
4162
+ here-document piped into `sh`, `$( … )`, `bash -c` or `eval`; "⚠ Only the
4163
+ beginning of this command is shown above" when it counts one; "⚠ More than
4164
+ one command may be here, and only the beginning is shown above" when it
4165
+ cannot count). A command shown whole gets no line: the
4166
+ card makes no claim about how many commands it is. The line never names or
4167
+ paraphrases a command the box does not show; the
4168
+ classifier's outline is in "Full details" only, because it leaves out flags
4169
+ and their values, which is where a deletion target or an uploaded file
4170
+ lives. A file change shows the file, the change and every other field under
4171
+ its own key name (`tool:`, `replace_all: true`). An email shows From, To, Cc,
4172
+ Bcc, Subject, Format and Message. An opaque payload shows the fields its
4173
+ `say.<class>.quote` map labels.
4174
+ - *Never a clean-looking partial:* when a command is cut, its computed count
4175
+ line says so; when any other value is cut, "⚠ There is more than fits here:
4176
+ open Full details before deciding." follows the box; when the declaration leaves fields off (`~`),
4177
+ "Not shown here: <their key names>. Open Full details before deciding." does.
4178
+ Both are outside the box, so quoted text can neither produce nor suppress
4179
+ them.
4180
+ - *Described, and labelled "not checked":* the AI summary and the agent's own
4181
+ summary and estimate. They never stand alone and never come first: they sit
4182
+ below the box.
4183
+
4184
+ **Quoted text is hostile input.** Every quoted value is one line (a line break
4185
+ is drawn ` ⏎ `). Control, format, bidirectional and every default-ignorable
4186
+ character (variation selectors, joiners, tag characters, U+034F, the Khmer and
4187
+ Mongolian invisibles), the Hangul fillers and the braille blank, and combining
4188
+ marks beyond two on one character are drawn as `«U+202E»`; so are `«` and `⏎`
4189
+ in the value, which makes the marking injective. One exception keeps emoji
4190
+ readable: a grapheme cluster that is WHOLLY a recognised emoji sequence
4191
+ (Unicode's RGI set, `\p{RGI_Emoji}` with the regular-expression `v` flag: ❤️,
4192
+ 👨‍👩‍👧, 1️⃣, 👍🏽) is drawn as itself. Every other presentation selector or joiner
4193
+ is marked, so a U+FE0F after a face that is already an emoji, or a U+200D
4194
+ between two pictographs that form no emoji, cannot carry hidden bits. On a
4195
+ runtime without that property nothing is exempt. Node 20, this package's
4196
+ floor, ships V8 11.3, which has it. A value longer than 280 characters is
4197
+ cut between grapheme clusters with `…(cut; see full details)`, and everything is
4198
+ HTML-escaped. A value cannot start a line of its own, close the box, open or
4199
+ close the collapsed block, or be bold.
4200
+
4201
+ Injective is not "never looks the same". Look-alike letters from other scripts
4202
+ (a Cyrillic а for a Latin a), a lone emoji with and without its presentation
4203
+ selector, and runs of ordinary spaces are drawn as themselves, so two different
4204
+ values can still look alike. Telegram clients also turn URLs, `@names`,
4205
+ `#tags` and `/commands` in the box and in "Full details" into links; a link's
4206
+ text is always its own target (markup in a value is escaped, so a value cannot
4207
+ make link text say something else), but a look-alike domain stays look-alike.
4208
+
4209
+ **When the technical card is sent instead.** The minimal card is never guessed
4210
+ at or drawn partially. These requests get today's technical card, the decision
4211
+ record says why (`payload.rendering.fallback`, below), and the listener says
4212
+ so on stderr:
4213
+
4214
+ - an attestation prompt, a `policy.*` or `log.*` class, or a protected path;
4215
+ - a truncated payload, or none;
4216
+ - any abnormal health fact (a budget over its ceiling, a policy not attested,
4217
+ an autonomy that is not `manual`), even when the row layout hides its row;
4218
+ - an opaque payload whose class has no `say` entry, carrying a key the entry's
4219
+ `quote` map does not name, or that is not an object;
4220
+ - a request delivered as part of a batch: a digest, its members, the collapsed
4221
+ stale-request summary, and a batch whose digest did not fit or has one member;
4222
+ - a card over 3800 characters (Telegram's limit is 4096, and a relay's
4223
+ `Agent: <name>` line needs room): the canonical rendering is never cut to fit;
4224
+ - a policy file that does not match its attestation (`policy-unattested`);
4225
+ - a minimal card the Bot API or a relay refuses (any 4xx but 429): the
4226
+ technical card is sent once instead (`send-refused`); a 429, a 5xx or a
4227
+ network failure is retried by the next cycle as before.
4228
+
4229
+ When the edit that settles a minimal card is refused (it replaces the only
4230
+ message holding the request), a short settle text with the outcome, the
4231
+ request's key and who decided when is sent instead, so a decided card never
4232
+ looks undecided.
4233
+
4234
+ Review cards ("REVIEW — THIS ALREADY RAN"), their note prompt and checkpoint
4235
+ prompts are the same under both styles in this release.
4236
+
4237
+ **`say`.** Keyed by EXACT class name: a pattern such as `files.*` is refused at
4238
+ load (`prompt-say-wildcard`), because one friendly phrase must never stand for
4239
+ several classes. For a class core phrases itself (the list above) core's phrase
4240
+ always wins: a `say` entry may set `quote` and `note` for it but not `does`
4241
+ (`prompt-say-builtin`). The same holds for a class the command classifier
4242
+ emits (`files.delete.out_of_scope`, `vcs.push.main`, `harness.launch.*`, …),
4243
+ whose payload is a command or a file change the runtime phrases itself
4244
+ (`prompt-say-kind`). For any other class `does` is a phrase for OPAQUE
4245
+ payloads only: when a request of that class carries a command, a file change
4246
+ or an email, the runtime's kind phrase is drawn instead and the record notes
4247
+ `say_does_ignored` (below). For those classes `does` is required and is a verb
4248
+ phrase of at most 120 characters that completes "Your agent wants to …": it
4249
+ starts with a letter and has no line break, `.` `!` `?` `…` `:` `;`, markup or
4250
+ `⚠` (`prompt-say-does`), so operator text cannot read as a second sentence or
4251
+ as one of the runtime's notices. `quote` maps every top-level key an opaque
4252
+ payload may carry to the label it is quoted under (letters, digits and spaces,
4253
+ at most 24; `""` only when it is the only key shown) or to `~` for a key
4254
+ deliberately left off, and must show at least one key (`prompt-say-label`,
4255
+ `prompt-say-shape`); `note` is `summary` (the default) or `none`. A `say` entry is read only under `style: minimal`. A class
4256
+ core phrases itself still needs a `say` entry with a `quote` map when its
4257
+ payload is opaque (a Hermes tool payload, `{tool, input}`, is). A `say` entry
4258
+ for a class no rule names loads: it applies if that class is ever requested.
4259
+
4260
+ **Other channels.** `style` and `say` are validated wherever they appear and an
4261
+ unknown value fails the policy closed (`prompt-style-unknown`,
4262
+ `prompt-say-shape`, `prompt-say-wildcard` on an untyped channel; the schema's
4263
+ own `enum`, `pattern` and shape errors on `telegram`, `web` and `cli`). Only
4264
+ Telegram draws a minimal card; the CLI and web channels IGNORE the keys and keep
4265
+ their full prompt, which shows more than was asked for rather than less.
4266
+
4267
+ **What the record says.** A decision taken on a card drawn under a minimal
4268
+ policy carries `payload.rendering` on its `approval.granted` or
4269
+ `approval.rejected`: `{"style": "minimal"}`, or `{"style": "technical",
4270
+ "fallback": "<reason>"}` when the technical card was sent instead. A minimal
4271
+ card whose class's `say` entry set a `does` phrase the runtime did not use
4272
+ (the payload was a command, a file change or an email, so the runtime drew its
4273
+ own phrase) records `{"style": "minimal", "say_does_ignored": true}`. Under a
4274
+ technical policy the key is absent and the record is unchanged. Nothing reads
4275
+ it back; it is a statement about the screen the approver answered on. It is an
4276
+ OPEN field: `schema/event.schema.json` does not constrain it in this release,
4277
+ so a reader with a closed schema for `approval.granted` must admit it.
4278
+
4279
+ **Changing it.** It is a policy key, so changing it is an attestation, and a
4280
+ decision on a request routed under the old policy is refused `policy-drift`, as
4281
+ for any policy change. The Telegram listener resolves `style` and `say` for EACH
4282
+ card it draws, and only from attested bytes: it reads the policy file once,
4283
+ requires those bytes to hash to the latest attestation in the verified log
4284
+ (the digest the gate decides under), and takes the settings from the same
4285
+ bytes. A re-attested setting therefore reaches the next card without a
4286
+ restart, and an edit to the file that nobody attested changes nothing: the card
4287
+ is technical, and when the attested policy asks for minimal the record says
4288
+ `fallback: "policy-unattested"`. An older core refuses the keys at the schema, and the
4289
+ whole policy then fails closed to all-`manual`: roll the core out before the
4290
+ policy that uses them.
4291
+
3463
4292
  ## channel cli
3464
4293
 
3465
4294
  **The rendering convention (SPEC.md §9).** Every displayed field carries a
@@ -3669,6 +4498,16 @@ This starts the standalone Telegram component. For normal operation, use
3669
4498
  `up` or another listener polling the same bot, even for a different policy
3670
4499
  project: competing `getUpdates` calls produce Telegram HTTP 409.
3671
4500
 
4501
+ A bot whose updates go to a webhook cannot be long-polled at all, so this verb
4502
+ and `approval up` refuse `webhook-registered` in the same preflight that asks
4503
+ `getMe`, naming the origin that holds it. That preflight also takes this gate's
4504
+ transport lease (`.approval/daemon/telegram-transport.lock`), so a second
4505
+ listener or a webhook runner started in the same project refuses
4506
+ `telegram-poller-running` naming the pid that holds it, rather than both
4507
+ processes putting every request on the phone under their own nonce. See
4508
+ [`channel telegram webhook`](#channel-telegram-webhook), which is the other
4509
+ transport and removes both its registration and its lease when it exits.
4510
+
3672
4511
  **Delivery is per cycle, not only at startup.** Before every `getUpdates` the
3673
4512
  listener re-derives the pending queue from the verified log and sends whatever
3674
4513
  it has not already sent, so a request appended while this listener is running
@@ -3766,6 +4605,34 @@ place in the walkthrough and a duplicate message, never a pending request nobody
3766
4605
  is shown; an approval that depended on a channel's memory would not be an
3767
4606
  acceptable trade.
3768
4607
 
4608
+ **Earlier copies, and `--no-stale-copy`** (APRV-196, APRV-456). Every single-request decision
4609
+ button carries `<verb>:<nonce>:<action ref>` (a digest's "all" buttons carry no reference and never had a fallback), where the nonce is issued by this
4610
+ process and the reference is the first 16 hex of the action key's SHA-256. On
4611
+ the direct-bot shape (no `--api-base`, or one naming `https://api.telegram.org`)
4612
+ a tap whose nonce this process is not holding, such as a button on a copy sent
4613
+ before a restart, falls back to its reference: when this process holds that
4614
+ action open on a newer card, the tap decides it there, and the gate judges it
4615
+ exactly as it judges any other tap. That fallback is safe because only the bot
4616
+ token can put a button in front of the approver.
4617
+
4618
+ The fallback is **off** when `--api-base` names anything other than the Bot API
4619
+ (a relay, or a self-hosted Bot API server, which this rule treats as a relay)
4620
+ and when `--no-stale-copy` is passed. Then a decision tap whose nonce this
4621
+ process is not holding is refused `nonce-not-issued` (a member of the channel
4622
+ decision refusal union, beside `sender-unmapped`): it never reaches the gate,
4623
+ the approver's toast says this listener did not send that button, and the live
4624
+ card stays armed. When the tap's reference names a request this process holds
4625
+ open, one `audit.decision_refused` records the attempt under that request's key,
4626
+ naming the sender the policy resolves; a reference that names nothing open here,
4627
+ or a replay of the same button within this process, records nothing (the memory of refused buttons is per process and bounded, so a redelivery after a restart can record once more). The restart banner says that
4628
+ earlier copies no longer decide. The reason is the relay: anyone holding its
4629
+ token can send the approver a card with forged text and a real pending action's
4630
+ reference, and on the fallback the approver's genuine tap would pass the chat
4631
+ check and the sender mapping. The flag is launch configuration only: no policy
4632
+ key and nothing in an update can set it or clear it. `approval up` and
4633
+ [`channel telegram webhook`](#channel-telegram-webhook) take the same flag and
4634
+ apply the same rule.
4635
+
3769
4636
  **The execution token is printed on this terminal's stdout and is never sent to
3770
4637
  Telegram.** A chat transcript is stored on someone else's servers, backed up to
3771
4638
  phones, and readable by anyone later added to the chat — it is not a credential
@@ -3809,16 +4676,61 @@ Each `audit.sampled` with no later `audit.reviewed` now arrives as a **review
3809
4676
  card**.
3810
4677
 
3811
4678
  A card is not a prompt and says so. Its headline is `REVIEW — THIS ALREADY RAN`,
3812
- it carries no payload region and no approve button, and it accepts no token: the
3813
- action has happened, and a card offering an approve would present a settled fact
3814
- as a live authorization. What it carries is the same rows a prompt would show
3815
- for the same action — class, the command breakdown, task, the agent's claimed
3816
- summary, the model gloss where one is attached — plus two the card adds: `ran
3817
- at` (the `execution.started` the sample named) and `verdict` (that the runtime
3818
- allowed this without asking, which autonomy said so, the rate it was drawn at,
3819
- and how the log says it ended). Everything computed is derived from the verified
3820
- log, the payload store and the classifier; the claimed rows sit under the same
3821
- "NOT verified by the runtime" heading a prompt gives them.
4679
+ it carries no approve button, and it accepts no token: the action has happened,
4680
+ and a card offering an approve would present a settled fact as a live
4681
+ authorization. What it carries is the same rows a prompt would show for the same
4682
+ action — class, the command breakdown, task, the agent's claimed summary, the
4683
+ model gloss where one is attached — plus two the card adds: `ran at` (the
4684
+ `execution.started` the sample named) and `verdict` (that the runtime allowed
4685
+ this without asking, which autonomy said so, the rate it was drawn at, and how
4686
+ the log says it ended). Everything computed is derived from the verified log,
4687
+ the payload store and the classifier; the claimed rows sit under the same "NOT
4688
+ verified by the runtime" heading a prompt gives them.
4689
+
4690
+ **The card shows the payload that ran (APRV-480).** Under supervised-retro the
4691
+ review is the individual approval nobody gave before the action executed, so the
4692
+ reviewer reads the published bytes rather than a key and the agent's own summary
4693
+ of what it did. Between the computed and the claimed rows the card carries one
4694
+ of three payload regions, and its heading says which:
4695
+
4696
+ | Heading | What the reviewer sees | When |
4697
+ | --- | --- | --- |
4698
+ | `PAYLOAD — the bytes that ran, shown whole; this review covers them` | the canonical rendering of the bytes, whole, in a `<pre>` block | the runtime holds bytes that hash to the execution's binding and they fit one card |
4699
+ | `PAYLOAD — NOT SHOWN, hash only; this review does not cover the bytes` | the binding's `sha256`, and why the bytes are absent | nobody holds the bytes, the bytes held do not hash to the binding, or they are longer than this card's payload budget (at most 2000 escaped characters, less when the card's rows leave less room) |
4700
+ | `PAYLOAD — none recorded; the execution bound to no payload hash` | nothing to show and nothing to name | the execution and its registration recorded no `payload_hash` |
4701
+
4702
+ The bytes are shown whole or not at all: a card is one message edited in place,
4703
+ so it cannot spill a payload over several messages the way a request prompt
4704
+ does, and a review over half the bytes would claim more than the reviewer read.
4705
+ Only the first case lets the review record carry a `payload_hash` (see
4706
+ [the review record](#the-review-record)). A terminal review (`approval audit
4707
+ review`) shows no payload and so never records one.
4708
+
4709
+ **The whole card fits one message (PR #614 refutation F4).** The agent's summary
4710
+ is cut at 400 characters and every other row at 300, each with a marker saying
4711
+ how many characters are not shown (the registration record keeps the text
4712
+ whole); a card whose rows are pathologically long cuts them further, in steps,
4713
+ until it fits. A notice shows at most 600 characters, the rest on the
4714
+ listener's stderr. The payload budget is what the card's own rows leave under
4715
+ Telegram's 4096-character limit after the longest heading and the largest
4716
+ notice a tap can add, capped at 2000, and it is computed from the card alone,
4717
+ so the card a reviewer saw and the view a tap records cannot disagree.
4718
+
4719
+ **A card that fails to send (PR #614 recheck NF-2).** When Telegram refuses the
4720
+ card itself (HTTP 400 `message is too long`, `can't parse entities`,
4721
+ `message text is empty` or `text must be non-empty`, `reply markup is too long`,
4722
+ `BUTTON_DATA_INVALID`, `ENTITIES_TOO_LONG`), resending it can only fail again:
4723
+ the listener prints a coded `approval: telegram review-offer-failed:` line
4724
+ naming `approval audit review <seq> --ok` (or `--deny`), leaves that sample for
4725
+ a terminal review, and offers the next one. Every other failure (a 429, a
4726
+ timeout, a 5xx, a network error) prints `approval: telegram review-offer-retry:`
4727
+ and pauses review cards for Telegram's `retry_after` or the backoff, whichever
4728
+ is longer (one minute, doubling per attempt; never more than an hour, whatever
4729
+ `retry_after` says), then offers the same sample again.
4730
+ After five failed attempts the sample is left for a terminal review with the
4731
+ `review-offer-failed` line. Either way the sample stays open in
4732
+ `approval audit list` and QUEUE.md, the queue behind it no longer waits, and a
4733
+ restarted listener offers it afresh.
3822
4734
 
3823
4735
  Six buttons, bare emoji and no words (APRV-302), in two rows: the verdict on the
3824
4736
  first (✅ OK, 🛑 Deny) and the grade on the second, worst to best (👎 disliked,
@@ -3826,12 +4738,39 @@ first (✅ OK, 🛑 Deny) and the grade on the second, worst to best (👎 disli
3826
4738
 
3827
4739
  | Tap | What is recorded |
3828
4740
  | --- | --- |
3829
- | ✅ | `audit.reviewed` with verdict `ok` and no reaction. |
3830
- | a reaction | verdict `ok` and that grade — a reaction alone implies OK. |
3831
- | 🛑 once | **Nothing.** It arms the card, which says `DENY ARMED` on itself. |
3832
- | 🛑 twice | verdict `denied`, and the reconciliation obligation it opens is named on the reply. |
4741
+ | ✅ | `audit.reviewed` with verdict `ok`, and the held grade if there is one. |
4742
+ | a reaction, nothing armed | **Nothing.** Refused `verdict-required` (APRV-482): a grade is not a verdict. The card holds the grade and says `GRADE … HELD` in its heading. |
4743
+ | ✅ after a reaction | verdict `ok` with the held grade (a `loved` or `disliked` asks for words first). |
4744
+ | 🛑 once | **Nothing.** The tap reaches the runtime with no verdict, and the card arms (it says `DENY ARMED` on itself) only when the answer is `verdict-required`, which the runtime gives only to a mapped sender on the class's roster. Any other refusal is shown on the card and arms nothing. |
4745
+ | 🛑 twice | verdict `denied` with the held grade if any, and the reconciliation obligation it opens is named on the reply. |
3833
4746
  | a reaction with deny armed | verdict `denied` with that grade. |
3834
4747
 
4748
+ **A held grade and an armed Deny belong to the account that tapped them (PR #614
4749
+ refutation F3).** The card remembers the transport's sender id with each. A tap
4750
+ that would finish another account's half-finished review records nothing and is
4751
+ refused on the card with one line: any ✅ or 🛑 while another account holds the
4752
+ grade or armed Deny, and any reaction while another account armed Deny. A
4753
+ reaction tapped with nothing armed only replaces the held grade, and its tapper
4754
+ then holds it. A record therefore never carries a grade or a denial its reviewer
4755
+ did not tap. Only an account that may review can leave either behind (PR #614
4756
+ recheck NF-1): a grade is held, and a Deny arms, only after the runtime has
4757
+ passed the sender and the roster and answered `verdict-required`, so an
4758
+ unmapped or off-roster account in the approver chat cannot block the
4759
+ approvers' taps.
4760
+
4761
+ **A verdict is an explicit act (APRV-482).** Under supervised-retro a review
4762
+ counts as the individual approval nobody gave before the action ran, so nothing
4763
+ records `ok` on a reviewer's behalf. The explicit forms per surface:
4764
+
4765
+ | Surface | `ok` | `denied` | Refused `verdict-required` |
4766
+ | --- | --- | --- | --- |
4767
+ | Telegram card | ✅ (alone, or after a grade) | 🛑 twice, or 🛑 then a grade | a grade tapped with nothing armed |
4768
+ | `approval audit review` | `--ok` | `--deny` | neither flag, with or without `--reaction`; both flags is a usage error (exit 2) |
4769
+
4770
+ The refusal on the card is the runtime's own: the tap reaches `reviewSample`
4771
+ with no verdict, and the code and its message come back exactly as a terminal
4772
+ sees them.
4773
+
3835
4774
  The card does not print this table under itself. It used to, and the paragraph
3836
4775
  of rules pushed the rows a review is actually about off the first screen for a
3837
4776
  reader who had read them on the card before. The two things a tap could get
@@ -3864,7 +4803,10 @@ reviewer has to be able to say which half they meant.
3864
4803
 
3865
4804
  Every append goes through the same `reviewSample` that `approval audit review`
3866
4805
  calls, recorded against the human identity this listener was configured with
3867
- (`--as` / `APPROVAL_HUMAN`), never anything the callback carried. So `approval
4806
+ (`--as` / `APPROVAL_HUMAN`), never anything the callback carried. That identity,
4807
+ or the person a sender mapping resolves a tap to, must be on the class's
4808
+ `approvers` roster where the rule names one, or the card shows
4809
+ `actor-not-approver` and keeps its buttons (APRV-483). So `approval
3868
4810
  feedback` shows a reaction given on a card exactly as one given at a terminal:
3869
4811
  same record, same `human:<id>`, same everything.
3870
4812
 
@@ -3978,6 +4920,256 @@ rather than a query:
3978
4920
 
3979
4921
  The raw execution token is never in the JSON stream.
3980
4922
 
4923
+ ## channel telegram webhook
4924
+
4925
+ The same channel, the other arrival (APRV-424). `listen` holds a socket open
4926
+ against `getUpdates` for as long as the gate exists, which needs one
4927
+ always-running process per bot and forbids a second poller on the same token.
4928
+ This registers a URL with `setWebhook` and serves the callback, so Telegram
4929
+ posts each update as it happens and a host that sleeps between requests can run
4930
+ a gate.
4931
+
4932
+ **Only the arrival changes, and that is the whole claim.** The pending queue is
4933
+ re-derived from the verified log every cycle by the same `dispatchPending`. The
4934
+ handlers are the same function objects `listen` registers (since APRV-456 that
4935
+ includes the recorder for a `nonce-not-issued` refusal, and `--no-stale-copy`
4936
+ or a relayed `--api-base` turns the earlier-copy fallback off here exactly as it
4937
+ does there). A tap becomes a
4938
+ decision through the same `handleUpdate`, the same `callback_query.from.id`
4939
+ reading, the same `approvers.<id>.senders` resolution, the same
4940
+ `recordChannelDecision` and the same annotate-after-decision edit. There is no
4941
+ second decision path and no second copy of the sender mapping:
4942
+ `tests/telegram-webhook.test.ts` drives one callback through both transports and
4943
+ compares the records the log ends up holding, field for field.
4944
+
4945
+ ### The secret, which is the whole of the authentication
4946
+
4947
+ `APPROVAL_TG_WEBHOOK_SECRET` is REQUIRED and is read from the launch
4948
+ environment, never from a file in the tree (SPEC.md §11.1 invariant 7, the rule
4949
+ `approval serve`'s two credentials follow). It is Telegram's own `secret_token`:
4950
+ set on `setWebhook`, echoed on every delivery in the
4951
+ `X-Telegram-Bot-Api-Secret-Token` header, compared here in constant time over
4952
+ SHA-256 digests.
4953
+
4954
+ A webhook URL is a public endpoint, and nothing in a request body could tell a
4955
+ delivery from a forgery — a self-reported field never reduces scrutiny (§11.1
4956
+ invariant 4). So the header is checked FIRST, before the path, the method and
4957
+ the body, and a post without the matching value is refused with its own code,
4958
+ counted, and reported on stderr. **A refusal is never a decision, and it appends
4959
+ nothing to the log.** That is the rule `TELEGRAM_ANOMALY_KINDS` states for
4960
+ ignored callbacks, and it binds harder here: an endpoint the internet can reach
4961
+ that could append would be an endpoint anyone could use to pad the record a
4962
+ human is asked to trust.
4963
+
4964
+ The value is refused at startup when it is unset, shorter than 24 characters, or
4965
+ holds a character outside Telegram's `A-Z a-z 0-9 _ -`. It appears in no policy,
4966
+ no log, no record, no message and no error line: the channel redacts it from
4967
+ everything that leaves it, exactly as it redacts the bot token.
4968
+
4969
+ | refusal | what happened |
4970
+ |---|---|
4971
+ | `webhook-secret-mismatch` | no `X-Telegram-Bot-Api-Secret-Token`, or not the registered value |
4972
+ | `webhook-duplicate-secret-header` | more than one of that header; malformed, and not resolvable by choosing |
4973
+ | `webhook-malformed-request` | the request line and `Host` header do not form a URL (400) |
4974
+ | `webhook-unknown-path` | a path this process does not serve (404) |
4975
+ | `webhook-method-not-allowed` | the right path, the wrong method; Telegram POSTs |
4976
+ | `webhook-body-too-large` | over 256 KiB; drained before the refusal is written |
4977
+ | `webhook-body-unreadable` | the body could not be read off the socket, or is not JSON |
4978
+ | `webhook-not-an-update` | JSON that is not an Update object |
4979
+ | `webhook-transport-conflict` | this channel is already receiving updates by long poll |
4980
+ | `webhook-handler-failed` | the channel threw; the log is what says what was recorded |
4981
+
4982
+ Startup refuses in its own vocabulary before anything binds:
4983
+ `webhook-secret-missing`, `webhook-secret-weak`, `webhook-secret-charset`,
4984
+ `webhook-url-missing`, `webhook-url-insecure`, `webhook-url-port`,
4985
+ `webhook-url-userinfo`, `webhook-path-invalid`, `webhook-path-mismatch`,
4986
+ `webhook-cycle`, and `webhook-registration-failed` for a `setWebhook` the Bot
4987
+ API turned down (its status and its own description, scrubbed of the token and
4988
+ the secret), plus every `channel telegram listen` refusal, which this verb
4989
+ inherits because it needs the same bot token, chat, identity, log and policy.
4990
+
4991
+ Four of those are the review's, and each one closes a start that used to
4992
+ succeed and then not work:
4993
+
4994
+ - **`--path` must be the url's own path.** `telegram/webhook` with no leading
4995
+ slash, or `/hook/` against a `--url` ending `/hook`, registered cleanly and
4996
+ then answered every real delivery 404 after verifying its secret. `--path` is
4997
+ an explicit restatement of the url's path and nothing else; rewriting belongs
4998
+ in the proxy in front of this process.
4999
+ - **`--url` may not carry userinfo.** `https://user:pw@host/hook` is a
5000
+ credential on a command line. It is refused, never stripped.
5001
+ - **The bind port may not be 0**, and must be inside 1..65535. Port 0 asks the
5002
+ kernel for an ephemeral port, and this transport is a fixed public url
5003
+ forwarded to a fixed local address: a receiver on a port nobody registered
5004
+ verifies nothing and answers nothing.
5005
+ - **Every line that prints the url prints its origin and a redacted path**, in
5006
+ the start-up banner, in `webhook_started` and in refusals: with two segments
5007
+ or more the first is kept and the rest becomes `<path redacted>`, a path of
5008
+ exactly one segment is replaced whole (that is the shape a tunnel's own token
5009
+ takes), and a query string becomes `<query redacted>`. A tunnel that hands out
5010
+ `https://host/hook/<random>` puts a bearer value in a path, and this runtime
5011
+ does not copy it into three more places. The local bind line is redacted the
5012
+ same way, because it carries the same path. The operator has the whole of it
5013
+ already: they typed it into `--url`.
5014
+
5015
+ ### The proxy or tunnel is required, and this process holds no certificate
5016
+
5017
+ Telegram delivers to HTTPS only, on port 443, 80, 88 or 8443. This process
5018
+ terminates no TLS and holds no certificate, exactly as `approval serve` does
5019
+ not: `--url` names the PUBLIC address your proxy or tunnel answers on, and
5020
+ `--port` / `--listen` names this process's own bind, which is `127.0.0.1:4683`
5021
+ unless you say otherwise. A routable bind takes both `--listen <host:port>` and
5022
+ `--allow-non-loopback` and prints a banner, because the secret arrives in a
5023
+ header and a cleartext hop hands it to whoever is on it.
5024
+
5025
+ So a working deployment is a reverse proxy (nginx, Caddy, a cloud load balancer)
5026
+ or a tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel) terminating TLS on a
5027
+ name you control and forwarding to the loopback bind. Without one there is
5028
+ nothing to register: a plain-HTTP `--url`, or one on another port, is refused
5029
+ before any call is made rather than attempted and rejected by the Bot API.
5030
+
5031
+ ### One transport per bot, refused on both sides
5032
+
5033
+ `getUpdates` is refused by the Bot API outright while a webhook is set, so the
5034
+ two are alternatives and never a pair. Four checks say so before anything runs,
5035
+ and they are in that order because the first needs no network:
5036
+
5037
+ - **A lease per gate.** `approval up`, `approval channel telegram listen` and
5038
+ this verb take a lockfile in the gate's own derived state directory,
5039
+ `.approval/daemon/telegram-transport.lock`, holding the holder's pid, which
5040
+ transport it is running and when it started. It is created with `O_EXCL`, so
5041
+ two processes racing cannot both believe they took it, and a holder that is
5042
+ no longer running is reclaimed automatically (a crash must not lock a gate
5043
+ out of its own channel). A second taker refuses `telegram-poller-running`
5044
+ when a poller holds it and `webhook-registered` when a webhook does, naming
5045
+ the pid and the mode; a directory the lease cannot be written in refuses
5046
+ `telegram-lease-unavailable`, because this file is the only thing keeping one
5047
+ gate from running two transports. Both transports release it on a clean stop.
5048
+
5049
+ **A holder is a process, not a number.** Pids are reused, so the probe asks
5050
+ three questions: does a process with that number exist, can this process
5051
+ signal it, and did it start before the lease was written. A number that is
5052
+ gone, one owned by a process this one cannot signal, and one belonging to a
5053
+ process that started *after* the lease are all reclaimed, and the line says
5054
+ which of the three it was. Only a process that is running, signalable and
5055
+ older than its own lease keeps the gate. Where the platform will not report a
5056
+ start time, a running pid keeps the gate, which is the stricter reading.
5057
+
5058
+ **Taking a lease over is a critical section, and the section is owned.**
5059
+ Reclaiming happens inside a sibling `O_EXCL` lock that carries the entering
5060
+ process's pid and a nonce. Inside it the lease record must still be exactly
5061
+ the one that was judged (same pid, same `started_at`), the lock must still
5062
+ name this entry at the moment of the write, and the lock is removed on the
5063
+ way out only while it still does. Without the first, two processes that had
5064
+ both read one dead holder took turns deleting each other's live lease;
5065
+ without the other two, a process that stalled inside the section renamed its
5066
+ lease over the one that replaced it and deleted the new holder's lock. The
5067
+ replacement is a rename, so the lockfile is never absent and never half
5068
+ written. A reclaim lock is evicted only when its own process is gone, judged
5069
+ by the probe above: its age is reported and decides nothing, because "five
5070
+ seconds have passed" says nothing about whether anybody is still inside. A
5071
+ lock this build cannot read names no process, so the take refuses and names
5072
+ the file rather than forcing it.
5073
+
5074
+ This is the check the other three cannot make. Two processes started in the
5075
+ SAME project — one `approval up` long-polling, one `approval channel telegram
5076
+ webhook` — passed all of them: `getWebhookInfo` is empty while the poller is
5077
+ the one running, and the ownership registry compares instance ids, which are
5078
+ equal because it is the same instance. Each then ran its own dispatch cycle
5079
+ over its own state against one log, so every request reached the phone twice
5080
+ under two nonces and only one copy could resolve a tap.
5081
+ - **What the Bot API says.** `listen` and `approval up` ask `getWebhookInfo` in
5082
+ the same preflight that asks `getMe`, and refuse `webhook-registered` naming
5083
+ the origin that holds the bot. For a poller an unreachable Bot API is not a
5084
+ refusal (a captive portal says nothing about which transport owns a bot) and
5085
+ the HTTP 409 path is the backstop. For THIS verb it is: a probe it could not
5086
+ make refuses `webhook-probe-failed`, because the next call would be a
5087
+ `setWebhook` that overwrites a registration this process never saw.
5088
+ - **Any existing registration, including the same url, refuses.** Telegram
5089
+ allows one webhook per bot and keeps the last registration, so a second host
5090
+ registering takes the taps: it overwrites the first host's `secret_token`, and
5091
+ from then on every real tap arrives at the first host with a secret it does
5092
+ not recognise, indistinguishable from a forgery. `--reclaim` is how an
5093
+ operator takes the bot deliberately; with it, a url that matches the
5094
+ registered one (host lowercased, trailing slash dropped) is reported as a
5095
+ re-registration and anything else as a takeover that stops the other receiver
5096
+ being posted to.
5097
+
5098
+ **One case needs no flag: this gate's own runner was killed.** A `SIGKILL`
5099
+ leaves the registration in place, because the dead process never reached
5100
+ `deleteWebhook`, and a restart that refused every time would teach operators
5101
+ to put `--reclaim` in the unit file, which retires the protection above for
5102
+ good in exchange for a crash recovery. So a restart may re-register the SAME
5103
+ normalised url without the flag when the lease it just reclaimed was this
5104
+ gate's own, written by a webhook runner, and that process is **gone**. A dead
5105
+ poller's lease does not count, a different url does not count, and a gate
5106
+ with no such lease does not count. Nor do the other two reclaim reasons: a
5107
+ lease reclaimed because its pid is owned by a process this one cannot signal,
5108
+ or because the number has been reused, is the right call for the lease and no
5109
+ evidence at all that the runner which registered the url has stopped. Those
5110
+ keep the refusal and the `--reclaim` demand.
5111
+ - Within one process, `TelegramChannel.claimTransport` refuses the second claim.
5112
+
5113
+ A clean stop (SIGINT, SIGTERM) removes the webhook, so long polling works again
5114
+ afterwards. Pending updates are kept in both directions: a tap that arrived
5115
+ while the process was down is the approver's answer, and the gate is what
5116
+ decides whether it is still honourable.
5117
+
5118
+ ### Redelivery, and the cycle
5119
+
5120
+ Telegram retries a delivery it did not get a 2xx for, so the same tap can arrive
5121
+ twice. Nothing deduplicates it: the second one reaches the gate through the same
5122
+ path as the first and is refused `already-decided`, with the first human answer
5123
+ standing — the property a button pressed twice has had since the channel
5124
+ shipped. Updates are handled one at a time, in arrival order, and the response
5125
+ is written after the update has been handled.
5126
+
5127
+ `--cycle <duration>` (default 30s) is the dispatch period, the webhook's
5128
+ equivalent of the poll loop's per-cycle dispatch: it re-derives the pending
5129
+ queue and sends what has not been sent. A cycle also runs immediately after each
5130
+ handled update, so a paced listener's next question follows a tap without
5131
+ waiting for the timer.
5132
+
5133
+ **A stop waits for the update it is holding.** Ctrl-C stops the receiver
5134
+ accepting, then drains: an update mid-append keeps its socket, finishes its
5135
+ decision and writes its response, and only then is `deleteWebhook` called and
5136
+ the stopped line printed. A second Ctrl-C during the drain is absorbed rather
5137
+ than killing the process, and a request that never finishes is dropped after ten
5138
+ seconds, because a stop has to end. `deleteWebhook` on that path is given five
5139
+ seconds rather than the channel's usual thirty, for the same reason: a stop an
5140
+ operator has asked for twice should not sit behind an unreachable Bot API, and
5141
+ a removal that does not land is reported as a webhook still registered.
5142
+
5143
+ ### The daemon id it stamps (APRV-448)
5144
+
5145
+ Every record this process appends (a decision a tap delivers, the refusal of
5146
+ one, anything a dispatch cycle writes) carries the `daemon` field, with the id
5147
+ `approval status` reports for the same store: `APPROVAL_DAEMON_ID` from the
5148
+ launch environment, or `daemon-` plus the instance id derived from the log's
5149
+ `.approval` directory, which is the id the daemon loop derives. The started line
5150
+ names it (`records stamped daemon <id> (<source>)`) and `webhook_started`
5151
+ carries it as `daemon`. A declared id that is not a usable id refuses the verb
5152
+ before the bot is claimed or a port is bound, exit 2.
5153
+
5154
+ The attested policy's `daemons` list governs this process at the write
5155
+ boundary, as it does the daemon and [`approval serve`](#serve). It is re-read at
5156
+ startup and at the top of every dispatch cycle, which is this process's tick: a
5157
+ list a human narrows and re-attests takes effect within one cycle (a cycle runs
5158
+ after every handled update and every `--cycle`), and a resolution that fails
5159
+ leaves the previous list standing. A tap whose decision is refused
5160
+ `daemon-not-allowed` appends nothing, and the refusal is reported on stderr. The
5161
+ process declares the id without marking itself the daemon.
5162
+
5163
+ ```json
5164
+ {"event":"webhook_started","url":"https://gate.example/telegram/<path redacted>",
5165
+ "host":"127.0.0.1","port":4683,"path":"/telegram/<path redacted>","cycle_ms":30000,
5166
+ "daemon":"daemon-3f2a9c11"}
5167
+ {"event":"stopped","notified":1,"updates":1,"decisions":1,"pollErrors":0,
5168
+ "anomalies":{"foreign-chat":0,"malformed-callback":0,"unknown-callback":0,
5169
+ "key-mismatch":0},
5170
+ "webhook":{"requests":2,"updates":1,"refusals":{"webhook-secret-mismatch":1}}}
5171
+ ```
5172
+
3981
5173
  ## channel telegram health
3982
5174
 
3983
5175
  Makes no network call. A health check that contacted the Bot API would announce
@@ -3991,6 +5183,194 @@ belong to a RUNNING listener: they are on its stderr as they happen, in its
3991
5183
  Which variables are read comes from the policy, so a renamed variable reads back
3992
5184
  as the name you set.
3993
5185
 
5186
+ ## channel relay
5187
+
5188
+ A third arrival for a human gesture, beside a Telegram tap and a terminal
5189
+ (APRV-455). An operator's control plane holds an authenticated session for a
5190
+ person (the Agent Village control plane holds an EdgeOS session for each
5191
+ resident), shows them a request or their policy, and posts what they did to
5192
+ this verb. The post carries the gesture, the EdgeOS `/humans/me` id of the
5193
+ session that made it, the policy hash or the action key it answers, a nonce and
5194
+ an `issued_at`. The daemon resolves the id against the attested policy exactly
5195
+ as it resolves a Telegram `from.id`, and records the gesture through the same
5196
+ `recordChannelDecision` a tap goes through.
5197
+
5198
+ ```
5199
+ approval channel relay [--listen [host:]port | --port <n>] [--allow-non-loopback]
5200
+ [--proposer agent:<id>] [--policy <p>] [--dir <p>]
5201
+ [--log <p>] [--json]
5202
+ ```
5203
+
5204
+ ### Trust level
5205
+
5206
+ **The daemon trusts the relay's attribution.** A post that carries the relay
5207
+ secret is the operator's control plane reporting which EdgeOS account acted,
5208
+ and the daemon believes that report the way it believes the Telegram Bot API's
5209
+ report of `callback_query.from.id`. That is operator trust, stated in
5210
+ APRV-422's words: no hosted service has authority over decisions. The relay
5211
+ carries a person's gesture and decides nothing. Which approver the account is,
5212
+ whether that approver may decide this request or attest this policy, and
5213
+ whether the request is still answerable are all decided by the runtime, from
5214
+ the verified log and the attested policy, with the same codes a tap gets.
5215
+
5216
+ What the relay cannot do, by construction:
5217
+
5218
+ - **Act as anyone of its own.** It takes no `--as` and reads no
5219
+ `APPROVAL_HUMAN`. Every gesture is attributed to the account
5220
+ `approvers.<id>.senders.edgeos` maps, or refused `sender-unmapped` with one
5221
+ `audit.decision_refused`. A policy that maps no EdgeOS account refuses every
5222
+ gesture the same way; there is no fallback to a configured identity.
5223
+ - **Read a mapping nobody has attested.** Every gesture, decisions included,
5224
+ resolves its sender against the policy IN FORCE: the verified bytes the
5225
+ latest attestation names, recovered from the payload store. A mapping
5226
+ sitting unattested on disk (the relay's own `propose` puts one there) decides
5227
+ nothing until a human attests it. Where the in-force bytes cannot be
5228
+ recovered, a decision is refused `policy-not-attested` (an attestation,
5229
+ `attest-requires-terminal`) and nothing is appended; a log that cannot be
5230
+ read keeps its own code.
5231
+ - **Choose who may sign for a policy change.** An attestation is resolved
5232
+ against the policy IN FORCE, never the one being attested, so an account a
5233
+ proposal adds or repoints cannot accept the proposal that names it, and an
5234
+ amendment that introduces the EdgeOS mapping is refused
5235
+ `attest-requires-terminal` (the operator bootstrap of APRV-449 is what maps
5236
+ the resident the first time).
5237
+ - **Hold a token.** A grant that mints a token for the deciding surface has it
5238
+ dropped unread; the response says only `token_issued`. Requests in the Agent
5239
+ Village are harness-executed or sealed, so none is lost; a manual-delivery
5240
+ request granted here has a token nobody holds and is asked again.
5241
+ - **Be reached through `approval serve` or `approval mcp serve`.** It is its
5242
+ own process, port and credential. The registry marks it `human_only`, so no
5243
+ wrapper publishes it, and neither serve credential reaches it.
5244
+ - **Be started by an agent.** The attribution a relay records is exactly as
5245
+ strong as control over who launches it, because its launcher chooses the
5246
+ secret. The hook classifier holds `approval channel relay` as `policy.core`
5247
+ (human-only in the reference policy), so an agent session cannot start one; a
5248
+ deployment starts it from its own launcher as the store's user. A keyed
5249
+ `senders.edgeos` mapping adds a second lock: a process without
5250
+ `APPROVAL_SENDER_KEY` cannot resolve any account.
5251
+
5252
+ ### The secret
5253
+
5254
+ `APPROVAL_RELAY_SECRET` is REQUIRED and is read from the launch environment,
5255
+ never from a file in the tree (SPEC.md §11.1 invariant 7). At least 24
5256
+ characters of `A-Z a-z 0-9 _ -` (generate it: `openssl rand -hex 32`), with the
5257
+ same value given to the control plane; surrounding whitespace is stripped at
5258
+ start, so the control plane sends the trimmed value. Under the `APPROVAL_`
5259
+ prefix it is withheld from every child `approval run` spawns. The control plane sends it
5260
+ in `x-approval-relay-secret`; it is compared in constant time over SHA-256
5261
+ digests, BEFORE the path, the method or the body, and a duplicate header is
5262
+ refused rather than resolved. **A refusal never reaches the gate and appends
5263
+ nothing to the log**: it is counted, written to stderr, and answered with its
5264
+ code. The secret appears in no response, no log record and no stderr line.
5265
+
5266
+ Loopback by default (port 4684). There is no TLS here; a routable bind takes
5267
+ `--listen <host:port> --allow-non-loopback` and prints a banner every start.
5268
+
5269
+ ### The body
5270
+
5271
+ `POST /relay/gesture`, one JSON object, closed: an unknown field is refused, so
5272
+ nothing that looks like authority (an `actor`, a second sender, a `ts`) rides
5273
+ along on a valid post.
5274
+
5275
+ ```json
5276
+ {"gesture":"propose","policy_sha256":"<64 hex>","nonce":"<16..128 of A-Za-z0-9_->","issued_at":"2026-10-03T12:00:00Z"}
5277
+ {"gesture":"attest","policy_sha256":"<64 hex>","sender":{"channel":"edgeos","id":"<EdgeOS id>"},"nonce":"...","issued_at":"..."}
5278
+ {"gesture":"decline","policy_sha256":"<64 hex>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
5279
+ {"gesture":"grant","action_key":"<the request's key>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
5280
+ {"gesture":"reject","action_key":"<the request's key>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
5281
+ ```
5282
+
5283
+ - `sender.channel` is `edgeos` and nothing else. `sender.id` is the raw
5284
+ `/humans/me` id: an ASCII letter or digit, then up to 127 letters, digits,
5285
+ `.`, `_` or `-`, the grammar `senders.edgeos` pins. Never a digest (a keyed
5286
+ policy is resolved with `APPROVAL_SENDER_KEY` in the relay's environment) and
5287
+ never an email address.
5288
+ - `issued_at` is RFC 3339 in UTC ending in `Z`, within five minutes of the
5289
+ relay's clock either side. It is used for that window and recorded nowhere;
5290
+ every record's `ts` is the runtime's own.
5291
+ - `nonce` is claimed before the gesture is applied, in a ledger of exclusively
5292
+ linked files under the gate's `.approval/daemon/relay-nonces/`, kept for eleven
5293
+ minutes. A replay is refused `relay-nonce-replayed` at this process, after a
5294
+ restart, or at a second relay on the same gate. A retry is a new post with a
5295
+ new nonce, and the gate answers a repeated decision `already-decided`.
5296
+
5297
+ `propose` is the control plane's half of the onboarding review. It writes the
5298
+ rendered policy into the store, then posts its hash: the relay re-hashes the
5299
+ file and refuses `relay-policy-mismatch` when the bytes are not the ones named,
5300
+ then appends a `policy.proposed` under `--proposer` (an `agent:` id, default
5301
+ `agent:edgeos-relay`: the relay is a machine, and a `human:` proposer would say
5302
+ a person proposed what nobody typed) with the semantic diff against the
5303
+ verified in-force text and a deadline one hour out, after which the prompt
5304
+ retires from every queue. A retry of an open proposal of the same bytes returns it with
5305
+ `"existing":true`. It also admits bytes ALREADY in force (`"reaffirm":true`):
5306
+ the review most residents make is "accept, unchanged", and that acceptance is
5307
+ still their attestation. A reaffirmation changes the attester of record and
5308
+ nothing else. The prompt also reaches Telegram, where the resident's tap
5309
+ answers it equally.
5310
+
5311
+ **Until a proposal is attested, the bytes on disk are not the policy in
5312
+ force.** Every gated call refuses `policy-not-attested` in that window (a
5313
+ grant through the relay included), exactly as during any amendment. So the
5314
+ control plane proposes and collects the acceptance in the same screen, and
5315
+ restores the in-force bytes when the resident declines or the proposal lapses.
5316
+ A `decline` appends `policy.declined` and leaves the attestation in force
5317
+ where it was, the operator's included: it is a refusal to adopt the proposed
5318
+ bytes, never a withdrawal of the policy already attested.
5319
+
5320
+ `attest` / `decline` answer the open proposal of that hash. `attest` appends
5321
+ `policy.updated` with `actor: human:<approver>` and `payload.sender
5322
+ {channel: edgeos, id}`. A hash that names no open proposal (superseded,
5323
+ answered, never proposed) is refused `proposal-not-found`, and a file changed
5324
+ under the proposal is refused `proposal-stale`; neither attests anything.
5325
+
5326
+ `grant` / `reject` answer a pending request exactly as a Telegram tap does,
5327
+ including `expired`, `policy-drift` (with its `audit.decision_refused` and
5328
+ withdrawal), `already-decided` and `actor-not-approver`.
5329
+
5330
+ ### Responses
5331
+
5332
+ | status | body | meaning |
5333
+ | --- | --- | --- |
5334
+ | 200 | `{"ok":true,"gesture":…,"event":…,"seq":…,"actor":…}` | recorded (`propose` returns `sha256`, `seq`, `existing`, `reaffirm`, `changes`, `loads`) |
5335
+ | 409 | `{"ok":false,"gesture":…,"refusal":{"code","message"}}` | the gate or the decision surface refused; the code is from `gate_refusal_codes` or `channel_decision_refusal_codes` |
5336
+ | 4xx/5xx | `{"error":{"code","message"}}` | the relay refused the post; the code is from `relay_refusal_codes`, and nothing was appended |
5337
+
5338
+ `relay_refusal_codes` (frozen, pinned in `conformance/vectors/refusal-unions.v1.json`):
5339
+ `relay-secret-mismatch`, `relay-duplicate-secret-header` (401),
5340
+ `relay-malformed-request` (400), `relay-unknown-path` (404),
5341
+ `relay-method-not-allowed` (405), `relay-body-too-large` (413),
5342
+ `relay-body-unreadable`, `relay-body-invalid`, `relay-sender-invalid`,
5343
+ `relay-gesture-stale` (400), `relay-nonce-replayed` (409),
5344
+ `relay-nonce-unavailable` (503, the ledger could not record the nonce, so
5345
+ nothing was decided), `relay-policy-mismatch` (409), `relay-handler-failed`
5346
+ (500).
5347
+
5348
+ Start-up refusals (exit 2, `{"error":{"code"}}` under `--json`):
5349
+ `relay-secret-missing`, `relay-secret-weak`, `relay-secret-charset`,
5350
+ `relay-proposer-invalid`, `relay-bind-invalid`. An unreadable log exits 4, a
5351
+ torn tail 3, a corrupt chain 1.
5352
+
5353
+ With `--json`, stdout carries one object per line: `relay_started` (`host`,
5354
+ `port`, `path`, `proposer`), one `relay_gesture` per gesture that reached the
5355
+ gate (`gesture`, `ok`, and `seq`/`record`/`actor` or `code`), and `stopped`
5356
+ with the counters. Never a body, a sender id or the secret.
5357
+
5358
+ There is no transport lease. A Telegram bot admits one receiver; a relay has no
5359
+ such constraint, because compare-and-append, the gate's idempotency and the
5360
+ nonce ledger (each claim written whole and linked into place, so exactly one
5361
+ claimant wins) make two relays on one gate safe. A second relay on the same
5362
+ port fails to bind.
5363
+
5364
+ **Who is listening on the port is the caller's check.** The secret travels in a
5365
+ header, so whoever holds the loopback port when the control plane posts
5366
+ receives it. In a sandbox where another uid can bind loopback (the Agent
5367
+ Village's `hermes` user), the control plane checks that the listener on 4684
5368
+ belongs to the relay's own user before it posts, the same foreign-listener
5369
+ rule the hook applies to `serve`, and posts to the literal `127.0.0.1`, never
5370
+ `localhost`, which another uid could answer on `[::1]`. A unix socket in a directory only the relay's
5371
+ user can write, or a signature over the body in place of a bearer secret, would
5372
+ remove the check; neither is in this version.
5373
+
3994
5374
  ## quickstart
3995
5375
 
3996
5376
  `approval quickstart [--dir <path>] [--api-base <url>]` is the human-only solo setup ceremony. It
@@ -4041,17 +5421,20 @@ code. A re-run in a scaffolded directory writes nothing and exits 0. A directory
4041
5421
  carrying `APPROVALS.md` (the SPEC.md §5 fallback filename) already has a policy:
4042
5422
  init reports `policy-exists` and writes no `APPROVAL.md` beside it.
4043
5423
 
4044
- Payloads are tracked. `.approval/payloads/` is deliberately not ignored: those
4045
- bytes are what each approval bound to, and evidence belongs in the history. To
4046
- ignore them instead, add `.approval/payloads/` yourself — the log keeps every
4047
- `payload_hash`, but the bytes behind them stop being rebuildable.
5424
+ Payloads are ignored (APRV-445; they were tracked before it). `.approval/payloads/`
5425
+ holds the exact bytes each approval bound to, and for a proposal those are a
5426
+ person's own words, so the merged block lists it. To keep the bytes in history
5427
+ as evidence instead, remove that line yourself; the log keeps every
5428
+ `payload_hash` either way. Files in the store are written 0600 in a 0700
5429
+ directory whatever the umask.
4048
5430
 
4049
5431
  Keys are not. The merged block ignores `.approval/*.sqlite` (a projection),
4050
5432
  `.approval/vault.enc`, `.approval/env`, `.approval/keys/`, `.approval/**/*.tmp-*`
4051
5433
  and `.approval-journal/`. `.approval/keys/` is the one that matters most and is
4052
5434
  easiest to miss: it holds the X25519 private halves of sealed token delivery, and
4053
- because the payload store beside it is tracked on purpose, `.approval/` is a
4054
- directory people `git add` from. A key committed there opens that action's
5435
+ because the payload store beside it was tracked by default before APRV-445 (and
5436
+ still is wherever an operator chose that), `.approval/` is a directory people
5437
+ `git add` from. A key committed there opens that action's
4055
5438
  `token_sealed` for everyone holding the log. The line is written whether or not
4056
5439
  your policy has opted into `token_delivery: sealed`, and `approval doctor`'s
4057
5440
  `sealed-keys` row reports a key that is tracked or in a store no line covers.
@@ -4097,6 +5480,81 @@ setting to change it, which the fail-closed invariant does not survive;
4097
5480
  `docs/grok-hook.md` states which cases the adapter cannot cover and is worth
4098
5481
  reading before the file is committed.
4099
5482
 
5483
+ **`hook muse` emits exactly ONE dialect and nothing else (APRV-350).** Muse Code
5484
+ treats output carrying any key it does not support as a failed hook, and a failed
5485
+ hook fails OPEN, so the belt-and-braces payload that satisfies several harnesses
5486
+ at once satisfies this one not at all. Its envelope is snake_case with its own
5487
+ tool names (`bash` carrying a per-call `workdir`, `write_file`, `read_file`, and
5488
+ `search` naming an ARRAY under `paths`), and a Contributor-tier model is refused
5489
+ for every tool call regardless of policy (`hook-muse-contributor-model`).
5490
+ `approval hook muse --help` prints the `.muse/hooks.json` the human commits;
5491
+ `docs/muse-hook.md` opens with the fail-open finding and the model warning.
5492
+
5493
+ **`hook hermes` is the second exception to the exit codes above, and the one
5494
+ harness whose configuration is not in the repository (APRV-398).** Its event
5495
+ names are its own — `pre_tool_call` and `post_tool_call`, not `PreToolUse` — and
5496
+ its envelope is snake_case (`tool_name`, `tool_input`, `session_id`, `cwd`,
5497
+ `profile`, `extra`). A deny is `{"action":"block","message":…}` **at exit 2**,
5498
+ which Hermes treats as an unconditional block whose message it takes from the
5499
+ stdout directive first; an **allow is `{}`** at exit 0, because Hermes has no
5500
+ allow directive and no directive is the allow, so an allow's reason goes to
5501
+ stderr. `execute_code` is refused outright
5502
+ (`hook-hermes-execute-code-unbound`): it carries a program and no path, no argv
5503
+ and no working directory, so no verdict could bind it. **A call whose effective
5504
+ directory the event does not carry is refused too** (`hook-unsupported-execution-context`,
5505
+ APRV-415): a live probe found that the envelope `cwd` is the Hermes PROCESS
5506
+ directory while `terminal` keeps a per-session recorded directory that a `cd` moves
5507
+ and no field reports, and that the file tools resolve relative paths against that
5508
+ one — so a `terminal` call with no absolute `workdir`, and a `write_file`, `patch`,
5509
+ `read_file` or `search_files` call whose path is relative or missing, are refused
5510
+ with a reason telling the session to retry with an absolute path. The file a human
5511
+ commits is `$HERMES_HOME/config.yaml` — name the home explicitly and spell its last
5512
+ segment `.hermes`, or the classifier does not recognise the organ — where the event
5513
+ is a mapping KEY rather than an `event:` field, `fail_closed: true` belongs on every
5514
+ entry because its default is false, and TWO timeouts bound the wait: the per-entry
5515
+ `timeout` caps at 300s and `plugins.hook_callback_timeout` defaults to 30s and
5516
+ fails closed by itself, so `--timeout` must come down under 300s. `--dir` is
5517
+ mandatory here, because a gateway session's `cwd` is the user's home. Without
5518
+ `hooks_auto_accept: true` (or `HERMES_ACCEPT_HOOKS=1`) and no TTY, Hermes
5519
+ **silently never registers the hook**. `fail_closed: true` was OBSERVED to block a
5520
+ hook crash, a hook timeout and unparseable output on `main` `118984d7`, which makes
5521
+ this the first adapter since Claude Code that is a gate rather than a backstop;
5522
+ `v0.21.3` (build 2026.9.14) does not know the key and fails open silently, so
5523
+ `approval doctor`'s `harness-version-unverified` row pins that floor.
5524
+ `approval hook hermes --help` prints the YAML; `docs/hermes-hook.md` opens with the
5525
+ fail-closed result, the floor, and what a post event does not prove.
5526
+
5527
+ **`--harness-cap <duration>` makes the question end before its asker does
5528
+ (APRV-423).** Every harness in the table kills the hook at some ceiling (Hermes
5529
+ caps a `pre_tool_call` entry at 300s, Claude Code kills it at the `timeout` in
5530
+ its settings file), and every one of them reads the killed process as a
5531
+ non-blocking error and runs the tool call. Before this flag the request that
5532
+ hook had opened stayed pending afterwards, so a tap arriving later was recorded
5533
+ as a plain `approval.granted` on a call nobody was holding, and the deny and the
5534
+ grant described one request and disagreed about it (APRV-410).
5535
+
5536
+ The flag states the ceiling. The hook records it on `approval.requested` as
5537
+ `payload.harness_cap_ms`, and the runtime judges the request against the SHORTER
5538
+ of the policy's `defaults.approval_ttl` and that ceiling minus a 60s margin
5539
+ (`HARNESS_CAP_MARGIN_MS` in `src/core/harness-wait.ts`, two of the daemon's 30s
5540
+ sweep intervals, so the `approval.expired` record lands while the harness is
5541
+ still listening in the common case; a skipped sweep or a slow intake can put it
5542
+ after the kill, and what keeps a late tap safe then is the gate's lazy `expired`
5543
+ refusal, which judges the lapse by arithmetic whether or not the record exists).
5544
+ A cap can only SHORTEN: the runtime takes a minimum, so a stated ceiling longer
5545
+ than the TTL changes nothing, which is SPEC.md §11.1 invariant 4 applied to a
5546
+ duration. Without the flag, `hook hermes` assumes **30s**, Hermes's default
5547
+ `plugins.hook_callback_timeout`, which does not clear the margin: every
5548
+ manual-class call is refused `hook-harness-cap-too-short` until the operator
5549
+ raises `plugins.hook_callback_timeout` above the per-entry `timeout` and passes
5550
+ `--harness-cap` with the smaller of the two (`--harness-cap 300s` for the
5551
+ documented `600` over `300`). A stated cap is still clamped to Hermes's observed
5552
+ 300s per-entry maximum. No other adapter assumes a ceiling, because no other
5553
+ harness documents one. A ceiling at or below the margin leaves no window for a
5554
+ human and is refused `hook-harness-cap-too-short` before anything is registered
5555
+ or requested. `approval serve` pins the same value on every hook call it makes
5556
+ with `--hook-harness-cap`, as it pins `--timeout` with `--hook-timeout`.
5557
+
4100
5558
  **Register the same command for the post-execution event too (APRV-145).** One
4101
5559
  binary answers two events, dispatched on `hook_event_name`. A `PostToolUse` or
4102
5560
  `PostToolUseFailure` run closes the delegated `execution.started` the
@@ -4133,7 +5591,38 @@ semantic-version-shaped tag, `tag <name>`, `--tags`, or `--follow-tags` is
4133
5591
  that DELETES a remote ref — `--delete`, `-d`, or a colon refspec such as
4134
5592
  `:refs/heads/x` — is `vcs.ref.delete` with the ref names bound (APRV-352), and a
4135
5593
  tag deletion keeps `release.publish`; ordinary branch pushes retain their branch
4136
- or trunk class. Claude file tools
5594
+ or trunk class.
5595
+
5596
+ `git tag` splits on its flags since APRV-397: a LISTING (`-l`, `--list`, `-n`,
5597
+ `-n<num>`, `--contains`, `--points-at`, `--merged`, `--sort`, `--format`,
5598
+ `--color`, `--ignore-case`, `--omit-empty`, or no argument at all) is
5599
+ `read.shell`, rule `git-tag-read`, the same class `git log`, `git status` and
5600
+ `git branch` take for reading local repository metadata. Everything else stays
5601
+ `release.publish`: `-a`, `-d`, `-f`, `-s`, `-m`, a bare tag name, and any flag
5602
+ the listing allowlist does not name, so a future `git tag` option cannot arrive
5603
+ as a read. A positional under `-l` is a pattern rather than a name.
5604
+
5605
+ The packaging and archive tools are classified too, for the same reason and in
5606
+ the same release (APRV-397): they were `unclassified`, so a verification could
5607
+ not run them and reached for `curl` and a script instead. `npm pack` is a
5608
+ workspace write of the tarball, scoped to `--pack-destination`, and
5609
+ `network.call` when a positional names a registry package; `npm init` writes
5610
+ `package.json`; a bare `npm --version`, `-v`, `-V`, `--help` or `-h` is a read,
5611
+ while every npm subcommand the table does not name keeps its `unclassified`
5612
+ deny; `tar -t` lists (a read) and `tar -x`/`-c` writes into `-C` or the archive
5613
+ `-f` names; `gunzip` reads only with `-c`/`--stdout`, `-t`/`--test` or
5614
+ `-l`/`--list` and otherwise replaces the file it names; `base64` reads unless
5615
+ `-o` names an output;
5616
+ `openssl dgst` (and the `md5`/`sha*` spellings) reads unless `-out` names one,
5617
+ while every other `openssl` subcommand stays unclassified. Each write is scoped
5618
+ by the arithmetic `rm` uses: a destination under a resolved scratch root or a
5619
+ relative one is `files.write.workspace`, and an absolute destination elsewhere, a
5620
+ `..` segment, an unreadable value, or a destination flag with nothing readable
5621
+ after it is `files.delete.out_of_scope`, rule `packaging-write-out-of-scope`,
5622
+ with the destination bound. A `tar` whose mode is not in its words is
5623
+ `hook-opaque`. `docs/claude-code-hook.md` holds the table and the reasoning.
5624
+
5625
+ Claude file tools
4137
5626
  (Edit, Write, MultiEdit, NotebookEdit) and
4138
5627
  Cursor Write/Delete are gated only when the file is policy-protected
4139
5628
  (`APPROVAL.md`, `.approval/`, `CLAUDE.md`, `AGENTS.md`, `.claude/settings*`,
@@ -4170,7 +5659,8 @@ its own flags are not parsed as this verb's.
4170
5659
  - `hook-rejected` — a human said no.
4171
5660
  - `hook-revoked` — a granted approval was withdrawn.
4172
5661
  - `hook-expired` — the TTL lapsed before a decision.
4173
- - `hook-timeout` — no decision inside `--timeout`; the request stays live.
5662
+ - `hook-timeout` — no decision inside the wait (`--timeout`, clamped to what
5663
+ `--harness-cap` leaves after the 60s margin; APRV-423); the request stays live.
4174
5664
  - `hook-gate-refused:<c>` — the gate refused intake; `<c>` is its own frozen code
4175
5665
  (`policy-not-attested`, `budget-exceeded`, …).
4176
5666
  - `hook-policy-unavailable` — `APPROVAL.md` could not be loaded.
@@ -4536,6 +6026,57 @@ defined over the canonical VALUE, so non-JSON input has no defined
4536
6026
  could reproduce. Empty input is the same answer. A file that exists but cannot be
4537
6027
  read is exit 4.
4538
6028
 
6029
+ ## payload run
6030
+
6031
+ The payload `approval run` will recompute for a command, printed before any
6032
+ approval exists: its argv, the cwd it will run in, and the size and SHA-256 of
6033
+ the script that argv names.
6034
+
6035
+ ```
6036
+ approval payload run -- bash scripts/install.sh
6037
+ {"argv":["bash","scripts/install.sh"],"cwd":"/repo",
6038
+ "script":{"argv_index":1,"path":"/repo/scripts/install.sh","bytes":412,
6039
+ "sha256":"fef0…"}}
6040
+ ```
6041
+
6042
+ The `script` object is the whole point of the verb (APRV-401). A grant over
6043
+ `bash scripts/install.sh` used to bind that STRING, so the bytes behind the path
6044
+ were whatever the file held at execution time and the requester controlled the
6045
+ file between the request and the grant. The digest is inside the hashed value,
6046
+ so a script edited after the declaration is refused `payload-mismatch` before the
6047
+ child is spawned and before anything is appended, and the approver's card carries
6048
+ the path, the byte count and the digest because the payload does.
6049
+
6050
+ The argv names a script in two shapes: a known interpreter (the six shells,
6051
+ `node`, `python`/`python3`, `perl`, `ruby`, `deno` — each a name the command
6052
+ classifier already knows) followed by a path operand, or a path at `argv[0]` the
6053
+ kernel reads a shebang from. An inline program (`bash -c …`, `node -e …`) is
6054
+ already a word of the argv and binds no file.
6055
+
6056
+ An argv naming no readable script carries no `script` key and hashes exactly as
6057
+ it did before the rule existed, which is why every record already in a log and
6058
+ every declaration already written into a task file still verifies.
6059
+
6060
+ `--hash` prints the `payload_hash` instead of the bytes — the value a task file's
6061
+ action declaration carries. `--cwd` states the directory the run will happen in,
6062
+ because that directory is inside the hash and every relative argv word resolves
6063
+ against it. The two ends must agree, so pass the same `--cwd` the run will use.
6064
+
6065
+ What is NOT bound is stated in full in `docs/run-payload-binding.md`: nothing
6066
+ resolved through `PATH`, nothing a bound script itself reads or executes, and the
6067
+ instant between this hash and the spawn.
6068
+
6069
+ This verb reads the one file the argv names as its script and nothing else. No
6070
+ log, no policy, no network, no token, and it executes nothing.
6071
+
6072
+ It is **local only**: the MCP wrapper and `approval serve` withhold it, because
6073
+ the path it digests is one of the command's own words and no transport guard
6074
+ confines those the way the store confinement confines `payload hash`'s
6075
+ positional. A remote caller could otherwise ask for the digest of any file the
6076
+ server process can read (`-- bash /etc/shadow`) and learn that the path exists
6077
+ and what its bytes fingerprint to. Compute the binding where the command will
6078
+ run, which is the only place the value is true.
6079
+
4539
6080
  ## payload agentmail-draft
4540
6081
 
4541
6082
  An AgentMail draft is mutable server-side state, so an approval of a draft id
@@ -4791,7 +6332,15 @@ says nothing about it.
4791
6332
  it, and neither is a log to append onto. A working log that is a strict prefix
4792
6333
  of the committed copy is an `anchor-behind` warning, not a stop. The `started`
4793
6334
  line names the rev and seq this run is held to (`"anchor"`), and the tick line
4794
- carries the comparison it made. Git is read, never fetched.
6335
+ carries the comparison it made. Git is read, never fetched. When the log was
6336
+ rewritten between the tick's opening read and the check's own read of the file
6337
+ (a `log sync` or a records pull replacing `events.jsonl` under the loop), the
6338
+ check reads the file again through the same verified read, reports one
6339
+ `anchor-reread` warning naming both heads, and the verdict beside it is the
6340
+ second read's (APRV-389). Every `anchor-diverged` stop is reached from the
6341
+ file's bytes and confirmed by that second read, and its message names the seq
6342
+ the chains parted at and both heads without also claiming either chain is a
6343
+ prefix of the other.
4795
6344
  - ENVELOPE DRIFT — a task file whose `state:` contradicts the log gets an
4796
6345
  `envelope.drift` event (actor `system:daemon`), once per claim.
4797
6346
  - TTL SWEEP — every live request whose TTL lapsed gets an `approval.expired`
@@ -4910,8 +6459,11 @@ Warnings go to stderr as `{"event":"warning","code":"...","message":"..."}`, wit
4910
6459
  `code` one of `task-unreadable`, `frontmatter-invalid`, `envelope-invalid`,
4911
6460
  `task-id-missing`, `tasks-dir-unreadable`, `append-refused`, `expire-refused`,
4912
6461
  `render-failed`, `watch-unavailable`, `prune-refused`, `write-back-refused`,
4913
- `advance-refused`, `draw-unavailable`. A warning never stops the
4914
- loop, and neither does `{"event":"git_evidence_failed","step":"commit",…}`.
6462
+ `advance-refused`, `dark-session-undetermined`, `anchor-behind`,
6463
+ `anchor-reread`, `checkpoint-due`, `draw-unavailable`, `sample-deferred`,
6464
+ `drift-deferred`. A
6465
+ warning never stops the loop, and neither does
6466
+ `{"event":"git_evidence_failed","step":"commit",…}`.
4915
6467
 
4916
6468
  Dangling advance cycles (APRV-264): at startup and before every trigger, the
4917
6469
  daemon lists every advance execution nobody closed and closes each one this
@@ -4936,6 +6488,73 @@ rather than inferred from the queue's backlog. `rendered` is emitted when the qu
4936
6488
  CHANGES; the file itself is rewritten every tick, because TTL countdowns move even
4937
6489
  when the log does not.
4938
6490
 
6491
+ **A sample that met another writer is deferred, not dropped (APRV-381).** The
6492
+ audit sweep's append is a compare-and-append against the head it read, so it can
6493
+ meet a lock another writer holds (`lock-timeout`) or a record another writer
6494
+ landed in between (`head-moved`). Both are reported as `sample-deferred` on
6495
+ stderr, naming the action key and saying that the sweep retries on the next tick:
6496
+
6497
+ ```
6498
+ warning sample-deferred audit sampling deferred hook:6f2a:vcs.push.main
6499
+ (lock-timeout): another writer holds .approval/log/events.jsonl.lock; gave up
6500
+ after 2000ms The sample is NOT lost: it is still pending in the log's own terms
6501
+ (eligible, drawn, no audit.sampled yet), and the sweep retries it on the next
6502
+ tick.
6503
+ audit.sampled: hook:6f2a:vcs.push.main drawn for review (execution.started at
6504
+ seq 51445) — recorded at seq 51447, the retry of the sample this run deferred
6505
+ earlier
6506
+ ```
6507
+
6508
+ The second line is the same `sampled` event with `"retry":true` added, and it
6509
+ appears only when this run is the run that deferred the sample: a restarted
6510
+ daemon still makes the append, and says nothing about a promise it did not make.
6511
+ Nothing is remembered across processes because nothing needs to be: the sweep
6512
+ re-derives the pending set (eligible, drawn by the sampler, no `audit.sampled`
6513
+ yet) from the verified log on every tick, so a deferral is lost work only if the
6514
+ daemon never runs again.
6515
+
6516
+ Every other append refusal on a sample keeps the `append-refused` form, because
6517
+ `validation`, `canonicalization`, `corrupt-tail` and `io` are facts about the
6518
+ record or the file that no amount of retrying repairs.
6519
+
6520
+ **A drift record that met another writer is deferred too (APRV-403).** The drift
6521
+ scan's append is the same compare-and-append against the head it read, and it
6522
+ met the same contention: the end-to-end test that holds the append lockfile for
6523
+ a whole tick showed the scan printing `append-refused … (lock-timeout)` for an
6524
+ `envelope.drift` the next tick went on to write. So the same split applies, from
6525
+ the same closed list of transient codes, on both drift reasons:
6526
+
6527
+ ```
6528
+ warning drift-deferred envelope.drift for task-042 was deferred (lock-timeout):
6529
+ another writer holds .approval/log/events.jsonl.lock; gave up after 2000ms The
6530
+ record is NOT lost: the disagreement is still there in the log's own terms, and
6531
+ the scan re-derives it and retries on the next tick.
6532
+ envelope.drift: task-042 (backlog/tasks/task-042.md) claims state approved, the
6533
+ log says proposed — recorded at seq 12, the retry of the record this run
6534
+ deferred earlier; the file is repaired to match the log later in this tick
6535
+ ```
6536
+
6537
+ The `retry` flag has the same standing as the sample's: it is on the `drift`
6538
+ event only when THIS run is the one that deferred the record, it is output
6539
+ bookkeeping that no append consults, and a restarted daemon makes the same
6540
+ append and claims nothing. Deferring costs nothing because the scan carries no
6541
+ state between ticks: it re-reads the folder, re-derives the state from the
6542
+ verified log, and `driftAlreadyLogged` keeps a repeat idempotent.
6543
+
6544
+ An `envelope.drift` refused for `validation`, `canonicalization`, `corrupt-tail`
6545
+ or `io` keeps the `append-refused` form it always had.
6546
+
6547
+ **The sweep waits `2000ms` for the lock and no longer, by decision.** The daemon
6548
+ is the one writer that could afford a longer wait and deliberately takes the
6549
+ default. A tick is serial, so a blocking wait here delays the expiry lines, the
6550
+ `state:` write-back and `QUEUE.md`; the writers it contends with hold the lock
6551
+ across spans no polite wait covers (`approval log advance` across a whole
6552
+ verify-and-commit, `approval log sync` across a baseline move); and the appends it
6553
+ would be muscling in front of are the ones somebody is waiting on, a hook's gate
6554
+ verdict or a lane's execution record. Deferring costs one line and at most one
6555
+ tick interval, and it is provably lossless. Raise `--interval` only if you want
6556
+ the retry later; there is no flag for the lock wait, on purpose.
6557
+
4939
6558
  ## up
4940
6559
 
4941
6560
  **Normal startup after setup and human attestation.** Run from the intended
@@ -5019,6 +6638,13 @@ A 409 that still happens at runtime — another machine, or a poller started
5019
6638
  outside this runtime — is reported once, with the instances the registry knows
5020
6639
  about, and its repeats are counted rather than reprinted.
5021
6640
 
6641
+ **A relayed Telegram channel refuses buttons it is not holding** (APRV-456). An
6642
+ `--api-base` that is not `https://api.telegram.org`, or `--no-stale-copy`, turns
6643
+ off the earlier-copy fallback, and a decision tap whose nonce this process is
6644
+ not holding is refused `nonce-not-issued` instead of reaching the gate. The rule
6645
+ and its reason are under [`channel telegram listen`](#channel-telegram-listen)
6646
+ ("Earlier copies, and `--no-stale-copy`").
6647
+
5022
6648
  **The ambient runtime: the daemon loop and every configured channel in one
5023
6649
  supervised foreground process.** `approval daemon run --with-channels` is the
5024
6650
  same verb spelled from the other side, and it reaches the same function before
@@ -6035,6 +7661,14 @@ STARTTLS, AUTH — and then QUIT. It proves the host answers, that the TLS mode
6035
7661
  the one the server offers, and that the credential is accepted. It does not prove
6036
7662
  delivery, and it puts no message on the wire.
6037
7663
 
7664
+ An `smtp.host` that is an IP address is probed without SNI (APRV-416). A server
7665
+ name is a name, so there is nothing to send for an address, and Node 26 refuses
7666
+ the session outright where earlier versions only warned. The certificate is
7667
+ still verified; what it is verified against is the address, which the server's
7668
+ certificate has to carry as an IP entry in its subject alternative names. A
7669
+ relay whose certificate names only a hostname therefore fails the probe when it
7670
+ is configured by address and passes when it is configured by that name.
7671
+
6038
7672
  A failed probe keeps the values. A laptop behind a captive portal is not a reason
6039
7673
  to make you type five things again. The refusal prints the SMTP code and the
6040
7674
  server's first line, with the credential redacted, and the undo.
@@ -6335,6 +7969,442 @@ The guest instructions string says so, tells the caller to poll `status`, and
6335
7969
  states plainly that a granted request executes nowhere: the demo is the approval
6336
7970
  flow itself.
6337
7971
 
7972
+ ## serve
7973
+
7974
+ `approval serve` is the same agent-facing surface as `mcp serve`, over HTTP,
7975
+ for a harness that has no local log and no local policy: a sandboxed tenant
7976
+ whose daemon runs elsewhere, one process per tenant. It is a transport and not
7977
+ a second gate. Every verdict, every verb and every batch of records comes from
7978
+ the function the CLI dispatches to; nothing under `src/serve/` classifies a
7979
+ command, resolves a policy, mints a token or verifies a chain.
7980
+
7981
+ STDOUT IS EMPTY. The bound address, the identity, the daemon id every record
7982
+ it appends carries (see [Which daemon wrote it](#which-daemon-wrote-it-aprv-448))
7983
+ and the store root go to stderr on one started line. SIGINT and SIGTERM close
7984
+ the listener and exit 0.
7985
+
7986
+ ### The two credentials
7987
+
7988
+ Two bearer values, both read from the launch environment (SPEC.md §11.1
7989
+ invariant 7: nothing is loaded out of the working tree), both required before
7990
+ the listener exists, and refused when they are equal or shorter than 24
7991
+ characters:
7992
+
7993
+ | variable | opens |
7994
+ |---|---|
7995
+ | `APPROVAL_SERVE_AGENT_TOKEN` | `GET /verbs`, `POST /hook/<harness>`, and the allowlisted verbs below |
7996
+ | `APPROVAL_SERVE_TENANT_TOKEN` | `GET /verbs`, `GET /log/follow`, `GET /export`, `GET /status`, and every other published verb |
7997
+
7998
+ **The agent credential never reads the log it is judged by.** That is the
7999
+ sentence the split exists for. An agent that could stream the verified log
8000
+ could read every decision made about it and the shape of the oversight it is
8001
+ under; an agent that could export the store could carry all of it away.
8002
+
8003
+ Authorization is therefore per verb, and it is an ALLOWLIST. The party at the
8004
+ other end of this transport is a sandboxed harness on another machine, not the
8005
+ operator at their own laptop, so the agent credential opens only what a harness
8006
+ under oversight needs in order to ASK and to ACT ON a grant:
8007
+
8008
+ > `instructions`, `hook_classify`, `request`, `wait`, `withdraw`
8009
+
8010
+ Five verbs, and the shortness is the point: a harness under oversight asks and
8011
+ is answered, and everything else about the gate belongs to the party the gate
8012
+ is for.
8013
+
8014
+ Neither payload builder is on it, under the rule that a builder is the agent's
8015
+ only if it builds from the request's own arguments and never reaches a host
8016
+ resource. `payload_hash` takes a FILE, and on this transport that file is on
8017
+ the daemon's machine while the caller is not: every path it could name is one
8018
+ the host put there, so answering would be a hash oracle that also files the
8019
+ bytes into the payload store. `payload_agentmail-draft` takes two ids rather
8020
+ than a path, but reads the draft over HTTPS with `AGENTMAIL_API_KEY` from this
8021
+ server's own environment, so a remote caller would be spending the host's
8022
+ credential on ids it chose: the same fault wearing a network instead of a
8023
+ filesystem.
8024
+
8025
+ Everything else the catalog publishes is the tenant's. The ones easiest to wave
8026
+ through and most worth naming: `log_tail` and `log_export` return records,
8027
+ `log_verify` and `gate_status` return the log's length, its head hash and the
8028
+ live state of the gate, `queue` returns the tenant's pending decisions,
8029
+ `policy_check` and `policy_test` answer what the policy does with a class and
8030
+ name the absolute store path while doing it, and `status` is the tenant's view
8031
+ of the oversight the agent is under. So are the verbs that touch the host
8032
+ rather than the log: `run` and `sandbox` spawn argv on the daemon's machine,
8033
+ every `adapter_<name>` spends vault credentials, `log_advance` opens pull
8034
+ requests against the tenant's records. And so are `register` (its positional is
8035
+ a host path; the hook endpoint is a harness's way to register, and it
8036
+ synthesises the envelope itself), the two payload builders, `token`,
8037
+ `coverage`, `doctor`, `audit_list`, `audit_obligations`,
8038
+ `channel_telegram_health`, `journal_write`, `journal_read`, `feedback`,
8039
+ `values`, `import_agents-md`, `reindex` and `render`.
8040
+
8041
+ **No caller names a path, and no positional wears a dash.** Four rules, and
8042
+ each closes a route around the others.
8043
+
8044
+ 1. The server appends `--dir`, `--log` and `--policy` to every verb call in
8045
+ every scope from its own launch configuration, and a call supplying one of
8046
+ them is refused `serve-path-pinned` even when the value is correct.
8047
+ 2. A positional beginning with `-` is refused `serve-positional-flag`, in every
8048
+ scope. Positionals are emitted first and verbatim, and the verb reads any
8049
+ token starting with `-` as a flag — including the `--payload=/path`
8050
+ spelling — so without this rule a refused flag could be sent as a value and
8051
+ reach the parser through the front door. `trailing` needs no such rule: it
8052
+ always follows a `--` separator, and the flag parser stops there, which is
8053
+ what lets `hook classify -- git push -f` classify a command line that has
8054
+ flags in it.
8055
+ 3. The AGENT credential may not use a path-typed flag at all
8056
+ (`serve-flag-not-permitted`), and may use only the flags on a short
8057
+ per-verb list. A harness reached through this transport has no files on the
8058
+ daemon's machine, so a path it names is either useless or somebody else's;
8059
+ payload bytes arrive through the hook route, which builds the envelope from
8060
+ the tool call.
8061
+ 4. The TENANT credential's path-typed flags are confined to the store
8062
+ (`serve-path-outside-store`), resolved through `realpath` on the deepest
8063
+ existing ancestor so traversal and a symlinked directory both refuse.
8064
+
8065
+ Which flags are path-typed comes from the registry rather than from a list
8066
+ kept beside the server: a flag declared `"path"` publishes as an ordinary
8067
+ `{"type":"string"}`, so the wire contract is unchanged, and it is confined on
8068
+ the day it appears. A test walks every published verb and fails on any flag
8069
+ that is neither declared `"path"` nor reviewed as something else.
8070
+
8071
+ A verb added to the registry tomorrow is published on both surfaces the same
8072
+ day and is TENANT-scoped until somebody decides otherwise. The allowlist is
8073
+ what widens, and widening it is a diff in the server and a diff in a test.
8074
+
8075
+ An agent-credential call to anything off that list is refused
8076
+ `serve-agent-forbidden`, and a tenant-credential call to an allowlisted verb
8077
+ (`request`, `wait`, `withdraw`, `hook_classify`, or `consume`, which this
8078
+ surface withholds entirely) is refused `serve-tenant-forbidden`. Both are scope refusals rather
8079
+ than not-founds: a caller that guessed a path learns that the door exists and
8080
+ is not theirs.
8081
+
8082
+ Every path requires one of the two. There is no unauthenticated surface at all,
8083
+ not a health check and not a 404, so a caller with no credential learns nothing
8084
+ about what exists.
8085
+
8086
+ ### The endpoints
8087
+
8088
+ `GET /verbs` is the published catalog: `approval mcp serve`'s tool list, name
8089
+ for name and schema for schema, because it is derived from the same registry
8090
+ filter by the same function. `--as` is absent from every published schema and
8091
+ the launch identity is appended last to every argv, so a caller can neither
8092
+ name an identity nor change one. `grant` is absent, as it is there and for the
8093
+ same reason: SPEC.md §11 makes the agent the untrusted policy, and a tool list
8094
+ is a statement about what a surface is for. Each entry carries the scope that
8095
+ opens it, so the catalog is honest about `status` being the tenant's.
8096
+
8097
+ ### The response contract
8098
+
8099
+ `POST /verb/<tool_name>` and `POST /hook/<harness>` answer with ONE body:
8100
+
8101
+ ```json
8102
+ {"exit_code": 0, "stdout": "…", "stderr": "…",
8103
+ "stdout_truncated": false, "stderr_truncated": false}
8104
+ ```
8105
+
8106
+ `stdout` and `stderr` are exactly what the CLI wrote to each stream, and
8107
+ `exit_code` is exactly what it exited. A client reproduces the invocation by
8108
+ writing `stdout`, writing `stderr`, and exiting `exit_code`. A client that
8109
+ wants the verb's machine-readable refusal parses `stderr`, which is the stream
8110
+ the CLI prints refusals on. Both streams are bounded at 256 KiB and clipped at
8111
+ a codepoint boundary, with a flag saying whether that happened.
8112
+
8113
+ **Nothing about a verdict travels in a header.** It used to: the exit code rode
8114
+ on `x-approval-exit-code`, and server-authored refusals did not set it. A
8115
+ client following the documented rule then read a missing header as zero, and
8116
+ zero is ALLOW on Claude Code, Cursor, Codex and Muse — so a 1.1 MB hook
8117
+ envelope came back as a 413 that meant *proceed*. The exit code is now data in
8118
+ the body, it is present on every response including every refusal, and on a
8119
+ refusal it is always non-zero.
8120
+
8121
+ **A client treats a missing, unparseable or truncated body as a BLOCK.** That
8122
+ rule is not a nicety: a transport failure this server never saw (a proxy, a
8123
+ dropped connection, a process killed mid-response) has no exit code to carry,
8124
+ and the only safe reading of "no answer" from a gate is no.
8125
+
8126
+ `POST /hook/<harness>` takes the harness envelope the stdin form would read, for
8127
+ every harness this runtime speaks a hook protocol for. Its `stdout` is
8128
+ BYTE-FOR-BYTE what `approval hook <harness>` prints for that envelope, because
8129
+ it IS that stdout: the request body is handed to `commandHook` as its stdin and
8130
+ nothing reshapes the answer. The exit code must travel with it, because the
8131
+ dialects disagree about where a block lives — Claude Code, Cursor, Codex and
8132
+ Muse put it in the body at exit 0; Grok Build and Hermes use exit 2. Hermes's
8133
+ ALLOW is `{}` and carries its reason on stderr and nowhere else, which is why
8134
+ `stderr` is part of the contract rather than a diagnostic.
8135
+
8136
+ A refusal the SERVER raises on the hook route — an oversized body, a credential
8137
+ at the wrong door, a method it does not answer — carries `{"error":{…}}` and
8138
+ `exit_code: 2` **and** a `stdout` holding the harness's own block directive,
8139
+ rendered by the same function that prints one. A client that writes `stdout`
8140
+ and exits `exit_code` therefore blocks on both halves of every dialect at once.
8141
+ That holds for the refusals that fire BEFORE the URL is parsed, too — a
8142
+ rotated credential, a malformed `Host` — because the harness is read off the
8143
+ raw request target for the purpose of the refusal body and nothing else. A
8144
+ credential rotation must not read as a permission, and on four of the six
8145
+ dialects a body with no block directive at exit 0 is exactly that.
8146
+
8147
+ Where the harness is not known (an unroutable path, an unrecognised name)
8148
+ `stdout` is absent, and the client rule in the paragraph above is what covers
8149
+ it.
8150
+
8151
+ A deny is a VERDICT, so its HTTP status is 200; only a refusal by the server
8152
+ is not.
8153
+
8154
+ **A Hermes tenant needs `--hook-harness-cap`, or every manual class is refused
8155
+ (APRV-423).** `approval serve` pins `--harness-cap` on every hook call with
8156
+ `--hook-harness-cap`, as it pins `--timeout` with `--hook-timeout`. Without it a
8157
+ `POST /hook/hermes` runs as `approval hook hermes` with no flag, which assumes
8158
+ Hermes's 30s default `plugins.hook_callback_timeout`; 30s does not clear the 60s
8159
+ margin, so every manual-class call answers `hook-harness-cap-too-short` (a
8160
+ block, nothing registered or requested, no prompt sent) and a tenant is gated
8161
+ shut rather than gated. The repair for a serve operator, who does not own the
8162
+ tenant's `$HERMES_HOME`: have the tenant raise `plugins.hook_callback_timeout` in
8163
+ the harness's config above the per-entry `timeout`, then start `serve` with
8164
+ `--hook-harness-cap <the smaller of the two>` (`--hook-harness-cap 300s` for the
8165
+ documented `600` over `300`). The value is per server rather than per tenant, so
8166
+ a server fronting several Hermes tenants states the smallest ceiling any of them
8167
+ runs under; a stated cap can only shorten a window, never lengthen one. The
8168
+ same flag bounds the hook's WAIT on every route: `--hook-timeout` is clamped to
8169
+ what the ceiling leaves after the margin, so a hook that adopts an already-open
8170
+ question under a ceiling with no room in it answers `hook-timeout` at once
8171
+ (question left open for the retry grace) instead of being killed mid-wait.
8172
+
8173
+ `GET /log/follow?from=<seq>&cursor_hash=<64hex>&limit=<n>` answers one page of
8174
+ verified records and the cursor to ask with next time. The cursor is EXCLUSIVE
8175
+ and the subscription is `core/log-subscribe.ts`'s, so SPEC.md §8 holds
8176
+ unchanged: the chain is verified from genesis before anything is emitted, and a
8177
+ `cursor_hash` that does not match the retained prefix is refused with the
8178
+ `integrity` code, `cursor-mismatch` as its reason, and NO RECORDS, not even the
8179
+ ones a partial drain had already produced. `caught_up` says whether the page
8180
+ exhausted the log or the limit. The server keeps no subscription state: a page
8181
+ is a function of the cursor in the request and the bytes on disk, so a host that
8182
+ sleeps and wakes serves the same next page it would have served before.
8183
+
8184
+ **`from` greater than zero REQUIRES `cursor_hash`.** The subscription calls the
8185
+ sequence-only form a weaker bootstrap, weak in exactly one way: it cannot
8186
+ detect a fully recomputed replacement prefix on its first read. A streaming
8187
+ consumer pays that once and then retains the digest it verified. A paged one
8188
+ never does — every request is a first read — so the weakness would be
8189
+ permanent, and a caller who dropped the hash would be served a replaced prefix
8190
+ silently while an honest caller who kept it got the refusal. `from=0` is the
8191
+ only hashless form, because replaying from genesis binds nothing.
8192
+
8193
+ `from` must be a sequence number this runtime can represent and `limit` must be
8194
+ between 1 and 1000. Both are refused `serve-invalid-cursor` when they are not,
8195
+ rather than clamped: a clamp answers a different question than the one asked
8196
+ and the caller cannot tell it happened.
8197
+
8198
+ `GET /export` answers the store as a gzipped POSIX tar. Its contents are a
8199
+ positive allowlist (`APPROVAL.md`, `.approval/log/`, `.approval/payloads/`,
8200
+ `.approval/QUEUE.md`, `.approval/index.sqlite`), so `.approval/keys`,
8201
+ `.approval/env`, `.approval/daemon` and any `vault.enc` are out by not being
8202
+ named rather than by a denylist remembering them. `.approval/payloads/` is in,
8203
+ and it is the entry the reasoning turns on: the log records a `payload_hash`
8204
+ for every action a human was shown, and those bytes live there. An archive
8205
+ carrying the hashes without the bytes would hand a tenant a chain of references
8206
+ to evidence they no longer hold, which is a receipt for an exit rather than an
8207
+ exit.
8208
+
8209
+ **No link is followed, of either kind, and a link refuses the whole export.**
8210
+ A name-based allowlist is no defence against one: `ln -s
8211
+ .approval/keys/sender.key .approval/log/note.jsonl` passes every check that
8212
+ reads the name and hands over the key, and a link out of the store hands over
8213
+ any file on the host.
8214
+
8215
+ So every entry is `lstat`ed and a symbolic link refuses the export with
8216
+ `serve-export-symlink`; every file is then opened `O_NOFOLLOW` and `fstat`ed,
8217
+ so the checks are made against the descriptor that is actually read rather
8218
+ than against a name that could have been swapped in between; and a regular
8219
+ file whose link count is greater than one refuses with
8220
+ `serve-export-hardlink`. That last is the case with nothing to notice — a hard
8221
+ link is a second name for one inode, with no link to refuse to follow and no
8222
+ target to inspect — so the link count is the only thing that distinguishes it.
8223
+ Each refusal names the path and never its target.
8224
+
8225
+ The whole export is refused rather than the offending file skipped, because an
8226
+ archive silently missing a file is one nobody can tell from a complete one.
8227
+
8228
+ The only file the export drops on its own account is the append lockfile
8229
+ derived from the log path, which exists for exactly the span of the copy
8230
+ because the export holds that lock. It is matched as an exact path, not as a
8231
+ `*.lock` class: a tenant's own `.approval/payloads/x.lock` is the tenant's, and
8232
+ a name this runtime happens to use for bookkeeping is no reason to drop
8233
+ somebody else's file.
8234
+
8235
+ `GET /status` is the `status` verb, answered to the tenant credential.
8236
+
8237
+ ### The bind, and where TLS is
8238
+
8239
+ `--port <n>` picks the port (default 4682) and always binds `127.0.0.1`.
8240
+ `--listen <[host:]port>` is the only way to bind anything else, passing both is
8241
+ a usage error, and a non-loopback host ALSO requires `--allow-non-loopback`: two
8242
+ flags, because the credentials here are bearer values and a cleartext hop hands
8243
+ them to whoever is on it. A widened bind prints a banner on stderr saying
8244
+ exactly that. This process terminates no TLS and holds no certificate; the
8245
+ supported deployment is a loopback bind behind a proxy the operator owns.
8246
+
8247
+ `--listen unix:<path>` (or `APPROVAL_SERVE_LISTEN=unix:<path>`, which the flag
8248
+ overrides) binds a unix-domain socket instead (APRV-445), for a sandbox on the
8249
+ same machine running as a different uid. The path must be absolute, inside an
8250
+ existing directory OWNED BY THE SERVING UID: that directory is the access
8251
+ control, so a directory somebody else owns is refused at startup (exit 2). The
8252
+ socket itself is opened `0666` once bound, and who may reach it is decided by
8253
+ the directory's mode, group or ACL. An other-writable directory (`/tmp`) is
8254
+ refused, since anyone could swap the socket. The widening to `0666` happens only
8255
+ in a directory nobody but the serving uid can write; in a group-writable one the
8256
+ socket keeps the umask's mode (a notice says so). The residual: between the bind
8257
+ and the chmod the socket carries the umask's narrower mode, which fails closed.
8258
+ A socket file is stale, and replaced, only when a connect to it is refused
8259
+ (`ECONNREFUSED`) or it vanished (`ENOENT`); a live one (another server answers)
8260
+ is refused rather than taken over, one whose probe fails any other way
8261
+ (`EACCES`, a full backlog) is left in place and refused, and anything at the
8262
+ path that is not a socket is refused rather than destroyed. Clients speak plain HTTP over the socket, with the same two
8263
+ bearer credentials. `--allow-non-loopback` has nothing to say about it.
8264
+
8265
+ ### Which daemon wrote it (APRV-448)
8266
+
8267
+ Every record this server appends, from a verb call and from a hook call alike,
8268
+ carries the `daemon` field, and the id is the one on the started line
8269
+ (`records stamped daemon <id> (<source>)`). It is resolved once, at startup, by
8270
+ the rule the daemon loop uses: `APPROVAL_DAEMON_ID` from the launch environment
8271
+ when it is set, otherwise `daemon-` plus the instance id derived from the
8272
+ store's `.approval` directory. That is the id `approval status` reports for the
8273
+ same store, so a tenant who reads `daemon` on a record can look it up. No
8274
+ request names any part of it. A declared id that is not a usable id (lowercase
8275
+ letters, digits, `.`, `-`, `_`, at most 64 characters) refuses the server
8276
+ before it binds, exit 2.
8277
+
8278
+ The attested policy's `daemons` list governs this server at the write boundary,
8279
+ exactly as it governs the daemon. It is re-read from the verified log and the
8280
+ attested policy inside the store lock before every verb call and every hook
8281
+ call, so a list a human narrows and re-attests while the server runs refuses
8282
+ the very next call. A refused append writes nothing, and the verb answers
8283
+ `append-failed` with the write boundary's code under `error.append`:
8284
+
8285
+ ```json
8286
+ {"ok":false,"error":{"code":"append-failed","append":"daemon-not-allowed",
8287
+ "message":"task.registered could not be appended: this daemon's id is village-goa-1 and the attested policy's `daemons` list admits village-goa-2, …"}}
8288
+ ```
8289
+
8290
+ A hook call whose append is refused blocks, in the harness's own dialect.
8291
+
8292
+ The processes that stamp the field are three: the daemon loop (`approval daemon
8293
+ run`, and `approval up`, whose Telegram listener runs in the daemon's process),
8294
+ this server, and [`channel telegram webhook`](#channel-telegram-webhook). A
8295
+ session's own CLI calls, `approval hook <harness>` run as a harness's own
8296
+ process, and a standalone `channel telegram listen` stamp nothing. This server
8297
+ declares the id without marking itself the daemon: the mark is what routes an
8298
+ advance under the daemon's own autonomous class, and a process that dispatches
8299
+ the verbs an agent asks for does not hold it.
8300
+
8301
+ ### What it never does
8302
+
8303
+ It appends no record on its own account. Every event in the log under it was
8304
+ written by a verb a caller asked for, under the identity the operator fixed at
8305
+ launch, and stamped with the daemon id on the started line. It reads no
8306
+ `.approval/env`.
8307
+
8308
+ ### What it serialises, and what it does not (APRV-427)
8309
+
8310
+ One lock per store, held inside this process. It is the lock for every stretch
8311
+ of work that may append to the store or must read it as one snapshot:
8312
+
8313
+ | work | holds the store lock |
8314
+ |---|---|
8315
+ | `POST /verb/<name>`, including `GET /status` | for the whole call |
8316
+ | `GET /log/follow` | for the page, so no append this process makes lands under a verified read |
8317
+ | `GET /export` | for the whole snapshot, and the log's append lockfile as well, so no other process appends during it either |
8318
+ | `POST /hook/<harness>` | for its MUTATION sections only: everything up to its poll loop (intake, the abandoned-question sweep, register, request) and everything after it (the spend, a withdrawal) |
8319
+ | a hook call's WAIT | never |
8320
+ | `GET /verbs` | never |
8321
+
8322
+ So one hook call waiting on a human holds nothing. While it polls, the tenant's
8323
+ `status`, `queue` and follow answer, and a second hook call opens its own
8324
+ question and waits beside the first. A hook call runs on a worker thread of
8325
+ this process, because a hook's wait is a synchronous sleep and on the
8326
+ listener's own thread it would stop the listener; the verdict, its bytes and
8327
+ its exit code are `approval hook <harness>`'s, exactly as before. A new thread
8328
+ reads the log once BEFORE it asks for the store lock, so the cold walk of a
8329
+ mature log (hundreds of milliseconds) happens in parallel and never inside the
8330
+ lock; its reads under the lock are then warm.
8331
+
8332
+ **The pool is bounded, because each thread holds its own copy of the log**
8333
+ (about 26 MB idle, plus roughly 84 MB once it has read a 73k-record log). At
8334
+ most `--hook-threads` hook calls run at once, 16 by default, and no more
8335
+ threads than that ever exist. Up to `--hook-queue` further calls, 64 by
8336
+ default, wait for a slot in arrival order; a queued call has registered and
8337
+ requested nothing yet. A call that finds every slot and every place in line
8338
+ taken is refused at once with `serve-hook-saturated` (HTTP 503), carrying the
8339
+ harness's own block directive in `stdout` and `exit_code: 2`, and nothing is
8340
+ appended for it. Four finished threads are kept warm for the next call.
8341
+
8342
+ **Time in line is charged to the harness's ceiling.** The ceiling a harness
8343
+ puts on its hook (`--hook-harness-cap`, or the adapter's documented default)
8344
+ runs from the moment the call ARRIVED at this server. A call that waits for a
8345
+ slot or for the store lock spends that budget, and the hook judges, waits by
8346
+ and records on its request the ceiling less that wait, so any question it
8347
+ opens lapses at arrival plus ceiling minus the 60s margin, while the harness
8348
+ is still listening. A call that arrived with room and has less than the
8349
+ margin left when its slot or the lock arrives is refused
8350
+ `serve-hook-saturated` instead, appending nothing: a question opened then
8351
+ would still be on the approver's phone when the harness kills its asker. A
8352
+ ceiling that never had room is the hook's own `hook-harness-cap-too-short`,
8353
+ as before.
8354
+
8355
+ A torn read in a hook's poll (another writer's line caught half-landed, which a
8356
+ filesystem that grows a file a page at a time can show a reader) is read again
8357
+ on the next tick, up to five ticks in a row, before the hook treats the log as
8358
+ unreadable. This holds for the stdin form too, beside the daemon. Verb calls still run one at a time, in the
8359
+ order they arrive, because some of them are synchronous and blocking too (`run`
8360
+ spawns, and `wait` sleeps on the listener's thread, so an agent's `POST
8361
+ /verb/wait` still holds the listener for its own timeout; the hook route is the
8362
+ one a harness waits on).
8363
+
8364
+ Two appends never interleave. Inside this process the store lock is the
8365
+ reason: every append is inside one of the sections above, and the sections run
8366
+ one at a time. Across processes (a daemon, a CLI run beside this server) the
8367
+ reason is the one every `approval` process relies on: each append takes the
8368
+ log's lockfile and compares-and-appends against the head it read.
8369
+
8370
+ **A client that goes away is not answered, and does not spend.** When the HTTP
8371
+ connection of a waiting hook call closes before its answer, the call stops at
8372
+ its next poll tick without spending a grant and without withdrawing its
8373
+ question, and nothing is sent. A grant that lands afterwards is left for the
8374
+ harness's retry of the same command, which carries it and spends it once; an
8375
+ undecided question is adopted by that retry. A call still in line for a
8376
+ thread simply leaves the line. A grant already spent before the connection
8377
+ closed stays spent.
8378
+
8379
+ Closing the listener runs in this order: stop accepting connections; cancel
8380
+ every hook call the same way (waiting calls stop at their next tick, queued
8381
+ ones leave the line); terminate the hook threads from inside the store lock,
8382
+ which also waits out any verb or mutation section still running, so no thread
8383
+ is killed while holding the log's append lockfile; and only then destroy the
8384
+ remaining sockets. A question a cancelled call opened stays open for the retry
8385
+ grace, exactly as for an `approval hook` process that was killed mid-wait.
8386
+ Shutdown therefore takes as long as the longest work already holding the store
8387
+ lock (a `POST /verb/wait` runs to its own timeout), so a platform that kills
8388
+ the process a fixed time after SIGTERM (a container's stop timeout, a
8389
+ supervisor's kill timeout) should allow more than that, or the kill lands on
8390
+ work the lock was protecting. A hook call whose thread fails answers a
8391
+ refusal, `serve-hook-failed`, with the harness's own block directive in
8392
+ `stdout`.
8393
+
8394
+ ## muse
8395
+
8396
+ `approval muse` starts a local, single-tenant synthetic consumer facade. The
8397
+ operator fixes the tenant and distinct read/propose credentials in the launch
8398
+ environment and the store root with `--dir` before the listener binds to
8399
+ loopback. The proposal
8400
+ scope can register an in-memory envelope and request its declared action. The
8401
+ read scope sees status, pending requests and the canonical rendering of a live
8402
+ request. Both scopes are narrow: the listener has no grant, reject, execution,
8403
+ token, generic verb or export route. Human decisions use the separately
8404
+ configured Telegram listener. A pending request does not prove delivery to
8405
+ Telegram. See `docs/muse-connector.md` for the local HTTP contract, native
8406
+ evidence limits and prerequisites.
8407
+
6338
8408
  ## Constrained Codex preparation
6339
8409
 
6340
8410
  approval codex prepare is an artifact generator. It writes one fresh review
@@ -6473,7 +8543,9 @@ The deadline is the policy's `approval_ttl`, not a harness ceiling. Every hook
6473
8543
  adapter answers inside a timeout its harness sets, and the retry grace exists so
6474
8544
  a denial-by-deadline is recoverable; this transport has no timeout at all, so a
6475
8545
  human who answers in eleven minutes is answering rather than arriving too late.
6476
- `--wait` overrides it.
8546
+ `--wait` overrides it. The bridge therefore states no `--harness-cap` and the
8547
+ window shortening of APRV-423 never applies to it: there is no process here that
8548
+ something else will kill, so there is nothing for a cap to keep ahead of.
6477
8549
 
6478
8550
  It answers accept or decline only, in the vocabulary the request advertised
6479
8551
  through `availableDecisions`, matched exactly and never by prefix, so