approval-md 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +98 -13
  3. package/cli.js +66 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +19 -0
  8. package/dist/src/channels/contract.js +105 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +271 -1
  22. package/dist/src/channels/telegram.js +403 -45
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/cli/amend.js +350 -4
  25. package/dist/src/cli/amend.js.map +1 -1
  26. package/dist/src/cli/attest.js +43 -6
  27. package/dist/src/cli/attest.js.map +1 -1
  28. package/dist/src/cli/channel-relay.d.ts +108 -0
  29. package/dist/src/cli/channel-relay.js +334 -0
  30. package/dist/src/cli/channel-relay.js.map +1 -0
  31. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  32. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  33. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  34. package/dist/src/cli/channel-telegram.d.ts +272 -26
  35. package/dist/src/cli/channel-telegram.js +478 -41
  36. package/dist/src/cli/channel-telegram.js.map +1 -1
  37. package/dist/src/cli/channel.d.ts +1 -1
  38. package/dist/src/cli/channel.js +5 -1
  39. package/dist/src/cli/channel.js.map +1 -1
  40. package/dist/src/cli/daemon.js +37 -5
  41. package/dist/src/cli/daemon.js.map +1 -1
  42. package/dist/src/cli/doctor.d.ts +63 -0
  43. package/dist/src/cli/doctor.js +856 -111
  44. package/dist/src/cli/doctor.js.map +1 -1
  45. package/dist/src/cli/execute.d.ts +17 -1
  46. package/dist/src/cli/execute.js +341 -20
  47. package/dist/src/cli/execute.js.map +1 -1
  48. package/dist/src/cli/exit-codes.d.ts +12 -0
  49. package/dist/src/cli/exit-codes.js +13 -0
  50. package/dist/src/cli/exit-codes.js.map +1 -1
  51. package/dist/src/cli/gate.d.ts +2 -0
  52. package/dist/src/cli/gate.js +233 -2
  53. package/dist/src/cli/gate.js.map +1 -1
  54. package/dist/src/cli/help.d.ts +9 -1
  55. package/dist/src/cli/help.js +246 -38
  56. package/dist/src/cli/help.js.map +1 -1
  57. package/dist/src/cli/hook.d.ts +371 -3
  58. package/dist/src/cli/hook.js +1926 -44
  59. package/dist/src/cli/hook.js.map +1 -1
  60. package/dist/src/cli/init.js +16 -7
  61. package/dist/src/cli/init.js.map +1 -1
  62. package/dist/src/cli/instructions.js +24 -3
  63. package/dist/src/cli/instructions.js.map +1 -1
  64. package/dist/src/cli/log-anchor.d.ts +55 -0
  65. package/dist/src/cli/log-anchor.js +199 -60
  66. package/dist/src/cli/log-anchor.js.map +1 -1
  67. package/dist/src/cli/main.d.ts +0 -12
  68. package/dist/src/cli/main.js +51 -0
  69. package/dist/src/cli/main.js.map +1 -1
  70. package/dist/src/cli/muse.d.ts +2 -0
  71. package/dist/src/cli/muse.js +62 -0
  72. package/dist/src/cli/muse.js.map +1 -0
  73. package/dist/src/cli/payload.js +79 -1
  74. package/dist/src/cli/payload.js.map +1 -1
  75. package/dist/src/cli/scaffold.js +6 -0
  76. package/dist/src/cli/scaffold.js.map +1 -1
  77. package/dist/src/cli/serve.d.ts +53 -0
  78. package/dist/src/cli/serve.js +270 -0
  79. package/dist/src/cli/serve.js.map +1 -0
  80. package/dist/src/cli/up.js +21 -2
  81. package/dist/src/cli/up.js.map +1 -1
  82. package/dist/src/cli/verb-registry.d.ts +2 -0
  83. package/dist/src/cli/verb-registry.js +337 -39
  84. package/dist/src/cli/verb-registry.js.map +1 -1
  85. package/dist/src/cli/wordmark.d.ts +2 -2
  86. package/dist/src/cli/wordmark.js +2 -2
  87. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  88. package/dist/src/core/attest-bootstrap.js +135 -0
  89. package/dist/src/core/attest-bootstrap.js.map +1 -0
  90. package/dist/src/core/attest.d.ts +20 -3
  91. package/dist/src/core/attest.js +25 -0
  92. package/dist/src/core/attest.js.map +1 -1
  93. package/dist/src/core/audit.d.ts +55 -3
  94. package/dist/src/core/audit.js +52 -5
  95. package/dist/src/core/audit.js.map +1 -1
  96. package/dist/src/core/channel-lease.d.ts +322 -0
  97. package/dist/src/core/channel-lease.js +663 -0
  98. package/dist/src/core/channel-lease.js.map +1 -0
  99. package/dist/src/core/command-class.d.ts +51 -0
  100. package/dist/src/core/command-class.js +760 -8
  101. package/dist/src/core/command-class.js.map +1 -1
  102. package/dist/src/core/daemon-host.d.ts +178 -0
  103. package/dist/src/core/daemon-host.js +228 -0
  104. package/dist/src/core/daemon-host.js.map +1 -0
  105. package/dist/src/core/daemon-identity.d.ts +238 -0
  106. package/dist/src/core/daemon-identity.js +252 -0
  107. package/dist/src/core/daemon-identity.js.map +1 -0
  108. package/dist/src/core/gate.d.ts +306 -5
  109. package/dist/src/core/gate.js +772 -24
  110. package/dist/src/core/gate.js.map +1 -1
  111. package/dist/src/core/harness-version.d.ts +128 -4
  112. package/dist/src/core/harness-version.js +179 -12
  113. package/dist/src/core/harness-version.js.map +1 -1
  114. package/dist/src/core/harness-wait.d.ts +82 -3
  115. package/dist/src/core/harness-wait.js +89 -3
  116. package/dist/src/core/harness-wait.js.map +1 -1
  117. package/dist/src/core/log-subscribe.d.ts +21 -0
  118. package/dist/src/core/log-subscribe.js +16 -6
  119. package/dist/src/core/log-subscribe.js.map +1 -1
  120. package/dist/src/core/log-write-layer.d.ts +55 -0
  121. package/dist/src/core/log-write-layer.js +91 -0
  122. package/dist/src/core/log-write-layer.js.map +1 -0
  123. package/dist/src/core/log.d.ts +50 -21
  124. package/dist/src/core/log.js +197 -19
  125. package/dist/src/core/log.js.map +1 -1
  126. package/dist/src/core/payload-census.d.ts +51 -0
  127. package/dist/src/core/payload-census.js +125 -2
  128. package/dist/src/core/payload-census.js.map +1 -1
  129. package/dist/src/core/payload-store.d.ts +31 -0
  130. package/dist/src/core/payload-store.js +113 -8
  131. package/dist/src/core/payload-store.js.map +1 -1
  132. package/dist/src/core/payload.d.ts +18 -3
  133. package/dist/src/core/payload.js +20 -4
  134. package/dist/src/core/payload.js.map +1 -1
  135. package/dist/src/core/policy-diff.d.ts +10 -0
  136. package/dist/src/core/policy-diff.js +10 -2
  137. package/dist/src/core/policy-diff.js.map +1 -1
  138. package/dist/src/core/policy-explain.d.ts +7 -1
  139. package/dist/src/core/policy-explain.js +16 -1
  140. package/dist/src/core/policy-explain.js.map +1 -1
  141. package/dist/src/core/policy-load.d.ts +24 -0
  142. package/dist/src/core/policy-load.js.map +1 -1
  143. package/dist/src/core/policy-match.d.ts +38 -0
  144. package/dist/src/core/policy-match.js +35 -0
  145. package/dist/src/core/policy-match.js.map +1 -1
  146. package/dist/src/core/policy-proposal.d.ts +23 -0
  147. package/dist/src/core/policy-proposal.js +8 -2
  148. package/dist/src/core/policy-proposal.js.map +1 -1
  149. package/dist/src/core/read-scope.d.ts +10 -0
  150. package/dist/src/core/read-scope.js +4 -0
  151. package/dist/src/core/read-scope.js.map +1 -1
  152. package/dist/src/core/run-payload.d.ts +160 -0
  153. package/dist/src/core/run-payload.js +304 -0
  154. package/dist/src/core/run-payload.js.map +1 -0
  155. package/dist/src/core/sender-identity.d.ts +35 -10
  156. package/dist/src/core/sender-identity.js +48 -11
  157. package/dist/src/core/sender-identity.js.map +1 -1
  158. package/dist/src/core/state.d.ts +52 -1
  159. package/dist/src/core/state.js +52 -6
  160. package/dist/src/core/state.js.map +1 -1
  161. package/dist/src/core/telegram-config.d.ts +79 -0
  162. package/dist/src/core/telegram-config.js +108 -0
  163. package/dist/src/core/telegram-config.js.map +1 -1
  164. package/dist/src/core/verify.d.ts +23 -1
  165. package/dist/src/core/verify.js +20 -2
  166. package/dist/src/core/verify.js.map +1 -1
  167. package/dist/src/daemon/audit.d.ts +80 -5
  168. package/dist/src/daemon/audit.js +126 -6
  169. package/dist/src/daemon/audit.js.map +1 -1
  170. package/dist/src/daemon/daemon.d.ts +174 -2
  171. package/dist/src/daemon/daemon.js +298 -7
  172. package/dist/src/daemon/daemon.js.map +1 -1
  173. package/dist/src/daemon/git-evidence.d.ts +2 -2
  174. package/dist/src/daemon/git-evidence.js +1 -1
  175. package/dist/src/daemon/projection.d.ts +13 -2
  176. package/dist/src/daemon/projection.js +39 -6
  177. package/dist/src/daemon/projection.js.map +1 -1
  178. package/dist/src/mcp/server.d.ts +39 -3
  179. package/dist/src/mcp/server.js +22 -3
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/dist/src/muse/server.d.ts +16 -0
  182. package/dist/src/muse/server.js +238 -0
  183. package/dist/src/muse/server.js.map +1 -0
  184. package/dist/src/serve/archive.d.ts +158 -0
  185. package/dist/src/serve/archive.js +354 -0
  186. package/dist/src/serve/archive.js.map +1 -0
  187. package/dist/src/serve/arguments.d.ts +93 -0
  188. package/dist/src/serve/arguments.js +228 -0
  189. package/dist/src/serve/arguments.js.map +1 -0
  190. package/dist/src/serve/credentials.d.ts +91 -0
  191. package/dist/src/serve/credentials.js +143 -0
  192. package/dist/src/serve/credentials.js.map +1 -0
  193. package/dist/src/serve/follow.d.ts +86 -0
  194. package/dist/src/serve/follow.js +101 -0
  195. package/dist/src/serve/follow.js.map +1 -0
  196. package/dist/src/serve/hook-thread.d.ts +134 -0
  197. package/dist/src/serve/hook-thread.js +333 -0
  198. package/dist/src/serve/hook-thread.js.map +1 -0
  199. package/dist/src/serve/hook-worker.d.ts +134 -0
  200. package/dist/src/serve/hook-worker.js +173 -0
  201. package/dist/src/serve/hook-worker.js.map +1 -0
  202. package/dist/src/serve/server.d.ts +412 -0
  203. package/dist/src/serve/server.js +1251 -0
  204. package/dist/src/serve/server.js.map +1 -0
  205. package/docs/README-extended.md +766 -0
  206. package/docs/cli-reference.md +1633 -43
  207. package/package.json +4 -2
  208. package/schema/event.schema.json +59 -2
  209. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  210. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  211. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  212. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  213. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  214. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  215. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  216. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  217. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  218. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  219. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  220. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  221. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  222. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  223. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  224. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  225. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  226. package/schema/policy.schema.json +50 -6
@@ -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
@@ -498,6 +507,11 @@ advance is the one that may carry `log.advance.daemon` instead, and only from
498
507
  inside the daemon process: see the `--advance` paragraph under `daemon run`
499
508
  (APRV-382).
500
509
 
510
+ An advance publishes the WHOLE log, so while its branch is open on origin it is
511
+ the log's publisher and `policy amend` stands aside: an amendment opened in that
512
+ window carries only the policy bytes and cannot conflict with the records. See
513
+ "Who publishes the log" under `policy amend` (APRV-420).
514
+
501
515
  `--co-author "Name <email>"` adds one validated `Co-authored-by` trailer to the
502
516
  generated records commit and to the pull request body. When the day's pull
503
517
  request already exists, the verb preserves its body and adds the trailer once.
@@ -669,9 +683,18 @@ channel; policy-authorized execution is never represented as a human grant.
669
683
  "autonomy":"autonomous","winner":true,
670
684
  "tieBreak":"specificity"|"strictest-autonomy"|
671
685
  "lexicographic"|"tied-specificity"}],
686
+ "agentRequest":{"allowed":false,"explicit":true,"pattern":null},
672
687
  "decisionPath":["...","..."]}
673
688
  ```
674
689
 
690
+ `agentRequest` (APRV-445) answers whether `approval propose` would admit this
691
+ class: `explicit` is whether the class is an exact key of `classes`, `pattern`
692
+ is the key whose `agent_may_request` decided (the exact key, or the most
693
+ specific declared `<prefix>.*` family that states one), and `allowed` is both.
694
+ It is read without the resolution above, because a wildcard match never makes a
695
+ class proposable. A `decisionPath` line appears only when some key states the
696
+ flag for this class.
697
+
675
698
  `specificity` is [literalSegments, wildcardSegments, totalSegments] (SPEC §5.2).
676
699
  `overridden.pattern` is null when the floor overrode `defaults.autonomy` rather
677
700
  than a rule.
@@ -741,6 +764,62 @@ existed still validate and verify; a reader treats the absent field as the
741
764
  pre-amendment state, where the in-force bytes are unrecoverable and the
742
765
  fail-closed fallback applies.
743
766
 
767
+ **No terminal is needed.** The identity is declared, so the verb runs the same
768
+ from a provisioning script with stdin at `/dev/null` as from a shell. The
769
+ `attest-requires-terminal` refusal belongs to the channel path (a tap cannot
770
+ attest a policy that maps no sender for that channel), never to this verb.
771
+
772
+ ### `--bootstrap`: a store's first policy, safe to re-run (APRV-449)
773
+
774
+ The plain verb is an unconditional assertion: it reads no log and appends a
775
+ `policy.updated` every time it runs. That is right for a human at a terminal and
776
+ wrong for a provisioning step that runs again on every update and recreate of a
777
+ hosted tenant, where a re-run would add an attestation per redeploy and, once
778
+ the tenant has attested or amended their own policy, would put the operator back
779
+ on record as its attester. `--bootstrap` reads the VERIFIED log first:
780
+
781
+ ```
782
+ approval policy attest --bootstrap --as human:<operator> [--json]
783
+ ```
784
+
785
+ | the log carries | answer | exit |
786
+ |---|---|---|
787
+ | no attestation | attests, exactly as the plain verb | 0 |
788
+ | an attestation of these exact bytes | `policy-already-attested`, nothing appended | 1 |
789
+ | an attestation of other bytes | `policy-amendment-required`, nothing appended | 1 |
790
+
791
+ Both refusals add `seq` and `attested_by` (the attestation in force and who made
792
+ it) to the error object:
793
+
794
+ ```
795
+ refusal {"ok":false,"error":{"code":"policy-already-attested","message":"...",
796
+ "seq":1,"attested_by":"human:carter"}} on stderr
797
+ ```
798
+
799
+ `policy-already-attested` is the expected answer on a re-run, so a provisioning
800
+ step branches on `error.code` and treats that one code as done.
801
+ `policy-amendment-required` means the bytes on disk moved after the store was
802
+ attested. The operator does not attest that change: a change to an attested
803
+ policy is an amendment, and an amendment is the approver's act through a channel
804
+ (SPEC.md §10.3). The append is compare-and-append against the head the decision
805
+ was read from, and binds the digest it checked, so an attestation landing in
806
+ between, or a file rewritten in between, refuses (`head-moved`, or `io` naming
807
+ the changed bytes) rather than stacking an operator attestation on top. A torn
808
+ log is exit 3 and a log that does not verify is `log-corrupt` at exit 1.
809
+
810
+ With `--bootstrap` the log is the store's: it resolves under `--dir`
811
+ (`<dir>/.approval/log/events.jsonl`) unless `--log` names one, so `approval
812
+ policy attest --bootstrap --dir "$STORE"` attests the store's own log from any
813
+ working directory. The plain verb keeps its working-directory default. A
814
+ refused append can leave one unbound copy of the policy text in the payload
815
+ store, because the text is stored before the append (APRV-356). It is not
816
+ removed: the record that moved the head may be another attestation of the same
817
+ bytes, which binds that very file. An unbound file is inert, and `approval
818
+ status` counts it under `payload_store.orphans`.
819
+
820
+ `--bootstrap` beside `--organ` or `--path` is a usage error. The whole
821
+ provisioning sequence is [docs/hosted-provisioning.md](hosted-provisioning.md).
822
+
744
823
  ### `--organ <path>`: the gate's organs (APRV-272)
745
824
 
746
825
  The ORGANS are the harness files that install the hook: `.claude/settings*`,
@@ -1035,6 +1114,69 @@ base carries it, and a pins edit somebody else landed is not reverted by this
1035
1114
  ceremony. The pin deltas print in the semantic diff beside the class deltas, ride
1036
1115
  in the commit subject, and appear in `--json` as `pins`.
1037
1116
 
1117
+ **Who publishes the log (APRV-420).** The log is in that file set only when this
1118
+ amendment is the thing publishing it. On 2026-09-21 it always was, and PR 530
1119
+ paid for it: two records advances merged first carrying the same records (the
1120
+ attestation at seq 65736 and its payload reached main through PR 531), and git
1121
+ does not care that one side's appended lines are a prefix of the other's, so the
1122
+ amendment went DIRTY, lost its arm, and the repair was a hand merge of
1123
+ `events.jsonl` in a throwaway worktree.
1124
+
1125
+ The cause was two publishers carrying the same bytes, so the ceremony now leaves
1126
+ one of them holding it. It asks two questions of the base it is about to commit
1127
+ on, in this order:
1128
+
1129
+ 1. does the base's log already carry every record this attestation added? Then
1130
+ the log is published and the amendment has nothing to add to it.
1131
+ 2. is a records advance live, meaning origin carries a `records-log-*` branch?
1132
+ That advance publishes the log, so the amendment has nothing to add to it
1133
+ either. Whether the branch already carries THIS attestation is checked
1134
+ rather than assumed: the ceremony fetches the branch and compares chains.
1135
+ An advance pushed before the human signed does not carry it, which is the
1136
+ ordinary order; the ceremony then says so, the next advance publishes the
1137
+ record, and the pull request's protected-path guard holds the policy
1138
+ change until a records branch or main carries it.
1139
+
1140
+ Either way the commit is the policy bytes, the attested text and the pins, and a
1141
+ commit that does not touch `events.jsonl` cannot conflict on `events.jsonl`,
1142
+ whichever side lands first. Otherwise the amendment is the only publisher and
1143
+ carries the log, exactly as it always has. The ceremony prints which of the three
1144
+ happened, so the pull request's file list is never a surprise.
1145
+
1146
+ The question is asked with `git ls-remote`, not `gh`, so it answers the same on a
1147
+ box with no GitHub CLI and no token. And it fails toward CARRYING: anything the
1148
+ check cannot establish leaves the amendment holding the log. A dirty pull request
1149
+ is a nuisance; an attestation that reaches `main` in nobody's commit is a policy
1150
+ in force that the committed log does not record.
1151
+
1152
+ **A re-run repairs the pull request it finds (APRV-420).** A second run used to
1153
+ stop at `nothing to amend`, which is true and useless: the live policy does match
1154
+ its attestation, and the pull request carrying that attestation is open,
1155
+ unmergeable and unarmed. So when `--commit` or `--pr` is asked for and origin
1156
+ already carries the amendment branch, the re-run fetches the default branch and
1157
+ computes what it still LACKS — the policy bytes, the attested text, the pins, and
1158
+ the log under the rule above — and then:
1159
+
1160
+ - nothing lacking: the amendment landed in full. It says so and prints `gh pr
1161
+ close <branch> --delete-branch`.
1162
+ - something lacking: it rebuilds that commit on the CURRENT default branch and
1163
+ force-updates the branch, then re-arms `gh pr merge <branch> --merge --auto`.
1164
+ The trunk's log is a superset of the amendment's by construction, so rebuilding
1165
+ on it is the re-merge without a merge.
1166
+
1167
+ The rebuild is `commitOnBase`'s scratch index like every other commit this verb
1168
+ makes, so nothing is checked out and no live appender has the log moved
1169
+ underneath it. The force-push is the one in this codebase, and it is bounded: the
1170
+ remote branch must be a single commit on top of a commit the base already
1171
+ contains, which is the shape this ceremony creates and nothing else. A branch
1172
+ carrying anything more is reported with `git log --oneline <base>..<tip>` and
1173
+ left alone. `--dry-run` and `--no-publish` compute the repair, print the
1174
+ commands and run none of it. `--json` carries a `repair` object (`branch`,
1175
+ `state` of `landed`/`repaired`/`owed`/`failed`, `base`, `carried`, `commit`,
1176
+ `pushed`, `autoMerge`, `commands`, `message`), additively and always present:
1177
+ `null` on every run that had an amendment to make and on every no-op that found
1178
+ no branch standing. Only `failed` is a nonzero exit.
1179
+
1038
1180
  It refuses outside a git repository (`commit-preconditions`), and refuses
1039
1181
  `staged-unrelated` when the index holds staged changes to anything beyond those
1040
1182
  three: a commit that swept in an unrelated staged edit would make "this commit is
@@ -1524,6 +1666,24 @@ hash must equal the declared `payload_hash` and it is filed in
1524
1666
  bytes from. Supply it here once and no channel needs `--payload-dir` or
1525
1667
  `--payloads` at all.
1526
1668
 
1669
+ For an action `approval run` will execute, produce those bytes with `approval
1670
+ payload run -- <cmd…>` rather than by hand. Since APRV-401 the payload carries
1671
+ the size and digest of the script the argv names, and a hand-written
1672
+ `{"argv":…,"cwd":…}` for a command that names a script is a declaration the
1673
+ execution will refuse `payload-mismatch`, correctly, because it describes bytes
1674
+ nobody bound:
1675
+
1676
+ ```
1677
+ approval payload run --hash -- bash scripts/install.sh # the declaration
1678
+ approval payload run -- bash scripts/install.sh \
1679
+ | approval request <task> --action <key> --as agent:<id> --payload -
1680
+ ```
1681
+
1682
+ Material supplied here is checked against the declared hash and never against
1683
+ the filesystem: this verb does not know where the run will happen, and the
1684
+ guarantee is that `approval run` recomputes the same value from the tree it is
1685
+ about to spawn into and refuses on any difference.
1686
+
1527
1687
  When the policy permits an unattended action, supplied material is still checked
1528
1688
  against the registered action's task, class and payload hash and retained before
1529
1689
  `proceed:true` is returned. This creates no approval event and does not require
@@ -1545,6 +1705,129 @@ refusal {"ok":false,"error":{"code":"...","message":"...",
1545
1705
  The refusal goes to stderr, and `seq` is the `budget.exceeded` record that WAS
1546
1706
  appended.
1547
1707
 
1708
+ ## propose
1709
+
1710
+ `approval propose --class <c> --key <k> --summary <s> --payload-json <json>`
1711
+ registers one action and requests it in one call (APRV-445, amended SPEC.md
1712
+ §10.1). It exists for a requester that holds its payload in memory and has no
1713
+ file on the gate's machine: an agent in a sandbox reaching `approval serve`
1714
+ with the agent credential, which refuses every path flag. `--payload-json` is
1715
+ the payload as the flag's VALUE, a JSON object of at most 262144 bytes of UTF-8
1716
+ as sent; an array, string, number or `null` is a usage error, and a larger
1717
+ payload is refused `payload-too-large` at exit 2 before it is parsed.
1718
+
1719
+ **Why this verb may name its class.** `request` never does, so that an agent
1720
+ cannot call a `financial.spend` a `read.web`. Here the agent is also the
1721
+ registrant, so the bound is the operator's: the class must be an EXACT key of
1722
+ the policy's `classes` (a wildcard rule that happens to match does not count),
1723
+ and that key or a declared `<prefix>.*` family of it must set
1724
+ `agent_may_request: true` (SPEC.md §5.2). Anything else refuses
1725
+ `class-not-agent-requestable` and registers nothing. A `human-only` member
1726
+ under an open family refuses `class-human-only`, also before anything is
1727
+ registered. The check runs again inside the request's own read-check-append
1728
+ cycle, so a re-attestation in between cannot slip a closed class through. What
1729
+ the agent picks AMONG the opened classes is still its own statement: an
1730
+ operator who opens a manual class and an autonomous one under the same family
1731
+ is trusting the agent to pick the right one, and the record names the class it
1732
+ picked.
1733
+
1734
+ ```yaml approval-policy
1735
+ classes:
1736
+ intent.publish.*: { autonomy: manual, agent_may_request: true }
1737
+ intent.publish.inferred.index: { autonomy: manual }
1738
+ intent.publish.stated.index: { autonomy: autonomous }
1739
+ ```
1740
+
1741
+ **The key** must begin with `<class>:` and an id of printable characters with
1742
+ no whitespace or control characters (refused
1743
+ `key-class-mismatch`, exit 2): `intent.publish.inferred.index:<intention id>`.
1744
+ It is recorded verbatim as `action_key`, and a consumer reads the class from the
1745
+ record rather than parsing it back out of the key. The key is at most 1024 bytes
1746
+ and `--summary` at most 4096; a lone UTF-16 surrogate in either or anywhere in
1747
+ the payload, and a number JSON parses to Infinity (`1e400`), are usage errors. An
1748
+ integer beyond 2^53 is hashed as the double it parses to, so send large ids as
1749
+ strings.
1750
+
1751
+ **Do not open a `supervised-live` class to agents.** The live draw would decide,
1752
+ per proposal, whether a human is asked at all, which the requester can neither
1753
+ predict nor explain to the person whose words it is proposing.
1754
+
1755
+ **The task id** is `propose:` and the first 32 hex digits of the SHA-256 over
1756
+ the RFC 8785 canonical JSON of `[actor, class, key]`. The actor is the tenant
1757
+ half: one `approval serve` process is one store and one fixed `agent:<id>`. Two
1758
+ actors proposing one key derive two tasks, and the second registration is
1759
+ refused `task-already-registered` because the key is already declared under the
1760
+ first.
1761
+
1762
+ **Records.** One `task.registered` (actor = the caller, envelope `origin.app:
1763
+ approval-propose`, one action: the class, the summary, `idempotency_key` = the
1764
+ key, `payload_hash`) and one `approval.requested` exactly as `request` writes it,
1765
+ with `action_key` = the key verbatim, `summary`, `payload_hash`, `display_hash`,
1766
+ `policy_sha256` and `execution: "harness"` (a grant mints no token; the
1767
+ requester proceeds on the grant and records it with `start`). The summary is in
1768
+ the log in cleartext, twice; the payload is never in the log, only in the
1769
+ payload store (`.approval/payloads/<hash>.json`, mode 0600 in a 0700 directory),
1770
+ which the tenant credential's `GET /export` includes and the agent credential
1771
+ can never read back. Set `payload_retention` so the daemon prunes bytes whose
1772
+ action has finished.
1773
+
1774
+ **Idempotency.** The same class, key and bytes append nothing and answer the
1775
+ request's standing with `idempotent: true` while it still means something:
1776
+ pending, granted and spendable, rejected, revoked, or executed (`state:
1777
+ "executed"`, after `start`). The same call ASKS AGAIN, under the same
1778
+ registration, when the request can no longer be answered usefully: withdrawn,
1779
+ expired, a grant whose request window lapsed (a grant has no window of its own,
1780
+ so a tap at hour 71 of 72 authorizes nothing at hour 73), or a grant or pending
1781
+ request pinned to a policy that has since been re-attested (a pending one is
1782
+ first withdrawn, reason `superseded`). Two identical calls racing to register
1783
+ are one proposal. The same key with different bytes refuses
1784
+ `duplicate-request` while a request is live and `payload-mismatch` otherwise: a
1785
+ key is bound to the bytes it was first proposed with. A plain `approval request`
1786
+ on a `propose:` task is refused `task-is-proposal`.
1787
+
1788
+ **Off the manual path.** A class that resolves `autonomous` or `supervised` is
1789
+ registered and answered at once with `decision: "autonomous"` (or
1790
+ `"supervised"`) and no approval record. Act, then record it with `start`.
1791
+
1792
+ **`--json`** (one object on stdout):
1793
+
1794
+ ```
1795
+ manual {"ok":true,"task":"propose:<32 hex>","action_key":"<k>","class":"<c>",
1796
+ "payload_hash":"<64 hex>","decision":"requested","state":"requested",
1797
+ "seq":7,"idempotent":false}
1798
+ retry {...,"state":"granted","seq":9,"idempotent":true}
1799
+ autonomous {...,"decision":"autonomous","state":null,"seq":null,"idempotent":false}
1800
+ ```
1801
+
1802
+ Poll a `requested` proposal with `approval wait <task> --timeout 0`.
1803
+
1804
+ ## start
1805
+
1806
+ `approval start <task> --action <key> --payload-json <json>` appends the one
1807
+ `execution.started` for an action its requester is about to carry out itself,
1808
+ outside any harness hook (APRV-445). Proposals only: any other task is refused
1809
+ `task-not-proposal`. REQUESTER-ONLY: the caller must be the actor
1810
+ that registered the task (`not-requester`). The bytes presented must hash to the
1811
+ registered `payload_hash` (`payload-mismatch`), so what is done is what was
1812
+ proposed.
1813
+
1814
+ It writes through the two existing harness spenders and nothing else. A key
1815
+ whose request is pending or granted and unspent spends its harness grant: refused `not-granted` while
1816
+ pending or after a rejection, `expired` after the window, `policy-drift` under
1817
+ a re-attested policy. A key with no such request (none, or one withdrawn,
1818
+ expired, rejected or revoked, which binds nothing) is recorded as authorized by
1819
+ the policy itself: refused `not-granted` for a manual class and `class-human-only`
1820
+ for a human-only one, and charged against the budgets. A second start of one key
1821
+ is `already-executed`. The record carries `execution: "harness"`, which is why no
1822
+ `execution.completed` ever follows it.
1823
+
1824
+ **`--json`**:
1825
+
1826
+ ```
1827
+ {"ok":true,"task":"propose:<32 hex>","action_key":"<k>","class":"<c>",
1828
+ "authorization":"grant"|"policy","seq":12}
1829
+ ```
1830
+
1548
1831
  ## grant
1549
1832
 
1550
1833
  Legal only on a request that is awaiting a decision. A second decision is
@@ -1719,8 +2002,8 @@ refusal {"ok":false,"error":{"code":"...","message":"...","state"?:"..."}}
1719
2002
 
1720
2003
  ## gate refusal codes
1721
2004
 
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
2005
+ The vocabulary every gate verb (register, request, propose, start, grant,
2006
+ reject, revoke, withdraw, expire) returns in `error.code` with `--json`. Frozen public API: an agent branches on it
1724
2007
  to decide whether to fix itself, stop retrying, or ask a human.
1725
2008
 
1726
2009
  - `policy-not-attested` — policy unattested or its bytes changed since
@@ -1784,7 +2067,8 @@ to decide whether to fix itself, stop retrying, or ask a human.
1784
2067
  it, or is withdrawing one it already withdrew. Distinct from `already-decided`:
1785
2068
  nobody answered, and nobody can now. Request the action again.
1786
2069
  - `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;
2070
+ that opened the request, or a `start` by an actor other than the one that
2071
+ registered the task. Only the party that asked may take the question back;
1788
2072
  anyone else who wants it gone rejects it.
1789
2073
  - `expired` — the TTL lapsed, judged from the request's own `ts`.
1790
2074
  - `not-expired` — expire was called before the TTL lapsed, or the policy declares
@@ -1822,6 +2106,20 @@ to decide whether to fix itself, stop retrying, or ask a human.
1822
2106
  at the verb (exit 2) rather than a member of this union.
1823
2107
  - `log-unreadable` (exit 4) / `log-torn-tail` (exit 3) / `log-corrupt` (exit 1):
1824
2108
  nothing is authorized from a log that does not verify.
2109
+ - `class-not-agent-requestable` — `propose` named a class the policy has not
2110
+ opened to agents: not an exact key of `classes`, or neither that key nor a
2111
+ declared `<prefix>.*` family sets `agent_may_request: true`. Nothing is
2112
+ registered or appended; the repair is a policy edit and a human attestation.
2113
+ - `payload-too-large` (exit 2) — a `propose` or `start` payload over 262144
2114
+ bytes of UTF-8. Refused before it is parsed; nothing is stored.
2115
+ - `task-is-proposal` — a plain `request` on a task `propose` registered, or a
2116
+ task file claiming an id in the `propose:` namespace. Nothing is appended.
2117
+ - `task-not-proposal` — `start` on a task `propose` did not register.
2118
+ - `key-class-mismatch` (exit 2) — a `propose` key that does not begin with
2119
+ `<class>:` and an identifier.
2120
+ - `contended` — `propose` restarted its bounded number of times on a log that
2121
+ kept moving under it and stopped; nothing more was appended. Read the
2122
+ standing with `wait --timeout 0`, or call again.
1825
2123
  - `append-failed` — the append itself failed; the exit code follows the cause.
1826
2124
  `head-moved` means the log grew between this command's read and its write, so
1827
2125
  nothing was written. Since APRV-236 you see it only after the command has
@@ -1953,9 +2251,23 @@ measurement.
1953
2251
  Content binding (amended SPEC.md §6.2, §10.4): run computes the hash of the argv
1954
2252
  and cwd it is about to spawn, always, and presents that. The command IS the
1955
2253
  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
2254
+ told wrong.
2255
+
2256
+ Since APRV-401 that computation includes THE SCRIPT THE ARGV NAMES. A known
2257
+ interpreter followed by a path operand, or a path at `argv[0]`, carries that
2258
+ file's size and SHA-256 inside the hashed payload, so a grant over `bash
2259
+ install.sh` binds the script's bytes rather than its path: a script edited
2260
+ between the declaration and the run hashes differently and is refused
2261
+ `payload-mismatch`, with nothing appended and the grant still live for the
2262
+ approved bytes. An argv naming no script hashes exactly as it always did.
2263
+ `approval payload run` prints the same value, which is how a requester obtains
2264
+ the declaration in the first place; `docs/run-payload-binding.md` states the
2265
+ rule and the six things it does not reach.
2266
+
2267
+ `--payload-hash` is consequently a CHECK and never a substitute
1957
2268
  (APRV-140): a value differing from the recomputed one is refused
1958
- `payload-mismatch` before the child exists and before anything is appended. An
2269
+ `payload-mismatch` before the child exists and before anything is appended, and
2270
+ the refusal names the script it bound so the repair is visible. An
1959
2271
  action whose payload is content rather than an argv (an email body, a record
1960
2272
  write, a message and its recipients) is executed through the adapter contract of
1961
2273
  §10.4, `approval adapter email`, which hashes those bytes itself; a token for
@@ -2154,6 +2466,33 @@ costs, and the carve-outs.
2154
2466
 
2155
2467
  ## wait
2156
2468
 
2469
+ `--timeout 0` (also `0s`, `0ms`) reads the current state once and never sleeps
2470
+ (APRV-445): an undecided request answers `timeout`, exit 6, at once, and a
2471
+ decided one answers as it always would. Use it to poll through `approval serve`,
2472
+ which runs every verb call whole under the store lock that hook calls take for
2473
+ their appends (APRV-427), so a wait that sleeps there holds the tenant's hook
2474
+ traffic for as long as it sleeps (APRV-441). The
2475
+ duration grammar has no zero anywhere else. `--withdraw-on-timeout` is refused
2476
+ beside it (exit 2): a status read that withdrew would retract every question it
2477
+ looked at.
2478
+
2479
+ **What a waiter is told is what the request still authorizes** (APRV-445). A
2480
+ grant whose request window has lapsed reads `expired` (exit 3), not `granted`. A
2481
+ pending request pinned to a policy hash that is no longer the attested one (any
2482
+ task), a HARNESS grant so pinned (`execution: "harness"`, every proposal
2483
+ included: its spend refuses `policy-drift`), or a request the runtime withdrew
2484
+ for `policy-drift`, reads `void`, **exit 7**: the answer can never be used, and
2485
+ the repair is to ask again (for a proposal, the same `propose` call re-files). A
2486
+ token grant is NOT void after a re-attest: `approval run` spends it without
2487
+ comparing policy hashes, so `wait && run` keeps working and `wait` reads
2488
+ `granted`. Precedence: rejected > withdrawn >
2489
+ void > expired > granted > executed > nothing-to-wait-for. An action that ran
2490
+ on the policy's own authority after its request ended (a `start` after a
2491
+ withdrawal and a re-tiering) reads `executed`, status `executed`, exit 0. A
2492
+ registered task with no requests answers `nothing-to-wait-for` at exit 0, and an
2493
+ unknown task id is refused `not-registered` at exit 1 (APRV-428), so poll the
2494
+ task id `propose` returned.
2495
+
2157
2496
  Polls the log and writes nothing — not even the `approval.expired` event it may
2158
2497
  derive: expiry is judged lazily from the request's own timestamp, and
2159
2498
  materialising it is `approval expire`'s job, not a reader's. The one exception is
@@ -2162,9 +2501,27 @@ materialising it is `approval expire`'s job, not a reader's. The one exception i
2162
2501
  For `approval wait` the exit code IS the decision (SPEC.md §10.1). The
2163
2502
  overloading of 1 (integrity / rejected) and 3 (torn tail / expired) is
2164
2503
  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.
2504
+ own, and `--json` names the outcome exactly (`granted | executed | rejected |
2505
+ withdrawn | void | expired | nothing-to-wait-for | timeout`) for callers that need more than a
2506
+ number. Flagged for human review.
2507
+
2508
+ **Nothing to wait for, and nothing registered** (APRV-428). Exit 0 has always
2509
+ answered at once for a task with no requests, because only the manual path
2510
+ produces requests and the supervised path tells an agent its `proceed: true`
2511
+ needs no wait. Its `status` is `nothing-to-wait-for`, never `granted`: a
2512
+ registered task with no `approval.requested` at all (no action declared, none
2513
+ reached the manual path, or none requested yet), or one whose every granted
2514
+ action has already started executing, so every grant is spent. `granted` means
2515
+ at least one granted action has not executed yet, and only that. The per-action
2516
+ rows are unchanged; an executed grant still reads `granted` there.
2517
+
2518
+ A task with no `task.registered` record is refused before anything is derived:
2519
+ `not-registered`, the gate union's code for exactly that condition (SPEC.md
2520
+ §11.2), at exit 1, the code `approval request` exits for the same refusal.
2521
+ Before APRV-428 such a wait answered `{"ok":true,"status":"granted","actions":[]}`,
2522
+ so a typo read as a grant. Through `approval serve` the verb's refusal passes
2523
+ through unchanged: the body's `stderr` carries the same object and `exit_code`
2524
+ is 1.
2168
2525
 
2169
2526
  `withdrawn` (APRV-106) reuses exit **1** rather than claiming a new number. The
2170
2527
  exit table in `src/cli/exit-codes.ts` is frozen public API and agents already
@@ -2192,10 +2549,11 @@ itself fails is reported on stderr and leaves the request live; the exit code is
2192
2549
 
2193
2550
  ```
2194
2551
  decided {"ok":true,"task":"task-042",
2195
- "status":"granted"|"rejected"|"withdrawn"|"expired",
2552
+ "status":"granted"|"rejected"|"withdrawn"|"expired"|"nothing-to-wait-for",
2196
2553
  "actions":[{"action_key":"...","state":"granted","seq":4}]}
2197
2554
  timeout {"ok":false,"task":"task-042","status":"timeout",
2198
2555
  "actions":[{"action_key":"...","state":"requested","seq":3}]}
2556
+ refused {"ok":false,"error":{"code":"not-registered","message":"..."}} (stderr, exit 1)
2199
2557
  ```
2200
2558
 
2201
2559
  **Sealed token delivery** (APRV-105). Under policy `defaults.token_delivery:
@@ -2227,8 +2585,8 @@ so the paste path is preserved rather than replaced.
2227
2585
  passed, listing the keys actually retracted; the default shape is unchanged.
2228
2586
 
2229
2587
  `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
2588
+ with rejected/revoked outranking withdrawn, withdrawn outranking expired,
2589
+ expired outranking granted, and granted outranking nothing-to-wait-for. `--timeout` and `--interval` take the SPEC.md §5.2
2232
2590
  duration grammar, `<positive integer><ms|s|m|h|d|w>`.
2233
2591
 
2234
2592
  ## queue
@@ -2252,11 +2610,16 @@ requested, and how much of the TTL is left.
2252
2610
  {"ok":true,"pending":[{"action_key":"task-042:chaser","task":"task-042",
2253
2611
  "class":"communicate.email.external","est_cost_usd":"0.02",
2254
2612
  "requested_ts":"2026-08-06T10:00:00.000Z","seq":3,
2255
- "ttl_remaining_ms":3599000}]}
2613
+ "ttl_remaining_ms":3599000,"ttl_ms":3600000}]}
2256
2614
  ```
2257
2615
 
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).
2616
+ `pending` is `[]` for an empty inbox. Both TTL fields are measured against the
2617
+ window the gate judges the request by (APRV-423): the policy's
2618
+ `defaults.approval_ttl`, narrowed by the harness ceiling the requesting hook
2619
+ declared, if any. `ttl_ms` is that whole window and `ttl_remaining_ms` is what is
2620
+ left of it; both are null only when nothing bounds the request (no TTL and no
2621
+ cap means no lapse). A hook-opened request under a policy with no TTL therefore
2622
+ shows its real remaining window here rather than `null`.
2260
2623
 
2261
2624
  ## gate
2262
2625
 
@@ -2397,9 +2760,47 @@ informational: it moves neither the health verdict nor the exit code. An empty
2397
2760
  store is the normal state of a repo that has never made a request carrying
2398
2761
  `--payload`. (`approval doctor` is where an unwritable store is a failure.)
2399
2762
 
2763
+ **attestation** names the policy in force: its state, the seq of the attestation
2764
+ the log last recorded, and `attested_by`, the actor of that record (APRV-449).
2765
+ The identity is read from the verified record and never from the file, so a
2766
+ hosted tenant can see whether their starting policy is the operator's
2767
+ bootstrap or their own later act (docs/hosted-provisioning.md). It is the
2768
+ identity the attesting process declared, which the runtime does not
2769
+ authenticate. `attested_by` is `null` when nothing was attested; on
2770
+ `hash-mismatch` it names who attested the bytes the live file no longer
2771
+ matches. The text row reads `attested (seq 1, by human:carter)`, and `approval
2772
+ doctor`'s attestation row carries the same name.
2773
+
2400
2774
  **anomalies** are informational for the same reason `approval log verify`
2401
2775
  declined to refuse on them: status does not get to overrule that.
2402
2776
 
2777
+ **refusals** is the two refusal families, counted, with the newest five of each
2778
+ (APRV-376). `decision` counts `audit.decision_refused`: a human decided and the
2779
+ gate would not take it, which is APRV-235's record. `gesture` counts
2780
+ `audit.gesture_refused`: a human made a gesture that is not a decision (a
2781
+ checkpoint signature, a review, a review note) and the surface refused it before
2782
+ any verb ran, which is APRV-355's. Until this field existed neither was reported
2783
+ anywhere, and an operator could reach them only through `approval log tail` and
2784
+ `approval log export`. The question the field answers is small enough that the
2785
+ chain was a poor place to answer it: told that taps from an account they did not
2786
+ map are being refused, an operator wants to know how many and from which account.
2787
+
2788
+ Each entry carries the `seq`, the surface's own refusal code verbatim, and the
2789
+ observed `sender` when the record carries one, in the form the record carries it
2790
+ (`hashed: true` marks APRV-370's keyed digest rather than a raw account id). The
2791
+ count is every record of that family in the log; the listing is the newest five,
2792
+ newest first, because a row that grew with the log would push the rest of the
2793
+ report off a terminal for a fact that decides nothing.
2794
+
2795
+ It is informational, exactly as `coverage` and `anomalies` are: it moves neither
2796
+ `healthy` nor the exit code. A refusal is the gate having worked, and a report
2797
+ that went red because the gate turned away a stranger's tap would teach an
2798
+ operator to stop reading it. It reads only verified records (SPEC.md §11.1
2799
+ invariant 1), which is also why a log that does not verify reports no refusals
2800
+ rather than reporting some it cannot stand behind. A family with no records is
2801
+ absent rather than zero, and the whole field is absent when both families are, so
2802
+ a repository where nothing has been refused emits the object it always emitted.
2803
+
2403
2804
  **coverage** is one line of `approval coverage` (APRV-245): the commits git
2404
2805
  recorded on THIS branch, counted against the verified log. The range is the
2405
2806
  merge base with `origin/main` to `HEAD`, so what it measures is what this branch
@@ -2420,7 +2821,7 @@ does not know the flag exists.
2420
2821
  **What it reports**, in one object:
2421
2822
 
2422
2823
  - `attestation` — attested | hash-mismatch | not-attested | unreadable, with the
2423
- seq of the governing `policy.updated` record.
2824
+ seq of the governing `policy.updated` record and `attested_by`, its actor.
2424
2825
  - `verification` — the latest chain verdict, and the record count (null when
2425
2826
  corrupt).
2426
2827
  - `dangling` — executions the runtime meant to watch and never closed. Not
@@ -2443,12 +2844,25 @@ does not know the flag exists.
2443
2844
  how many the log records as pruned, and how many are unbound. Informational.
2444
2845
  - `anomalies` — additive and present only when non-empty: gate-typed events whose
2445
2846
  `ts` steps backwards by more than 2s. Informational.
2847
+ - `refusals` — additive and present only when the log carries a refused decision
2848
+ or a refused gesture: `{count, recent}` per family, each recent entry naming the
2849
+ `seq`, the refusal `code` and the observed `sender` where there was one.
2850
+ Informational.
2851
+ - `daemon` — `{id, source, allowed}`, always present (APRV-383): the daemon
2852
+ instance id a daemon run against THIS log would write onto every record it
2853
+ appends, whether it came from `APPROVAL_DAEMON_ID` (`source: "environment"`) or
2854
+ was derived from the instance (`source: "derived"`), and the `daemons` allowlist
2855
+ the attested policy puts in force. `id: null` means `APPROVAL_DAEMON_ID` holds
2856
+ something that is not a usable id, so a daemon started here would be refused
2857
+ every append. `allowed: null` means no restriction, which is not the same fact as
2858
+ an empty list. Informational: nothing here moves `healthy` or the exit code, and
2859
+ being listed grants a daemon nothing beyond the ability to write.
2446
2860
 
2447
2861
  **`--json`** (one object on stdout):
2448
2862
 
2449
2863
  ```
2450
2864
  {"ok":true,"healthy":false,
2451
- "attestation":{"state":"attested","seq":1},
2865
+ "attestation":{"state":"attested","seq":1,"attested_by":"human:carter"},
2452
2866
  "verification":{"status":"clean","records":6},
2453
2867
  "dangling":[{"action_key":"...","task":"...","ts":"...","seq":5}],
2454
2868
  "budgets":[{"limit":"global.daily_usd","scope":"global",
@@ -2460,12 +2874,16 @@ does not know the flag exists.
2460
2874
  "reconciliation":[{"seq":18,"ts":"...","action_key":"...","task":"...",
2461
2875
  "class":"records.write","obligation":"gated-revert","review_seq":17}],
2462
2876
  "payload_store":{"present":true,"files":2,"pruned":0,"orphans":0,
2463
- "note":"..."}}
2877
+ "note":"..."},
2878
+ "refusals":{"decision":{"count":2,"recent":[{"seq":21,"code":"policy-drift"}]},
2879
+ "gesture":{"count":1,"recent":[{"seq":24,"code":"sender-unmapped",
2880
+ "sender":{"channel":"telegram","id":"5551234567"}}]}},
2881
+ "daemon":{"id":"daemon-3f2a9c11","source":"derived","allowed":null}}
2464
2882
  ```
2465
2883
 
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.
2884
+ `ok` is true whenever status ran; `healthy` is the verdict. `attestation.seq` and
2885
+ `attestation.attested_by` are null for not-attested and unreadable. `note`
2886
+ carries the unrebuildable warning verbatim.
2469
2887
 
2470
2888
  ## coverage
2471
2889
 
@@ -2657,7 +3075,8 @@ The checks, at length:
2657
3075
  `--as`: this reports what the next command will find.
2658
3076
  - **attestation** — anything other than "the live bytes match" makes every gated
2659
3077
  operation refuse, and that refusal reads like "the policy says no" when it
2660
- means "the policy is unverified".
3078
+ means "the policy is unverified". A pass names the seq and the attester read
3079
+ from that record (`attested at seq 1 by human:carter`, APRV-449).
2661
3080
  - **log** — a torn tail and a corrupt log are both failures here; neither is
2662
3081
  repaired, and doctor never truncates a torn line.
2663
3082
  - **telegram** — `getMe` against `--api-base`, when both variables are set;
@@ -2674,7 +3093,17 @@ The checks, at length:
2674
3093
  `--payload` request creates it); an existing directory this process cannot
2675
3094
  write FAILS, because a request already accepted by the gate would refuse
2676
3095
  `payload-store-failed` mid ceremony. The probe creates and removes one empty
2677
- file and reads no payload.
3096
+ file. Since APRV-457 the row also reads back every payload a verified record
3097
+ binds (pruned ones excepted) and FAILS on two distinct readings. An empty or
3098
+ all-NUL file, or a missing file whose record proves the store held it
3099
+ (`policy.proposed`, `policy.updated`, or `approval.requested` carrying
3100
+ `display_hash`), is the crash-before-writeback signature: nothing was
3101
+ tampered and the bytes are gone from this store; the fix moves a torn file
3102
+ aside and restores a copy that `approval payload hash` confirms. A file
3103
+ holding any non-NUL byte that does not verify is NOT the crash signature
3104
+ (a partial writeback leaving a valid prefix and a NUL tail lands here too, the stricter reading) and reads as tampering or
3105
+ corruption; the fix moves it aside as `.suspect`. Doctor moves, restores and
3106
+ deletes nothing.
2678
3107
  - **audit-sampling** — sampling fails open by design (SPEC.md §5.2), so an
2679
3108
  unconfigured sampler silently audits nothing; this states the disabled reason
2680
3109
  out loud. A sampler nobody configured skips; a half-configured one fails,
@@ -2749,17 +3178,25 @@ The checks, at length:
2749
3178
  of the gate, so the repair is a line for a human to commit, printed by
2750
3179
  `approval instructions hook`.
2751
3180
  - **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
3181
+ registers `approval hook claude-code` for PreToolUse over every gated tool.
3182
+ The roster is read from the adapter that answers the calls (Bash, Edit, Write,
3183
+ MultiEdit, NotebookEdit since APRV-408, where it was a hand list that had
3184
+ drifted behind them). SKIP, named, when the file is absent, unreadable, or
2754
3185
  registers the hook for only some tools: a spawned-agent worktree without the
2755
3186
  entry is how the APRV-151 bypasses happened, and a session started elsewhere
2756
3187
  may still be hooked, so this row can only speak for the checkout it runs in.
2757
3188
  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.
3189
+ the running session loaded it. The one FAIL is a handler whose `--dir` names a
3190
+ checkout other than this one, which answers from another policy, another log
3191
+ and another open window. Two informational lines never decide anything: the
3192
+ adapter read tools the matcher leaves out (Read, Glob, Grep) are named as the
3193
+ documented default they are, and a matcher tool the adapter handles as none of
3194
+ its own is named as the unclassified `allow` it will actually receive. The
3195
+ check that trusts no session is the CI-side grant cross-check
3196
+ (`scripts/protected-path-guard.mjs`) over the committed log, which since
3197
+ APRV-202 requires every added and removed line of a protected path to trace to
3198
+ the bound material of a grant, rather than only that the path was granted at
3199
+ some point in the week.
2763
3200
  - **keychain-scope** — whose keystore items this instance's `.approval/env`
2764
3201
  names, answered from the NAMES alone so that it too can never block on an
2765
3202
  unlock dialog. FAIL for an item whose eight-hex scope suffix belongs to
@@ -2863,7 +3300,14 @@ The checks, at length:
2863
3300
  the `fix` is the promptless self-test in `docs/claude-code-hook.md`, one
2864
3301
  supervised-class tool call, after which the row is green. PASS says only that
2865
3302
  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.
3303
+ and reduces nothing anywhere, so a match is not proof the hook fired. Since
3304
+ APRV-415 the row carries a second finding for one harness: a Hermes below the
3305
+ **fail-closed version floor** (a build at or after `main` `118984d7` of
3306
+ 2026-09-20) FAILS, because `v0.21.3` ignores `fail_closed` silently and every
3307
+ broken hook on it proceeds. That half is read from `hermes --version` rather than
3308
+ from a record — Hermes prints its build stamp behind a non-ASCII separator the
3309
+ write boundary refuses, so no record can carry it — and it compares the build
3310
+ date and the upstream commit, because the two builds report the same semver.
2867
3311
  - **live-draw** — whether a daemon is answering `supervised-live` draws for this
2868
3312
  log (APRV-208). SKIP when the policy declares no live class: no draw is ever
2869
3313
  made, and a missing socket is nothing. It FAILS in three shapes, all of them
@@ -2997,6 +3441,22 @@ The checks, at length:
2997
3441
  would find nothing and report green, which is the worst direction a health
2998
3442
  check can fail in. The `fix` points at the harness's own configuration,
2999
3443
  because nothing here can turn another system's reviewer off.
3444
+ - **daemon-identity** — which daemon id a daemon run against this log would
3445
+ write onto every record it appends, and whether the attested policy admits it
3446
+ (APRV-383). The id is `APPROVAL_DAEMON_ID` from this shell where it is set and
3447
+ otherwise `daemon-` plus the instance id the `keychain-scope` row names, so the
3448
+ two rows describe one gate. A FAIL where `APPROVAL_DAEMON_ID` is set to
3449
+ something that is not an id: a daemon started from this shell would be refused
3450
+ `daemon-id-invalid` on every append, so it would read, render and report while
3451
+ writing nothing, and the `fix` is that one variable. A loud SKIP where the
3452
+ attested policy's `daemons` list does not admit the id, naming what the list
3453
+ does admit — expected where the daemon for this log runs on another machine,
3454
+ which is the hosted case the key exists for, and wrong if that daemon is meant
3455
+ to be this one. A PASS where the list admits it, and where there is no list at
3456
+ all, which is every installation that never adopts the key. Being listed grants
3457
+ nothing beyond the ability to write: no verdict, budget, floor, draw or token
3458
+ reads the id. The row reads the policy and one variable's SHAPE, prints no
3459
+ value on any path, and asks no running daemon anything.
3000
3460
 
3001
3461
 
3002
3462
 
@@ -3669,6 +4129,16 @@ This starts the standalone Telegram component. For normal operation, use
3669
4129
  `up` or another listener polling the same bot, even for a different policy
3670
4130
  project: competing `getUpdates` calls produce Telegram HTTP 409.
3671
4131
 
4132
+ A bot whose updates go to a webhook cannot be long-polled at all, so this verb
4133
+ and `approval up` refuse `webhook-registered` in the same preflight that asks
4134
+ `getMe`, naming the origin that holds it. That preflight also takes this gate's
4135
+ transport lease (`.approval/daemon/telegram-transport.lock`), so a second
4136
+ listener or a webhook runner started in the same project refuses
4137
+ `telegram-poller-running` naming the pid that holds it, rather than both
4138
+ processes putting every request on the phone under their own nonce. See
4139
+ [`channel telegram webhook`](#channel-telegram-webhook), which is the other
4140
+ transport and removes both its registration and its lease when it exits.
4141
+
3672
4142
  **Delivery is per cycle, not only at startup.** Before every `getUpdates` the
3673
4143
  listener re-derives the pending queue from the verified log and sends whatever
3674
4144
  it has not already sent, so a request appended while this listener is running
@@ -3978,6 +4448,253 @@ rather than a query:
3978
4448
 
3979
4449
  The raw execution token is never in the JSON stream.
3980
4450
 
4451
+ ## channel telegram webhook
4452
+
4453
+ The same channel, the other arrival (APRV-424). `listen` holds a socket open
4454
+ against `getUpdates` for as long as the gate exists, which needs one
4455
+ always-running process per bot and forbids a second poller on the same token.
4456
+ This registers a URL with `setWebhook` and serves the callback, so Telegram
4457
+ posts each update as it happens and a host that sleeps between requests can run
4458
+ a gate.
4459
+
4460
+ **Only the arrival changes, and that is the whole claim.** The pending queue is
4461
+ re-derived from the verified log every cycle by the same `dispatchPending`. The
4462
+ handlers are the same four function objects `listen` registers. A tap becomes a
4463
+ decision through the same `handleUpdate`, the same `callback_query.from.id`
4464
+ reading, the same `approvers.<id>.senders` resolution, the same
4465
+ `recordChannelDecision` and the same annotate-after-decision edit. There is no
4466
+ second decision path and no second copy of the sender mapping:
4467
+ `tests/telegram-webhook.test.ts` drives one callback through both transports and
4468
+ compares the records the log ends up holding, field for field.
4469
+
4470
+ ### The secret, which is the whole of the authentication
4471
+
4472
+ `APPROVAL_TG_WEBHOOK_SECRET` is REQUIRED and is read from the launch
4473
+ environment, never from a file in the tree (SPEC.md §11.1 invariant 7, the rule
4474
+ `approval serve`'s two credentials follow). It is Telegram's own `secret_token`:
4475
+ set on `setWebhook`, echoed on every delivery in the
4476
+ `X-Telegram-Bot-Api-Secret-Token` header, compared here in constant time over
4477
+ SHA-256 digests.
4478
+
4479
+ A webhook URL is a public endpoint, and nothing in a request body could tell a
4480
+ delivery from a forgery — a self-reported field never reduces scrutiny (§11.1
4481
+ invariant 4). So the header is checked FIRST, before the path, the method and
4482
+ the body, and a post without the matching value is refused with its own code,
4483
+ counted, and reported on stderr. **A refusal is never a decision, and it appends
4484
+ nothing to the log.** That is the rule `TELEGRAM_ANOMALY_KINDS` states for
4485
+ ignored callbacks, and it binds harder here: an endpoint the internet can reach
4486
+ that could append would be an endpoint anyone could use to pad the record a
4487
+ human is asked to trust.
4488
+
4489
+ The value is refused at startup when it is unset, shorter than 24 characters, or
4490
+ holds a character outside Telegram's `A-Z a-z 0-9 _ -`. It appears in no policy,
4491
+ no log, no record, no message and no error line: the channel redacts it from
4492
+ everything that leaves it, exactly as it redacts the bot token.
4493
+
4494
+ | refusal | what happened |
4495
+ |---|---|
4496
+ | `webhook-secret-mismatch` | no `X-Telegram-Bot-Api-Secret-Token`, or not the registered value |
4497
+ | `webhook-duplicate-secret-header` | more than one of that header; malformed, and not resolvable by choosing |
4498
+ | `webhook-malformed-request` | the request line and `Host` header do not form a URL (400) |
4499
+ | `webhook-unknown-path` | a path this process does not serve (404) |
4500
+ | `webhook-method-not-allowed` | the right path, the wrong method; Telegram POSTs |
4501
+ | `webhook-body-too-large` | over 256 KiB; drained before the refusal is written |
4502
+ | `webhook-body-unreadable` | the body could not be read off the socket, or is not JSON |
4503
+ | `webhook-not-an-update` | JSON that is not an Update object |
4504
+ | `webhook-transport-conflict` | this channel is already receiving updates by long poll |
4505
+ | `webhook-handler-failed` | the channel threw; the log is what says what was recorded |
4506
+
4507
+ Startup refuses in its own vocabulary before anything binds:
4508
+ `webhook-secret-missing`, `webhook-secret-weak`, `webhook-secret-charset`,
4509
+ `webhook-url-missing`, `webhook-url-insecure`, `webhook-url-port`,
4510
+ `webhook-url-userinfo`, `webhook-path-invalid`, `webhook-path-mismatch`,
4511
+ `webhook-cycle`, and `webhook-registration-failed` for a `setWebhook` the Bot
4512
+ API turned down (its status and its own description, scrubbed of the token and
4513
+ the secret), plus every `channel telegram listen` refusal, which this verb
4514
+ inherits because it needs the same bot token, chat, identity, log and policy.
4515
+
4516
+ Four of those are the review's, and each one closes a start that used to
4517
+ succeed and then not work:
4518
+
4519
+ - **`--path` must be the url's own path.** `telegram/webhook` with no leading
4520
+ slash, or `/hook/` against a `--url` ending `/hook`, registered cleanly and
4521
+ then answered every real delivery 404 after verifying its secret. `--path` is
4522
+ an explicit restatement of the url's path and nothing else; rewriting belongs
4523
+ in the proxy in front of this process.
4524
+ - **`--url` may not carry userinfo.** `https://user:pw@host/hook` is a
4525
+ credential on a command line. It is refused, never stripped.
4526
+ - **The bind port may not be 0**, and must be inside 1..65535. Port 0 asks the
4527
+ kernel for an ephemeral port, and this transport is a fixed public url
4528
+ forwarded to a fixed local address: a receiver on a port nobody registered
4529
+ verifies nothing and answers nothing.
4530
+ - **Every line that prints the url prints its origin and a redacted path**, in
4531
+ the start-up banner, in `webhook_started` and in refusals: with two segments
4532
+ or more the first is kept and the rest becomes `<path redacted>`, a path of
4533
+ exactly one segment is replaced whole (that is the shape a tunnel's own token
4534
+ takes), and a query string becomes `<query redacted>`. A tunnel that hands out
4535
+ `https://host/hook/<random>` puts a bearer value in a path, and this runtime
4536
+ does not copy it into three more places. The local bind line is redacted the
4537
+ same way, because it carries the same path. The operator has the whole of it
4538
+ already: they typed it into `--url`.
4539
+
4540
+ ### The proxy or tunnel is required, and this process holds no certificate
4541
+
4542
+ Telegram delivers to HTTPS only, on port 443, 80, 88 or 8443. This process
4543
+ terminates no TLS and holds no certificate, exactly as `approval serve` does
4544
+ not: `--url` names the PUBLIC address your proxy or tunnel answers on, and
4545
+ `--port` / `--listen` names this process's own bind, which is `127.0.0.1:4683`
4546
+ unless you say otherwise. A routable bind takes both `--listen <host:port>` and
4547
+ `--allow-non-loopback` and prints a banner, because the secret arrives in a
4548
+ header and a cleartext hop hands it to whoever is on it.
4549
+
4550
+ So a working deployment is a reverse proxy (nginx, Caddy, a cloud load balancer)
4551
+ or a tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel) terminating TLS on a
4552
+ name you control and forwarding to the loopback bind. Without one there is
4553
+ nothing to register: a plain-HTTP `--url`, or one on another port, is refused
4554
+ before any call is made rather than attempted and rejected by the Bot API.
4555
+
4556
+ ### One transport per bot, refused on both sides
4557
+
4558
+ `getUpdates` is refused by the Bot API outright while a webhook is set, so the
4559
+ two are alternatives and never a pair. Four checks say so before anything runs,
4560
+ and they are in that order because the first needs no network:
4561
+
4562
+ - **A lease per gate.** `approval up`, `approval channel telegram listen` and
4563
+ this verb take a lockfile in the gate's own derived state directory,
4564
+ `.approval/daemon/telegram-transport.lock`, holding the holder's pid, which
4565
+ transport it is running and when it started. It is created with `O_EXCL`, so
4566
+ two processes racing cannot both believe they took it, and a holder that is
4567
+ no longer running is reclaimed automatically (a crash must not lock a gate
4568
+ out of its own channel). A second taker refuses `telegram-poller-running`
4569
+ when a poller holds it and `webhook-registered` when a webhook does, naming
4570
+ the pid and the mode; a directory the lease cannot be written in refuses
4571
+ `telegram-lease-unavailable`, because this file is the only thing keeping one
4572
+ gate from running two transports. Both transports release it on a clean stop.
4573
+
4574
+ **A holder is a process, not a number.** Pids are reused, so the probe asks
4575
+ three questions: does a process with that number exist, can this process
4576
+ signal it, and did it start before the lease was written. A number that is
4577
+ gone, one owned by a process this one cannot signal, and one belonging to a
4578
+ process that started *after* the lease are all reclaimed, and the line says
4579
+ which of the three it was. Only a process that is running, signalable and
4580
+ older than its own lease keeps the gate. Where the platform will not report a
4581
+ start time, a running pid keeps the gate, which is the stricter reading.
4582
+
4583
+ **Taking a lease over is a critical section, and the section is owned.**
4584
+ Reclaiming happens inside a sibling `O_EXCL` lock that carries the entering
4585
+ process's pid and a nonce. Inside it the lease record must still be exactly
4586
+ the one that was judged (same pid, same `started_at`), the lock must still
4587
+ name this entry at the moment of the write, and the lock is removed on the
4588
+ way out only while it still does. Without the first, two processes that had
4589
+ both read one dead holder took turns deleting each other's live lease;
4590
+ without the other two, a process that stalled inside the section renamed its
4591
+ lease over the one that replaced it and deleted the new holder's lock. The
4592
+ replacement is a rename, so the lockfile is never absent and never half
4593
+ written. A reclaim lock is evicted only when its own process is gone, judged
4594
+ by the probe above: its age is reported and decides nothing, because "five
4595
+ seconds have passed" says nothing about whether anybody is still inside. A
4596
+ lock this build cannot read names no process, so the take refuses and names
4597
+ the file rather than forcing it.
4598
+
4599
+ This is the check the other three cannot make. Two processes started in the
4600
+ SAME project — one `approval up` long-polling, one `approval channel telegram
4601
+ webhook` — passed all of them: `getWebhookInfo` is empty while the poller is
4602
+ the one running, and the ownership registry compares instance ids, which are
4603
+ equal because it is the same instance. Each then ran its own dispatch cycle
4604
+ over its own state against one log, so every request reached the phone twice
4605
+ under two nonces and only one copy could resolve a tap.
4606
+ - **What the Bot API says.** `listen` and `approval up` ask `getWebhookInfo` in
4607
+ the same preflight that asks `getMe`, and refuse `webhook-registered` naming
4608
+ the origin that holds the bot. For a poller an unreachable Bot API is not a
4609
+ refusal (a captive portal says nothing about which transport owns a bot) and
4610
+ the HTTP 409 path is the backstop. For THIS verb it is: a probe it could not
4611
+ make refuses `webhook-probe-failed`, because the next call would be a
4612
+ `setWebhook` that overwrites a registration this process never saw.
4613
+ - **Any existing registration, including the same url, refuses.** Telegram
4614
+ allows one webhook per bot and keeps the last registration, so a second host
4615
+ registering takes the taps: it overwrites the first host's `secret_token`, and
4616
+ from then on every real tap arrives at the first host with a secret it does
4617
+ not recognise, indistinguishable from a forgery. `--reclaim` is how an
4618
+ operator takes the bot deliberately; with it, a url that matches the
4619
+ registered one (host lowercased, trailing slash dropped) is reported as a
4620
+ re-registration and anything else as a takeover that stops the other receiver
4621
+ being posted to.
4622
+
4623
+ **One case needs no flag: this gate's own runner was killed.** A `SIGKILL`
4624
+ leaves the registration in place, because the dead process never reached
4625
+ `deleteWebhook`, and a restart that refused every time would teach operators
4626
+ to put `--reclaim` in the unit file, which retires the protection above for
4627
+ good in exchange for a crash recovery. So a restart may re-register the SAME
4628
+ normalised url without the flag when the lease it just reclaimed was this
4629
+ gate's own, written by a webhook runner, and that process is **gone**. A dead
4630
+ poller's lease does not count, a different url does not count, and a gate
4631
+ with no such lease does not count. Nor do the other two reclaim reasons: a
4632
+ lease reclaimed because its pid is owned by a process this one cannot signal,
4633
+ or because the number has been reused, is the right call for the lease and no
4634
+ evidence at all that the runner which registered the url has stopped. Those
4635
+ keep the refusal and the `--reclaim` demand.
4636
+ - Within one process, `TelegramChannel.claimTransport` refuses the second claim.
4637
+
4638
+ A clean stop (SIGINT, SIGTERM) removes the webhook, so long polling works again
4639
+ afterwards. Pending updates are kept in both directions: a tap that arrived
4640
+ while the process was down is the approver's answer, and the gate is what
4641
+ decides whether it is still honourable.
4642
+
4643
+ ### Redelivery, and the cycle
4644
+
4645
+ Telegram retries a delivery it did not get a 2xx for, so the same tap can arrive
4646
+ twice. Nothing deduplicates it: the second one reaches the gate through the same
4647
+ path as the first and is refused `already-decided`, with the first human answer
4648
+ standing — the property a button pressed twice has had since the channel
4649
+ shipped. Updates are handled one at a time, in arrival order, and the response
4650
+ is written after the update has been handled.
4651
+
4652
+ `--cycle <duration>` (default 30s) is the dispatch period, the webhook's
4653
+ equivalent of the poll loop's per-cycle dispatch: it re-derives the pending
4654
+ queue and sends what has not been sent. A cycle also runs immediately after each
4655
+ handled update, so a paced listener's next question follows a tap without
4656
+ waiting for the timer.
4657
+
4658
+ **A stop waits for the update it is holding.** Ctrl-C stops the receiver
4659
+ accepting, then drains: an update mid-append keeps its socket, finishes its
4660
+ decision and writes its response, and only then is `deleteWebhook` called and
4661
+ the stopped line printed. A second Ctrl-C during the drain is absorbed rather
4662
+ than killing the process, and a request that never finishes is dropped after ten
4663
+ seconds, because a stop has to end. `deleteWebhook` on that path is given five
4664
+ seconds rather than the channel's usual thirty, for the same reason: a stop an
4665
+ operator has asked for twice should not sit behind an unreachable Bot API, and
4666
+ a removal that does not land is reported as a webhook still registered.
4667
+
4668
+ ### The daemon id it stamps (APRV-448)
4669
+
4670
+ Every record this process appends (a decision a tap delivers, the refusal of
4671
+ one, anything a dispatch cycle writes) carries the `daemon` field, with the id
4672
+ `approval status` reports for the same store: `APPROVAL_DAEMON_ID` from the
4673
+ launch environment, or `daemon-` plus the instance id derived from the log's
4674
+ `.approval` directory, which is the id the daemon loop derives. The started line
4675
+ names it (`records stamped daemon <id> (<source>)`) and `webhook_started`
4676
+ carries it as `daemon`. A declared id that is not a usable id refuses the verb
4677
+ before the bot is claimed or a port is bound, exit 2.
4678
+
4679
+ The attested policy's `daemons` list governs this process at the write
4680
+ boundary, as it does the daemon and [`approval serve`](#serve). It is re-read at
4681
+ startup and at the top of every dispatch cycle, which is this process's tick: a
4682
+ list a human narrows and re-attests takes effect within one cycle (a cycle runs
4683
+ after every handled update and every `--cycle`), and a resolution that fails
4684
+ leaves the previous list standing. A tap whose decision is refused
4685
+ `daemon-not-allowed` appends nothing, and the refusal is reported on stderr. The
4686
+ process declares the id without marking itself the daemon.
4687
+
4688
+ ```json
4689
+ {"event":"webhook_started","url":"https://gate.example/telegram/<path redacted>",
4690
+ "host":"127.0.0.1","port":4683,"path":"/telegram/<path redacted>","cycle_ms":30000,
4691
+ "daemon":"daemon-3f2a9c11"}
4692
+ {"event":"stopped","notified":1,"updates":1,"decisions":1,"pollErrors":0,
4693
+ "anomalies":{"foreign-chat":0,"malformed-callback":0,"unknown-callback":0,
4694
+ "key-mismatch":0},
4695
+ "webhook":{"requests":2,"updates":1,"refusals":{"webhook-secret-mismatch":1}}}
4696
+ ```
4697
+
3981
4698
  ## channel telegram health
3982
4699
 
3983
4700
  Makes no network call. A health check that contacted the Bot API would announce
@@ -3991,6 +4708,194 @@ belong to a RUNNING listener: they are on its stderr as they happen, in its
3991
4708
  Which variables are read comes from the policy, so a renamed variable reads back
3992
4709
  as the name you set.
3993
4710
 
4711
+ ## channel relay
4712
+
4713
+ A third arrival for a human gesture, beside a Telegram tap and a terminal
4714
+ (APRV-455). An operator's control plane holds an authenticated session for a
4715
+ person (the Agent Village control plane holds an EdgeOS session for each
4716
+ resident), shows them a request or their policy, and posts what they did to
4717
+ this verb. The post carries the gesture, the EdgeOS `/humans/me` id of the
4718
+ session that made it, the policy hash or the action key it answers, a nonce and
4719
+ an `issued_at`. The daemon resolves the id against the attested policy exactly
4720
+ as it resolves a Telegram `from.id`, and records the gesture through the same
4721
+ `recordChannelDecision` a tap goes through.
4722
+
4723
+ ```
4724
+ approval channel relay [--listen [host:]port | --port <n>] [--allow-non-loopback]
4725
+ [--proposer agent:<id>] [--policy <p>] [--dir <p>]
4726
+ [--log <p>] [--json]
4727
+ ```
4728
+
4729
+ ### Trust level
4730
+
4731
+ **The daemon trusts the relay's attribution.** A post that carries the relay
4732
+ secret is the operator's control plane reporting which EdgeOS account acted,
4733
+ and the daemon believes that report the way it believes the Telegram Bot API's
4734
+ report of `callback_query.from.id`. That is operator trust, stated in
4735
+ APRV-422's words: no hosted service has authority over decisions. The relay
4736
+ carries a person's gesture and decides nothing. Which approver the account is,
4737
+ whether that approver may decide this request or attest this policy, and
4738
+ whether the request is still answerable are all decided by the runtime, from
4739
+ the verified log and the attested policy, with the same codes a tap gets.
4740
+
4741
+ What the relay cannot do, by construction:
4742
+
4743
+ - **Act as anyone of its own.** It takes no `--as` and reads no
4744
+ `APPROVAL_HUMAN`. Every gesture is attributed to the account
4745
+ `approvers.<id>.senders.edgeos` maps, or refused `sender-unmapped` with one
4746
+ `audit.decision_refused`. A policy that maps no EdgeOS account refuses every
4747
+ gesture the same way; there is no fallback to a configured identity.
4748
+ - **Read a mapping nobody has attested.** Every gesture, decisions included,
4749
+ resolves its sender against the policy IN FORCE: the verified bytes the
4750
+ latest attestation names, recovered from the payload store. A mapping
4751
+ sitting unattested on disk (the relay's own `propose` puts one there) decides
4752
+ nothing until a human attests it. Where the in-force bytes cannot be
4753
+ recovered, a decision is refused `policy-not-attested` (an attestation,
4754
+ `attest-requires-terminal`) and nothing is appended; a log that cannot be
4755
+ read keeps its own code.
4756
+ - **Choose who may sign for a policy change.** An attestation is resolved
4757
+ against the policy IN FORCE, never the one being attested, so an account a
4758
+ proposal adds or repoints cannot accept the proposal that names it, and an
4759
+ amendment that introduces the EdgeOS mapping is refused
4760
+ `attest-requires-terminal` (the operator bootstrap of APRV-449 is what maps
4761
+ the resident the first time).
4762
+ - **Hold a token.** A grant that mints a token for the deciding surface has it
4763
+ dropped unread; the response says only `token_issued`. Requests in the Agent
4764
+ Village are harness-executed or sealed, so none is lost; a manual-delivery
4765
+ request granted here has a token nobody holds and is asked again.
4766
+ - **Be reached through `approval serve` or `approval mcp serve`.** It is its
4767
+ own process, port and credential. The registry marks it `human_only`, so no
4768
+ wrapper publishes it, and neither serve credential reaches it.
4769
+ - **Be started by an agent.** The attribution a relay records is exactly as
4770
+ strong as control over who launches it, because its launcher chooses the
4771
+ secret. The hook classifier holds `approval channel relay` as `policy.core`
4772
+ (human-only in the reference policy), so an agent session cannot start one; a
4773
+ deployment starts it from its own launcher as the store's user. A keyed
4774
+ `senders.edgeos` mapping adds a second lock: a process without
4775
+ `APPROVAL_SENDER_KEY` cannot resolve any account.
4776
+
4777
+ ### The secret
4778
+
4779
+ `APPROVAL_RELAY_SECRET` is REQUIRED and is read from the launch environment,
4780
+ never from a file in the tree (SPEC.md §11.1 invariant 7). At least 24
4781
+ characters of `A-Z a-z 0-9 _ -` (generate it: `openssl rand -hex 32`), with the
4782
+ same value given to the control plane; surrounding whitespace is stripped at
4783
+ start, so the control plane sends the trimmed value. Under the `APPROVAL_`
4784
+ prefix it is withheld from every child `approval run` spawns. The control plane sends it
4785
+ in `x-approval-relay-secret`; it is compared in constant time over SHA-256
4786
+ digests, BEFORE the path, the method or the body, and a duplicate header is
4787
+ refused rather than resolved. **A refusal never reaches the gate and appends
4788
+ nothing to the log**: it is counted, written to stderr, and answered with its
4789
+ code. The secret appears in no response, no log record and no stderr line.
4790
+
4791
+ Loopback by default (port 4684). There is no TLS here; a routable bind takes
4792
+ `--listen <host:port> --allow-non-loopback` and prints a banner every start.
4793
+
4794
+ ### The body
4795
+
4796
+ `POST /relay/gesture`, one JSON object, closed: an unknown field is refused, so
4797
+ nothing that looks like authority (an `actor`, a second sender, a `ts`) rides
4798
+ along on a valid post.
4799
+
4800
+ ```json
4801
+ {"gesture":"propose","policy_sha256":"<64 hex>","nonce":"<16..128 of A-Za-z0-9_->","issued_at":"2026-10-03T12:00:00Z"}
4802
+ {"gesture":"attest","policy_sha256":"<64 hex>","sender":{"channel":"edgeos","id":"<EdgeOS id>"},"nonce":"...","issued_at":"..."}
4803
+ {"gesture":"decline","policy_sha256":"<64 hex>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
4804
+ {"gesture":"grant","action_key":"<the request's key>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
4805
+ {"gesture":"reject","action_key":"<the request's key>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
4806
+ ```
4807
+
4808
+ - `sender.channel` is `edgeos` and nothing else. `sender.id` is the raw
4809
+ `/humans/me` id: an ASCII letter or digit, then up to 127 letters, digits,
4810
+ `.`, `_` or `-`, the grammar `senders.edgeos` pins. Never a digest (a keyed
4811
+ policy is resolved with `APPROVAL_SENDER_KEY` in the relay's environment) and
4812
+ never an email address.
4813
+ - `issued_at` is RFC 3339 in UTC ending in `Z`, within five minutes of the
4814
+ relay's clock either side. It is used for that window and recorded nowhere;
4815
+ every record's `ts` is the runtime's own.
4816
+ - `nonce` is claimed before the gesture is applied, in a ledger of exclusively
4817
+ linked files under the gate's `.approval/daemon/relay-nonces/`, kept for eleven
4818
+ minutes. A replay is refused `relay-nonce-replayed` at this process, after a
4819
+ restart, or at a second relay on the same gate. A retry is a new post with a
4820
+ new nonce, and the gate answers a repeated decision `already-decided`.
4821
+
4822
+ `propose` is the control plane's half of the onboarding review. It writes the
4823
+ rendered policy into the store, then posts its hash: the relay re-hashes the
4824
+ file and refuses `relay-policy-mismatch` when the bytes are not the ones named,
4825
+ then appends a `policy.proposed` under `--proposer` (an `agent:` id, default
4826
+ `agent:edgeos-relay`: the relay is a machine, and a `human:` proposer would say
4827
+ a person proposed what nobody typed) with the semantic diff against the
4828
+ verified in-force text and a deadline one hour out, after which the prompt
4829
+ retires from every queue. A retry of an open proposal of the same bytes returns it with
4830
+ `"existing":true`. It also admits bytes ALREADY in force (`"reaffirm":true`):
4831
+ the review most residents make is "accept, unchanged", and that acceptance is
4832
+ still their attestation. A reaffirmation changes the attester of record and
4833
+ nothing else. The prompt also reaches Telegram, where the resident's tap
4834
+ answers it equally.
4835
+
4836
+ **Until a proposal is attested, the bytes on disk are not the policy in
4837
+ force.** Every gated call refuses `policy-not-attested` in that window (a
4838
+ grant through the relay included), exactly as during any amendment. So the
4839
+ control plane proposes and collects the acceptance in the same screen, and
4840
+ restores the in-force bytes when the resident declines or the proposal lapses.
4841
+ A `decline` appends `policy.declined` and leaves the attestation in force
4842
+ where it was, the operator's included: it is a refusal to adopt the proposed
4843
+ bytes, never a withdrawal of the policy already attested.
4844
+
4845
+ `attest` / `decline` answer the open proposal of that hash. `attest` appends
4846
+ `policy.updated` with `actor: human:<approver>` and `payload.sender
4847
+ {channel: edgeos, id}`. A hash that names no open proposal (superseded,
4848
+ answered, never proposed) is refused `proposal-not-found`, and a file changed
4849
+ under the proposal is refused `proposal-stale`; neither attests anything.
4850
+
4851
+ `grant` / `reject` answer a pending request exactly as a Telegram tap does,
4852
+ including `expired`, `policy-drift` (with its `audit.decision_refused` and
4853
+ withdrawal), `already-decided` and `actor-not-approver`.
4854
+
4855
+ ### Responses
4856
+
4857
+ | status | body | meaning |
4858
+ | --- | --- | --- |
4859
+ | 200 | `{"ok":true,"gesture":…,"event":…,"seq":…,"actor":…}` | recorded (`propose` returns `sha256`, `seq`, `existing`, `reaffirm`, `changes`, `loads`) |
4860
+ | 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` |
4861
+ | 4xx/5xx | `{"error":{"code","message"}}` | the relay refused the post; the code is from `relay_refusal_codes`, and nothing was appended |
4862
+
4863
+ `relay_refusal_codes` (frozen, pinned in `conformance/vectors/refusal-unions.v1.json`):
4864
+ `relay-secret-mismatch`, `relay-duplicate-secret-header` (401),
4865
+ `relay-malformed-request` (400), `relay-unknown-path` (404),
4866
+ `relay-method-not-allowed` (405), `relay-body-too-large` (413),
4867
+ `relay-body-unreadable`, `relay-body-invalid`, `relay-sender-invalid`,
4868
+ `relay-gesture-stale` (400), `relay-nonce-replayed` (409),
4869
+ `relay-nonce-unavailable` (503, the ledger could not record the nonce, so
4870
+ nothing was decided), `relay-policy-mismatch` (409), `relay-handler-failed`
4871
+ (500).
4872
+
4873
+ Start-up refusals (exit 2, `{"error":{"code"}}` under `--json`):
4874
+ `relay-secret-missing`, `relay-secret-weak`, `relay-secret-charset`,
4875
+ `relay-proposer-invalid`, `relay-bind-invalid`. An unreadable log exits 4, a
4876
+ torn tail 3, a corrupt chain 1.
4877
+
4878
+ With `--json`, stdout carries one object per line: `relay_started` (`host`,
4879
+ `port`, `path`, `proposer`), one `relay_gesture` per gesture that reached the
4880
+ gate (`gesture`, `ok`, and `seq`/`record`/`actor` or `code`), and `stopped`
4881
+ with the counters. Never a body, a sender id or the secret.
4882
+
4883
+ There is no transport lease. A Telegram bot admits one receiver; a relay has no
4884
+ such constraint, because compare-and-append, the gate's idempotency and the
4885
+ nonce ledger (each claim written whole and linked into place, so exactly one
4886
+ claimant wins) make two relays on one gate safe. A second relay on the same
4887
+ port fails to bind.
4888
+
4889
+ **Who is listening on the port is the caller's check.** The secret travels in a
4890
+ header, so whoever holds the loopback port when the control plane posts
4891
+ receives it. In a sandbox where another uid can bind loopback (the Agent
4892
+ Village's `hermes` user), the control plane checks that the listener on 4684
4893
+ belongs to the relay's own user before it posts, the same foreign-listener
4894
+ rule the hook applies to `serve`, and posts to the literal `127.0.0.1`, never
4895
+ `localhost`, which another uid could answer on `[::1]`. A unix socket in a directory only the relay's
4896
+ user can write, or a signature over the body in place of a bearer secret, would
4897
+ remove the check; neither is in this version.
4898
+
3994
4899
  ## quickstart
3995
4900
 
3996
4901
  `approval quickstart [--dir <path>] [--api-base <url>]` is the human-only solo setup ceremony. It
@@ -4041,17 +4946,20 @@ code. A re-run in a scaffolded directory writes nothing and exits 0. A directory
4041
4946
  carrying `APPROVALS.md` (the SPEC.md §5 fallback filename) already has a policy:
4042
4947
  init reports `policy-exists` and writes no `APPROVAL.md` beside it.
4043
4948
 
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.
4949
+ Payloads are ignored (APRV-445; they were tracked before it). `.approval/payloads/`
4950
+ holds the exact bytes each approval bound to, and for a proposal those are a
4951
+ person's own words, so the merged block lists it. To keep the bytes in history
4952
+ as evidence instead, remove that line yourself; the log keeps every
4953
+ `payload_hash` either way. Files in the store are written 0600 in a 0700
4954
+ directory whatever the umask.
4048
4955
 
4049
4956
  Keys are not. The merged block ignores `.approval/*.sqlite` (a projection),
4050
4957
  `.approval/vault.enc`, `.approval/env`, `.approval/keys/`, `.approval/**/*.tmp-*`
4051
4958
  and `.approval-journal/`. `.approval/keys/` is the one that matters most and is
4052
4959
  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
4960
+ because the payload store beside it was tracked by default before APRV-445 (and
4961
+ still is wherever an operator chose that), `.approval/` is a directory people
4962
+ `git add` from. A key committed there opens that action's
4055
4963
  `token_sealed` for everyone holding the log. The line is written whether or not
4056
4964
  your policy has opted into `token_delivery: sealed`, and `approval doctor`'s
4057
4965
  `sealed-keys` row reports a key that is tracked or in a store no line covers.
@@ -4097,6 +5005,81 @@ setting to change it, which the fail-closed invariant does not survive;
4097
5005
  `docs/grok-hook.md` states which cases the adapter cannot cover and is worth
4098
5006
  reading before the file is committed.
4099
5007
 
5008
+ **`hook muse` emits exactly ONE dialect and nothing else (APRV-350).** Muse Code
5009
+ treats output carrying any key it does not support as a failed hook, and a failed
5010
+ hook fails OPEN, so the belt-and-braces payload that satisfies several harnesses
5011
+ at once satisfies this one not at all. Its envelope is snake_case with its own
5012
+ tool names (`bash` carrying a per-call `workdir`, `write_file`, `read_file`, and
5013
+ `search` naming an ARRAY under `paths`), and a Contributor-tier model is refused
5014
+ for every tool call regardless of policy (`hook-muse-contributor-model`).
5015
+ `approval hook muse --help` prints the `.muse/hooks.json` the human commits;
5016
+ `docs/muse-hook.md` opens with the fail-open finding and the model warning.
5017
+
5018
+ **`hook hermes` is the second exception to the exit codes above, and the one
5019
+ harness whose configuration is not in the repository (APRV-398).** Its event
5020
+ names are its own — `pre_tool_call` and `post_tool_call`, not `PreToolUse` — and
5021
+ its envelope is snake_case (`tool_name`, `tool_input`, `session_id`, `cwd`,
5022
+ `profile`, `extra`). A deny is `{"action":"block","message":…}` **at exit 2**,
5023
+ which Hermes treats as an unconditional block whose message it takes from the
5024
+ stdout directive first; an **allow is `{}`** at exit 0, because Hermes has no
5025
+ allow directive and no directive is the allow, so an allow's reason goes to
5026
+ stderr. `execute_code` is refused outright
5027
+ (`hook-hermes-execute-code-unbound`): it carries a program and no path, no argv
5028
+ and no working directory, so no verdict could bind it. **A call whose effective
5029
+ directory the event does not carry is refused too** (`hook-unsupported-execution-context`,
5030
+ APRV-415): a live probe found that the envelope `cwd` is the Hermes PROCESS
5031
+ directory while `terminal` keeps a per-session recorded directory that a `cd` moves
5032
+ and no field reports, and that the file tools resolve relative paths against that
5033
+ one — so a `terminal` call with no absolute `workdir`, and a `write_file`, `patch`,
5034
+ `read_file` or `search_files` call whose path is relative or missing, are refused
5035
+ with a reason telling the session to retry with an absolute path. The file a human
5036
+ commits is `$HERMES_HOME/config.yaml` — name the home explicitly and spell its last
5037
+ segment `.hermes`, or the classifier does not recognise the organ — where the event
5038
+ is a mapping KEY rather than an `event:` field, `fail_closed: true` belongs on every
5039
+ entry because its default is false, and TWO timeouts bound the wait: the per-entry
5040
+ `timeout` caps at 300s and `plugins.hook_callback_timeout` defaults to 30s and
5041
+ fails closed by itself, so `--timeout` must come down under 300s. `--dir` is
5042
+ mandatory here, because a gateway session's `cwd` is the user's home. Without
5043
+ `hooks_auto_accept: true` (or `HERMES_ACCEPT_HOOKS=1`) and no TTY, Hermes
5044
+ **silently never registers the hook**. `fail_closed: true` was OBSERVED to block a
5045
+ hook crash, a hook timeout and unparseable output on `main` `118984d7`, which makes
5046
+ this the first adapter since Claude Code that is a gate rather than a backstop;
5047
+ `v0.21.3` (build 2026.9.14) does not know the key and fails open silently, so
5048
+ `approval doctor`'s `harness-version-unverified` row pins that floor.
5049
+ `approval hook hermes --help` prints the YAML; `docs/hermes-hook.md` opens with the
5050
+ fail-closed result, the floor, and what a post event does not prove.
5051
+
5052
+ **`--harness-cap <duration>` makes the question end before its asker does
5053
+ (APRV-423).** Every harness in the table kills the hook at some ceiling (Hermes
5054
+ caps a `pre_tool_call` entry at 300s, Claude Code kills it at the `timeout` in
5055
+ its settings file), and every one of them reads the killed process as a
5056
+ non-blocking error and runs the tool call. Before this flag the request that
5057
+ hook had opened stayed pending afterwards, so a tap arriving later was recorded
5058
+ as a plain `approval.granted` on a call nobody was holding, and the deny and the
5059
+ grant described one request and disagreed about it (APRV-410).
5060
+
5061
+ The flag states the ceiling. The hook records it on `approval.requested` as
5062
+ `payload.harness_cap_ms`, and the runtime judges the request against the SHORTER
5063
+ of the policy's `defaults.approval_ttl` and that ceiling minus a 60s margin
5064
+ (`HARNESS_CAP_MARGIN_MS` in `src/core/harness-wait.ts`, two of the daemon's 30s
5065
+ sweep intervals, so the `approval.expired` record lands while the harness is
5066
+ still listening in the common case; a skipped sweep or a slow intake can put it
5067
+ after the kill, and what keeps a late tap safe then is the gate's lazy `expired`
5068
+ refusal, which judges the lapse by arithmetic whether or not the record exists).
5069
+ A cap can only SHORTEN: the runtime takes a minimum, so a stated ceiling longer
5070
+ than the TTL changes nothing, which is SPEC.md §11.1 invariant 4 applied to a
5071
+ duration. Without the flag, `hook hermes` assumes **30s**, Hermes's default
5072
+ `plugins.hook_callback_timeout`, which does not clear the margin: every
5073
+ manual-class call is refused `hook-harness-cap-too-short` until the operator
5074
+ raises `plugins.hook_callback_timeout` above the per-entry `timeout` and passes
5075
+ `--harness-cap` with the smaller of the two (`--harness-cap 300s` for the
5076
+ documented `600` over `300`). A stated cap is still clamped to Hermes's observed
5077
+ 300s per-entry maximum. No other adapter assumes a ceiling, because no other
5078
+ harness documents one. A ceiling at or below the margin leaves no window for a
5079
+ human and is refused `hook-harness-cap-too-short` before anything is registered
5080
+ or requested. `approval serve` pins the same value on every hook call it makes
5081
+ with `--hook-harness-cap`, as it pins `--timeout` with `--hook-timeout`.
5082
+
4100
5083
  **Register the same command for the post-execution event too (APRV-145).** One
4101
5084
  binary answers two events, dispatched on `hook_event_name`. A `PostToolUse` or
4102
5085
  `PostToolUseFailure` run closes the delegated `execution.started` the
@@ -4133,7 +5116,38 @@ semantic-version-shaped tag, `tag <name>`, `--tags`, or `--follow-tags` is
4133
5116
  that DELETES a remote ref — `--delete`, `-d`, or a colon refspec such as
4134
5117
  `:refs/heads/x` — is `vcs.ref.delete` with the ref names bound (APRV-352), and a
4135
5118
  tag deletion keeps `release.publish`; ordinary branch pushes retain their branch
4136
- or trunk class. Claude file tools
5119
+ or trunk class.
5120
+
5121
+ `git tag` splits on its flags since APRV-397: a LISTING (`-l`, `--list`, `-n`,
5122
+ `-n<num>`, `--contains`, `--points-at`, `--merged`, `--sort`, `--format`,
5123
+ `--color`, `--ignore-case`, `--omit-empty`, or no argument at all) is
5124
+ `read.shell`, rule `git-tag-read`, the same class `git log`, `git status` and
5125
+ `git branch` take for reading local repository metadata. Everything else stays
5126
+ `release.publish`: `-a`, `-d`, `-f`, `-s`, `-m`, a bare tag name, and any flag
5127
+ the listing allowlist does not name, so a future `git tag` option cannot arrive
5128
+ as a read. A positional under `-l` is a pattern rather than a name.
5129
+
5130
+ The packaging and archive tools are classified too, for the same reason and in
5131
+ the same release (APRV-397): they were `unclassified`, so a verification could
5132
+ not run them and reached for `curl` and a script instead. `npm pack` is a
5133
+ workspace write of the tarball, scoped to `--pack-destination`, and
5134
+ `network.call` when a positional names a registry package; `npm init` writes
5135
+ `package.json`; a bare `npm --version`, `-v`, `-V`, `--help` or `-h` is a read,
5136
+ while every npm subcommand the table does not name keeps its `unclassified`
5137
+ deny; `tar -t` lists (a read) and `tar -x`/`-c` writes into `-C` or the archive
5138
+ `-f` names; `gunzip` reads only with `-c`/`--stdout`, `-t`/`--test` or
5139
+ `-l`/`--list` and otherwise replaces the file it names; `base64` reads unless
5140
+ `-o` names an output;
5141
+ `openssl dgst` (and the `md5`/`sha*` spellings) reads unless `-out` names one,
5142
+ while every other `openssl` subcommand stays unclassified. Each write is scoped
5143
+ by the arithmetic `rm` uses: a destination under a resolved scratch root or a
5144
+ relative one is `files.write.workspace`, and an absolute destination elsewhere, a
5145
+ `..` segment, an unreadable value, or a destination flag with nothing readable
5146
+ after it is `files.delete.out_of_scope`, rule `packaging-write-out-of-scope`,
5147
+ with the destination bound. A `tar` whose mode is not in its words is
5148
+ `hook-opaque`. `docs/claude-code-hook.md` holds the table and the reasoning.
5149
+
5150
+ Claude file tools
4137
5151
  (Edit, Write, MultiEdit, NotebookEdit) and
4138
5152
  Cursor Write/Delete are gated only when the file is policy-protected
4139
5153
  (`APPROVAL.md`, `.approval/`, `CLAUDE.md`, `AGENTS.md`, `.claude/settings*`,
@@ -4170,7 +5184,8 @@ its own flags are not parsed as this verb's.
4170
5184
  - `hook-rejected` — a human said no.
4171
5185
  - `hook-revoked` — a granted approval was withdrawn.
4172
5186
  - `hook-expired` — the TTL lapsed before a decision.
4173
- - `hook-timeout` — no decision inside `--timeout`; the request stays live.
5187
+ - `hook-timeout` — no decision inside the wait (`--timeout`, clamped to what
5188
+ `--harness-cap` leaves after the 60s margin; APRV-423); the request stays live.
4174
5189
  - `hook-gate-refused:<c>` — the gate refused intake; `<c>` is its own frozen code
4175
5190
  (`policy-not-attested`, `budget-exceeded`, …).
4176
5191
  - `hook-policy-unavailable` — `APPROVAL.md` could not be loaded.
@@ -4536,6 +5551,57 @@ defined over the canonical VALUE, so non-JSON input has no defined
4536
5551
  could reproduce. Empty input is the same answer. A file that exists but cannot be
4537
5552
  read is exit 4.
4538
5553
 
5554
+ ## payload run
5555
+
5556
+ The payload `approval run` will recompute for a command, printed before any
5557
+ approval exists: its argv, the cwd it will run in, and the size and SHA-256 of
5558
+ the script that argv names.
5559
+
5560
+ ```
5561
+ approval payload run -- bash scripts/install.sh
5562
+ {"argv":["bash","scripts/install.sh"],"cwd":"/repo",
5563
+ "script":{"argv_index":1,"path":"/repo/scripts/install.sh","bytes":412,
5564
+ "sha256":"fef0…"}}
5565
+ ```
5566
+
5567
+ The `script` object is the whole point of the verb (APRV-401). A grant over
5568
+ `bash scripts/install.sh` used to bind that STRING, so the bytes behind the path
5569
+ were whatever the file held at execution time and the requester controlled the
5570
+ file between the request and the grant. The digest is inside the hashed value,
5571
+ so a script edited after the declaration is refused `payload-mismatch` before the
5572
+ child is spawned and before anything is appended, and the approver's card carries
5573
+ the path, the byte count and the digest because the payload does.
5574
+
5575
+ The argv names a script in two shapes: a known interpreter (the six shells,
5576
+ `node`, `python`/`python3`, `perl`, `ruby`, `deno` — each a name the command
5577
+ classifier already knows) followed by a path operand, or a path at `argv[0]` the
5578
+ kernel reads a shebang from. An inline program (`bash -c …`, `node -e …`) is
5579
+ already a word of the argv and binds no file.
5580
+
5581
+ An argv naming no readable script carries no `script` key and hashes exactly as
5582
+ it did before the rule existed, which is why every record already in a log and
5583
+ every declaration already written into a task file still verifies.
5584
+
5585
+ `--hash` prints the `payload_hash` instead of the bytes — the value a task file's
5586
+ action declaration carries. `--cwd` states the directory the run will happen in,
5587
+ because that directory is inside the hash and every relative argv word resolves
5588
+ against it. The two ends must agree, so pass the same `--cwd` the run will use.
5589
+
5590
+ What is NOT bound is stated in full in `docs/run-payload-binding.md`: nothing
5591
+ resolved through `PATH`, nothing a bound script itself reads or executes, and the
5592
+ instant between this hash and the spawn.
5593
+
5594
+ This verb reads the one file the argv names as its script and nothing else. No
5595
+ log, no policy, no network, no token, and it executes nothing.
5596
+
5597
+ It is **local only**: the MCP wrapper and `approval serve` withhold it, because
5598
+ the path it digests is one of the command's own words and no transport guard
5599
+ confines those the way the store confinement confines `payload hash`'s
5600
+ positional. A remote caller could otherwise ask for the digest of any file the
5601
+ server process can read (`-- bash /etc/shadow`) and learn that the path exists
5602
+ and what its bytes fingerprint to. Compute the binding where the command will
5603
+ run, which is the only place the value is true.
5604
+
4539
5605
  ## payload agentmail-draft
4540
5606
 
4541
5607
  An AgentMail draft is mutable server-side state, so an approval of a draft id
@@ -4791,7 +5857,15 @@ says nothing about it.
4791
5857
  it, and neither is a log to append onto. A working log that is a strict prefix
4792
5858
  of the committed copy is an `anchor-behind` warning, not a stop. The `started`
4793
5859
  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.
5860
+ carries the comparison it made. Git is read, never fetched. When the log was
5861
+ rewritten between the tick's opening read and the check's own read of the file
5862
+ (a `log sync` or a records pull replacing `events.jsonl` under the loop), the
5863
+ check reads the file again through the same verified read, reports one
5864
+ `anchor-reread` warning naming both heads, and the verdict beside it is the
5865
+ second read's (APRV-389). Every `anchor-diverged` stop is reached from the
5866
+ file's bytes and confirmed by that second read, and its message names the seq
5867
+ the chains parted at and both heads without also claiming either chain is a
5868
+ prefix of the other.
4795
5869
  - ENVELOPE DRIFT — a task file whose `state:` contradicts the log gets an
4796
5870
  `envelope.drift` event (actor `system:daemon`), once per claim.
4797
5871
  - TTL SWEEP — every live request whose TTL lapsed gets an `approval.expired`
@@ -4910,8 +5984,11 @@ Warnings go to stderr as `{"event":"warning","code":"...","message":"..."}`, wit
4910
5984
  `code` one of `task-unreadable`, `frontmatter-invalid`, `envelope-invalid`,
4911
5985
  `task-id-missing`, `tasks-dir-unreadable`, `append-refused`, `expire-refused`,
4912
5986
  `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",…}`.
5987
+ `advance-refused`, `dark-session-undetermined`, `anchor-behind`,
5988
+ `anchor-reread`, `checkpoint-due`, `draw-unavailable`, `sample-deferred`,
5989
+ `drift-deferred`. A
5990
+ warning never stops the loop, and neither does
5991
+ `{"event":"git_evidence_failed","step":"commit",…}`.
4915
5992
 
4916
5993
  Dangling advance cycles (APRV-264): at startup and before every trigger, the
4917
5994
  daemon lists every advance execution nobody closed and closes each one this
@@ -4936,6 +6013,73 @@ rather than inferred from the queue's backlog. `rendered` is emitted when the qu
4936
6013
  CHANGES; the file itself is rewritten every tick, because TTL countdowns move even
4937
6014
  when the log does not.
4938
6015
 
6016
+ **A sample that met another writer is deferred, not dropped (APRV-381).** The
6017
+ audit sweep's append is a compare-and-append against the head it read, so it can
6018
+ meet a lock another writer holds (`lock-timeout`) or a record another writer
6019
+ landed in between (`head-moved`). Both are reported as `sample-deferred` on
6020
+ stderr, naming the action key and saying that the sweep retries on the next tick:
6021
+
6022
+ ```
6023
+ warning sample-deferred audit sampling deferred hook:6f2a:vcs.push.main
6024
+ (lock-timeout): another writer holds .approval/log/events.jsonl.lock; gave up
6025
+ after 2000ms The sample is NOT lost: it is still pending in the log's own terms
6026
+ (eligible, drawn, no audit.sampled yet), and the sweep retries it on the next
6027
+ tick.
6028
+ audit.sampled: hook:6f2a:vcs.push.main drawn for review (execution.started at
6029
+ seq 51445) — recorded at seq 51447, the retry of the sample this run deferred
6030
+ earlier
6031
+ ```
6032
+
6033
+ The second line is the same `sampled` event with `"retry":true` added, and it
6034
+ appears only when this run is the run that deferred the sample: a restarted
6035
+ daemon still makes the append, and says nothing about a promise it did not make.
6036
+ Nothing is remembered across processes because nothing needs to be: the sweep
6037
+ re-derives the pending set (eligible, drawn by the sampler, no `audit.sampled`
6038
+ yet) from the verified log on every tick, so a deferral is lost work only if the
6039
+ daemon never runs again.
6040
+
6041
+ Every other append refusal on a sample keeps the `append-refused` form, because
6042
+ `validation`, `canonicalization`, `corrupt-tail` and `io` are facts about the
6043
+ record or the file that no amount of retrying repairs.
6044
+
6045
+ **A drift record that met another writer is deferred too (APRV-403).** The drift
6046
+ scan's append is the same compare-and-append against the head it read, and it
6047
+ met the same contention: the end-to-end test that holds the append lockfile for
6048
+ a whole tick showed the scan printing `append-refused … (lock-timeout)` for an
6049
+ `envelope.drift` the next tick went on to write. So the same split applies, from
6050
+ the same closed list of transient codes, on both drift reasons:
6051
+
6052
+ ```
6053
+ warning drift-deferred envelope.drift for task-042 was deferred (lock-timeout):
6054
+ another writer holds .approval/log/events.jsonl.lock; gave up after 2000ms The
6055
+ record is NOT lost: the disagreement is still there in the log's own terms, and
6056
+ the scan re-derives it and retries on the next tick.
6057
+ envelope.drift: task-042 (backlog/tasks/task-042.md) claims state approved, the
6058
+ log says proposed — recorded at seq 12, the retry of the record this run
6059
+ deferred earlier; the file is repaired to match the log later in this tick
6060
+ ```
6061
+
6062
+ The `retry` flag has the same standing as the sample's: it is on the `drift`
6063
+ event only when THIS run is the one that deferred the record, it is output
6064
+ bookkeeping that no append consults, and a restarted daemon makes the same
6065
+ append and claims nothing. Deferring costs nothing because the scan carries no
6066
+ state between ticks: it re-reads the folder, re-derives the state from the
6067
+ verified log, and `driftAlreadyLogged` keeps a repeat idempotent.
6068
+
6069
+ An `envelope.drift` refused for `validation`, `canonicalization`, `corrupt-tail`
6070
+ or `io` keeps the `append-refused` form it always had.
6071
+
6072
+ **The sweep waits `2000ms` for the lock and no longer, by decision.** The daemon
6073
+ is the one writer that could afford a longer wait and deliberately takes the
6074
+ default. A tick is serial, so a blocking wait here delays the expiry lines, the
6075
+ `state:` write-back and `QUEUE.md`; the writers it contends with hold the lock
6076
+ across spans no polite wait covers (`approval log advance` across a whole
6077
+ verify-and-commit, `approval log sync` across a baseline move); and the appends it
6078
+ would be muscling in front of are the ones somebody is waiting on, a hook's gate
6079
+ verdict or a lane's execution record. Deferring costs one line and at most one
6080
+ tick interval, and it is provably lossless. Raise `--interval` only if you want
6081
+ the retry later; there is no flag for the lock wait, on purpose.
6082
+
4939
6083
  ## up
4940
6084
 
4941
6085
  **Normal startup after setup and human attestation.** Run from the intended
@@ -6035,6 +7179,14 @@ STARTTLS, AUTH — and then QUIT. It proves the host answers, that the TLS mode
6035
7179
  the one the server offers, and that the credential is accepted. It does not prove
6036
7180
  delivery, and it puts no message on the wire.
6037
7181
 
7182
+ An `smtp.host` that is an IP address is probed without SNI (APRV-416). A server
7183
+ name is a name, so there is nothing to send for an address, and Node 26 refuses
7184
+ the session outright where earlier versions only warned. The certificate is
7185
+ still verified; what it is verified against is the address, which the server's
7186
+ certificate has to carry as an IP entry in its subject alternative names. A
7187
+ relay whose certificate names only a hostname therefore fails the probe when it
7188
+ is configured by address and passes when it is configured by that name.
7189
+
6038
7190
  A failed probe keeps the values. A laptop behind a captive portal is not a reason
6039
7191
  to make you type five things again. The refusal prints the SMTP code and the
6040
7192
  server's first line, with the credential redacted, and the undo.
@@ -6335,6 +7487,442 @@ The guest instructions string says so, tells the caller to poll `status`, and
6335
7487
  states plainly that a granted request executes nowhere: the demo is the approval
6336
7488
  flow itself.
6337
7489
 
7490
+ ## serve
7491
+
7492
+ `approval serve` is the same agent-facing surface as `mcp serve`, over HTTP,
7493
+ for a harness that has no local log and no local policy: a sandboxed tenant
7494
+ whose daemon runs elsewhere, one process per tenant. It is a transport and not
7495
+ a second gate. Every verdict, every verb and every batch of records comes from
7496
+ the function the CLI dispatches to; nothing under `src/serve/` classifies a
7497
+ command, resolves a policy, mints a token or verifies a chain.
7498
+
7499
+ STDOUT IS EMPTY. The bound address, the identity, the daemon id every record
7500
+ it appends carries (see [Which daemon wrote it](#which-daemon-wrote-it-aprv-448))
7501
+ and the store root go to stderr on one started line. SIGINT and SIGTERM close
7502
+ the listener and exit 0.
7503
+
7504
+ ### The two credentials
7505
+
7506
+ Two bearer values, both read from the launch environment (SPEC.md §11.1
7507
+ invariant 7: nothing is loaded out of the working tree), both required before
7508
+ the listener exists, and refused when they are equal or shorter than 24
7509
+ characters:
7510
+
7511
+ | variable | opens |
7512
+ |---|---|
7513
+ | `APPROVAL_SERVE_AGENT_TOKEN` | `GET /verbs`, `POST /hook/<harness>`, and the allowlisted verbs below |
7514
+ | `APPROVAL_SERVE_TENANT_TOKEN` | `GET /verbs`, `GET /log/follow`, `GET /export`, `GET /status`, and every other published verb |
7515
+
7516
+ **The agent credential never reads the log it is judged by.** That is the
7517
+ sentence the split exists for. An agent that could stream the verified log
7518
+ could read every decision made about it and the shape of the oversight it is
7519
+ under; an agent that could export the store could carry all of it away.
7520
+
7521
+ Authorization is therefore per verb, and it is an ALLOWLIST. The party at the
7522
+ other end of this transport is a sandboxed harness on another machine, not the
7523
+ operator at their own laptop, so the agent credential opens only what a harness
7524
+ under oversight needs in order to ASK and to ACT ON a grant:
7525
+
7526
+ > `instructions`, `hook_classify`, `request`, `wait`, `withdraw`
7527
+
7528
+ Five verbs, and the shortness is the point: a harness under oversight asks and
7529
+ is answered, and everything else about the gate belongs to the party the gate
7530
+ is for.
7531
+
7532
+ Neither payload builder is on it, under the rule that a builder is the agent's
7533
+ only if it builds from the request's own arguments and never reaches a host
7534
+ resource. `payload_hash` takes a FILE, and on this transport that file is on
7535
+ the daemon's machine while the caller is not: every path it could name is one
7536
+ the host put there, so answering would be a hash oracle that also files the
7537
+ bytes into the payload store. `payload_agentmail-draft` takes two ids rather
7538
+ than a path, but reads the draft over HTTPS with `AGENTMAIL_API_KEY` from this
7539
+ server's own environment, so a remote caller would be spending the host's
7540
+ credential on ids it chose: the same fault wearing a network instead of a
7541
+ filesystem.
7542
+
7543
+ Everything else the catalog publishes is the tenant's. The ones easiest to wave
7544
+ through and most worth naming: `log_tail` and `log_export` return records,
7545
+ `log_verify` and `gate_status` return the log's length, its head hash and the
7546
+ live state of the gate, `queue` returns the tenant's pending decisions,
7547
+ `policy_check` and `policy_test` answer what the policy does with a class and
7548
+ name the absolute store path while doing it, and `status` is the tenant's view
7549
+ of the oversight the agent is under. So are the verbs that touch the host
7550
+ rather than the log: `run` and `sandbox` spawn argv on the daemon's machine,
7551
+ every `adapter_<name>` spends vault credentials, `log_advance` opens pull
7552
+ requests against the tenant's records. And so are `register` (its positional is
7553
+ a host path; the hook endpoint is a harness's way to register, and it
7554
+ synthesises the envelope itself), the two payload builders, `token`,
7555
+ `coverage`, `doctor`, `audit_list`, `audit_obligations`,
7556
+ `channel_telegram_health`, `journal_write`, `journal_read`, `feedback`,
7557
+ `values`, `import_agents-md`, `reindex` and `render`.
7558
+
7559
+ **No caller names a path, and no positional wears a dash.** Four rules, and
7560
+ each closes a route around the others.
7561
+
7562
+ 1. The server appends `--dir`, `--log` and `--policy` to every verb call in
7563
+ every scope from its own launch configuration, and a call supplying one of
7564
+ them is refused `serve-path-pinned` even when the value is correct.
7565
+ 2. A positional beginning with `-` is refused `serve-positional-flag`, in every
7566
+ scope. Positionals are emitted first and verbatim, and the verb reads any
7567
+ token starting with `-` as a flag — including the `--payload=/path`
7568
+ spelling — so without this rule a refused flag could be sent as a value and
7569
+ reach the parser through the front door. `trailing` needs no such rule: it
7570
+ always follows a `--` separator, and the flag parser stops there, which is
7571
+ what lets `hook classify -- git push -f` classify a command line that has
7572
+ flags in it.
7573
+ 3. The AGENT credential may not use a path-typed flag at all
7574
+ (`serve-flag-not-permitted`), and may use only the flags on a short
7575
+ per-verb list. A harness reached through this transport has no files on the
7576
+ daemon's machine, so a path it names is either useless or somebody else's;
7577
+ payload bytes arrive through the hook route, which builds the envelope from
7578
+ the tool call.
7579
+ 4. The TENANT credential's path-typed flags are confined to the store
7580
+ (`serve-path-outside-store`), resolved through `realpath` on the deepest
7581
+ existing ancestor so traversal and a symlinked directory both refuse.
7582
+
7583
+ Which flags are path-typed comes from the registry rather than from a list
7584
+ kept beside the server: a flag declared `"path"` publishes as an ordinary
7585
+ `{"type":"string"}`, so the wire contract is unchanged, and it is confined on
7586
+ the day it appears. A test walks every published verb and fails on any flag
7587
+ that is neither declared `"path"` nor reviewed as something else.
7588
+
7589
+ A verb added to the registry tomorrow is published on both surfaces the same
7590
+ day and is TENANT-scoped until somebody decides otherwise. The allowlist is
7591
+ what widens, and widening it is a diff in the server and a diff in a test.
7592
+
7593
+ An agent-credential call to anything off that list is refused
7594
+ `serve-agent-forbidden`, and a tenant-credential call to an allowlisted verb
7595
+ (`request`, `wait`, `withdraw`, `hook_classify`, or `consume`, which this
7596
+ surface withholds entirely) is refused `serve-tenant-forbidden`. Both are scope refusals rather
7597
+ than not-founds: a caller that guessed a path learns that the door exists and
7598
+ is not theirs.
7599
+
7600
+ Every path requires one of the two. There is no unauthenticated surface at all,
7601
+ not a health check and not a 404, so a caller with no credential learns nothing
7602
+ about what exists.
7603
+
7604
+ ### The endpoints
7605
+
7606
+ `GET /verbs` is the published catalog: `approval mcp serve`'s tool list, name
7607
+ for name and schema for schema, because it is derived from the same registry
7608
+ filter by the same function. `--as` is absent from every published schema and
7609
+ the launch identity is appended last to every argv, so a caller can neither
7610
+ name an identity nor change one. `grant` is absent, as it is there and for the
7611
+ same reason: SPEC.md §11 makes the agent the untrusted policy, and a tool list
7612
+ is a statement about what a surface is for. Each entry carries the scope that
7613
+ opens it, so the catalog is honest about `status` being the tenant's.
7614
+
7615
+ ### The response contract
7616
+
7617
+ `POST /verb/<tool_name>` and `POST /hook/<harness>` answer with ONE body:
7618
+
7619
+ ```json
7620
+ {"exit_code": 0, "stdout": "…", "stderr": "…",
7621
+ "stdout_truncated": false, "stderr_truncated": false}
7622
+ ```
7623
+
7624
+ `stdout` and `stderr` are exactly what the CLI wrote to each stream, and
7625
+ `exit_code` is exactly what it exited. A client reproduces the invocation by
7626
+ writing `stdout`, writing `stderr`, and exiting `exit_code`. A client that
7627
+ wants the verb's machine-readable refusal parses `stderr`, which is the stream
7628
+ the CLI prints refusals on. Both streams are bounded at 256 KiB and clipped at
7629
+ a codepoint boundary, with a flag saying whether that happened.
7630
+
7631
+ **Nothing about a verdict travels in a header.** It used to: the exit code rode
7632
+ on `x-approval-exit-code`, and server-authored refusals did not set it. A
7633
+ client following the documented rule then read a missing header as zero, and
7634
+ zero is ALLOW on Claude Code, Cursor, Codex and Muse — so a 1.1 MB hook
7635
+ envelope came back as a 413 that meant *proceed*. The exit code is now data in
7636
+ the body, it is present on every response including every refusal, and on a
7637
+ refusal it is always non-zero.
7638
+
7639
+ **A client treats a missing, unparseable or truncated body as a BLOCK.** That
7640
+ rule is not a nicety: a transport failure this server never saw (a proxy, a
7641
+ dropped connection, a process killed mid-response) has no exit code to carry,
7642
+ and the only safe reading of "no answer" from a gate is no.
7643
+
7644
+ `POST /hook/<harness>` takes the harness envelope the stdin form would read, for
7645
+ every harness this runtime speaks a hook protocol for. Its `stdout` is
7646
+ BYTE-FOR-BYTE what `approval hook <harness>` prints for that envelope, because
7647
+ it IS that stdout: the request body is handed to `commandHook` as its stdin and
7648
+ nothing reshapes the answer. The exit code must travel with it, because the
7649
+ dialects disagree about where a block lives — Claude Code, Cursor, Codex and
7650
+ Muse put it in the body at exit 0; Grok Build and Hermes use exit 2. Hermes's
7651
+ ALLOW is `{}` and carries its reason on stderr and nowhere else, which is why
7652
+ `stderr` is part of the contract rather than a diagnostic.
7653
+
7654
+ A refusal the SERVER raises on the hook route — an oversized body, a credential
7655
+ at the wrong door, a method it does not answer — carries `{"error":{…}}` and
7656
+ `exit_code: 2` **and** a `stdout` holding the harness's own block directive,
7657
+ rendered by the same function that prints one. A client that writes `stdout`
7658
+ and exits `exit_code` therefore blocks on both halves of every dialect at once.
7659
+ That holds for the refusals that fire BEFORE the URL is parsed, too — a
7660
+ rotated credential, a malformed `Host` — because the harness is read off the
7661
+ raw request target for the purpose of the refusal body and nothing else. A
7662
+ credential rotation must not read as a permission, and on four of the six
7663
+ dialects a body with no block directive at exit 0 is exactly that.
7664
+
7665
+ Where the harness is not known (an unroutable path, an unrecognised name)
7666
+ `stdout` is absent, and the client rule in the paragraph above is what covers
7667
+ it.
7668
+
7669
+ A deny is a VERDICT, so its HTTP status is 200; only a refusal by the server
7670
+ is not.
7671
+
7672
+ **A Hermes tenant needs `--hook-harness-cap`, or every manual class is refused
7673
+ (APRV-423).** `approval serve` pins `--harness-cap` on every hook call with
7674
+ `--hook-harness-cap`, as it pins `--timeout` with `--hook-timeout`. Without it a
7675
+ `POST /hook/hermes` runs as `approval hook hermes` with no flag, which assumes
7676
+ Hermes's 30s default `plugins.hook_callback_timeout`; 30s does not clear the 60s
7677
+ margin, so every manual-class call answers `hook-harness-cap-too-short` (a
7678
+ block, nothing registered or requested, no prompt sent) and a tenant is gated
7679
+ shut rather than gated. The repair for a serve operator, who does not own the
7680
+ tenant's `$HERMES_HOME`: have the tenant raise `plugins.hook_callback_timeout` in
7681
+ the harness's config above the per-entry `timeout`, then start `serve` with
7682
+ `--hook-harness-cap <the smaller of the two>` (`--hook-harness-cap 300s` for the
7683
+ documented `600` over `300`). The value is per server rather than per tenant, so
7684
+ a server fronting several Hermes tenants states the smallest ceiling any of them
7685
+ runs under; a stated cap can only shorten a window, never lengthen one. The
7686
+ same flag bounds the hook's WAIT on every route: `--hook-timeout` is clamped to
7687
+ what the ceiling leaves after the margin, so a hook that adopts an already-open
7688
+ question under a ceiling with no room in it answers `hook-timeout` at once
7689
+ (question left open for the retry grace) instead of being killed mid-wait.
7690
+
7691
+ `GET /log/follow?from=<seq>&cursor_hash=<64hex>&limit=<n>` answers one page of
7692
+ verified records and the cursor to ask with next time. The cursor is EXCLUSIVE
7693
+ and the subscription is `core/log-subscribe.ts`'s, so SPEC.md §8 holds
7694
+ unchanged: the chain is verified from genesis before anything is emitted, and a
7695
+ `cursor_hash` that does not match the retained prefix is refused with the
7696
+ `integrity` code, `cursor-mismatch` as its reason, and NO RECORDS, not even the
7697
+ ones a partial drain had already produced. `caught_up` says whether the page
7698
+ exhausted the log or the limit. The server keeps no subscription state: a page
7699
+ is a function of the cursor in the request and the bytes on disk, so a host that
7700
+ sleeps and wakes serves the same next page it would have served before.
7701
+
7702
+ **`from` greater than zero REQUIRES `cursor_hash`.** The subscription calls the
7703
+ sequence-only form a weaker bootstrap, weak in exactly one way: it cannot
7704
+ detect a fully recomputed replacement prefix on its first read. A streaming
7705
+ consumer pays that once and then retains the digest it verified. A paged one
7706
+ never does — every request is a first read — so the weakness would be
7707
+ permanent, and a caller who dropped the hash would be served a replaced prefix
7708
+ silently while an honest caller who kept it got the refusal. `from=0` is the
7709
+ only hashless form, because replaying from genesis binds nothing.
7710
+
7711
+ `from` must be a sequence number this runtime can represent and `limit` must be
7712
+ between 1 and 1000. Both are refused `serve-invalid-cursor` when they are not,
7713
+ rather than clamped: a clamp answers a different question than the one asked
7714
+ and the caller cannot tell it happened.
7715
+
7716
+ `GET /export` answers the store as a gzipped POSIX tar. Its contents are a
7717
+ positive allowlist (`APPROVAL.md`, `.approval/log/`, `.approval/payloads/`,
7718
+ `.approval/QUEUE.md`, `.approval/index.sqlite`), so `.approval/keys`,
7719
+ `.approval/env`, `.approval/daemon` and any `vault.enc` are out by not being
7720
+ named rather than by a denylist remembering them. `.approval/payloads/` is in,
7721
+ and it is the entry the reasoning turns on: the log records a `payload_hash`
7722
+ for every action a human was shown, and those bytes live there. An archive
7723
+ carrying the hashes without the bytes would hand a tenant a chain of references
7724
+ to evidence they no longer hold, which is a receipt for an exit rather than an
7725
+ exit.
7726
+
7727
+ **No link is followed, of either kind, and a link refuses the whole export.**
7728
+ A name-based allowlist is no defence against one: `ln -s
7729
+ .approval/keys/sender.key .approval/log/note.jsonl` passes every check that
7730
+ reads the name and hands over the key, and a link out of the store hands over
7731
+ any file on the host.
7732
+
7733
+ So every entry is `lstat`ed and a symbolic link refuses the export with
7734
+ `serve-export-symlink`; every file is then opened `O_NOFOLLOW` and `fstat`ed,
7735
+ so the checks are made against the descriptor that is actually read rather
7736
+ than against a name that could have been swapped in between; and a regular
7737
+ file whose link count is greater than one refuses with
7738
+ `serve-export-hardlink`. That last is the case with nothing to notice — a hard
7739
+ link is a second name for one inode, with no link to refuse to follow and no
7740
+ target to inspect — so the link count is the only thing that distinguishes it.
7741
+ Each refusal names the path and never its target.
7742
+
7743
+ The whole export is refused rather than the offending file skipped, because an
7744
+ archive silently missing a file is one nobody can tell from a complete one.
7745
+
7746
+ The only file the export drops on its own account is the append lockfile
7747
+ derived from the log path, which exists for exactly the span of the copy
7748
+ because the export holds that lock. It is matched as an exact path, not as a
7749
+ `*.lock` class: a tenant's own `.approval/payloads/x.lock` is the tenant's, and
7750
+ a name this runtime happens to use for bookkeeping is no reason to drop
7751
+ somebody else's file.
7752
+
7753
+ `GET /status` is the `status` verb, answered to the tenant credential.
7754
+
7755
+ ### The bind, and where TLS is
7756
+
7757
+ `--port <n>` picks the port (default 4682) and always binds `127.0.0.1`.
7758
+ `--listen <[host:]port>` is the only way to bind anything else, passing both is
7759
+ a usage error, and a non-loopback host ALSO requires `--allow-non-loopback`: two
7760
+ flags, because the credentials here are bearer values and a cleartext hop hands
7761
+ them to whoever is on it. A widened bind prints a banner on stderr saying
7762
+ exactly that. This process terminates no TLS and holds no certificate; the
7763
+ supported deployment is a loopback bind behind a proxy the operator owns.
7764
+
7765
+ `--listen unix:<path>` (or `APPROVAL_SERVE_LISTEN=unix:<path>`, which the flag
7766
+ overrides) binds a unix-domain socket instead (APRV-445), for a sandbox on the
7767
+ same machine running as a different uid. The path must be absolute, inside an
7768
+ existing directory OWNED BY THE SERVING UID: that directory is the access
7769
+ control, so a directory somebody else owns is refused at startup (exit 2). The
7770
+ socket itself is opened `0666` once bound, and who may reach it is decided by
7771
+ the directory's mode, group or ACL. An other-writable directory (`/tmp`) is
7772
+ refused, since anyone could swap the socket. The widening to `0666` happens only
7773
+ in a directory nobody but the serving uid can write; in a group-writable one the
7774
+ socket keeps the umask's mode (a notice says so). The residual: between the bind
7775
+ and the chmod the socket carries the umask's narrower mode, which fails closed.
7776
+ A socket file is stale, and replaced, only when a connect to it is refused
7777
+ (`ECONNREFUSED`) or it vanished (`ENOENT`); a live one (another server answers)
7778
+ is refused rather than taken over, one whose probe fails any other way
7779
+ (`EACCES`, a full backlog) is left in place and refused, and anything at the
7780
+ path that is not a socket is refused rather than destroyed. Clients speak plain HTTP over the socket, with the same two
7781
+ bearer credentials. `--allow-non-loopback` has nothing to say about it.
7782
+
7783
+ ### Which daemon wrote it (APRV-448)
7784
+
7785
+ Every record this server appends, from a verb call and from a hook call alike,
7786
+ carries the `daemon` field, and the id is the one on the started line
7787
+ (`records stamped daemon <id> (<source>)`). It is resolved once, at startup, by
7788
+ the rule the daemon loop uses: `APPROVAL_DAEMON_ID` from the launch environment
7789
+ when it is set, otherwise `daemon-` plus the instance id derived from the
7790
+ store's `.approval` directory. That is the id `approval status` reports for the
7791
+ same store, so a tenant who reads `daemon` on a record can look it up. No
7792
+ request names any part of it. A declared id that is not a usable id (lowercase
7793
+ letters, digits, `.`, `-`, `_`, at most 64 characters) refuses the server
7794
+ before it binds, exit 2.
7795
+
7796
+ The attested policy's `daemons` list governs this server at the write boundary,
7797
+ exactly as it governs the daemon. It is re-read from the verified log and the
7798
+ attested policy inside the store lock before every verb call and every hook
7799
+ call, so a list a human narrows and re-attests while the server runs refuses
7800
+ the very next call. A refused append writes nothing, and the verb answers
7801
+ `append-failed` with the write boundary's code under `error.append`:
7802
+
7803
+ ```json
7804
+ {"ok":false,"error":{"code":"append-failed","append":"daemon-not-allowed",
7805
+ "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, …"}}
7806
+ ```
7807
+
7808
+ A hook call whose append is refused blocks, in the harness's own dialect.
7809
+
7810
+ The processes that stamp the field are three: the daemon loop (`approval daemon
7811
+ run`, and `approval up`, whose Telegram listener runs in the daemon's process),
7812
+ this server, and [`channel telegram webhook`](#channel-telegram-webhook). A
7813
+ session's own CLI calls, `approval hook <harness>` run as a harness's own
7814
+ process, and a standalone `channel telegram listen` stamp nothing. This server
7815
+ declares the id without marking itself the daemon: the mark is what routes an
7816
+ advance under the daemon's own autonomous class, and a process that dispatches
7817
+ the verbs an agent asks for does not hold it.
7818
+
7819
+ ### What it never does
7820
+
7821
+ It appends no record on its own account. Every event in the log under it was
7822
+ written by a verb a caller asked for, under the identity the operator fixed at
7823
+ launch, and stamped with the daemon id on the started line. It reads no
7824
+ `.approval/env`.
7825
+
7826
+ ### What it serialises, and what it does not (APRV-427)
7827
+
7828
+ One lock per store, held inside this process. It is the lock for every stretch
7829
+ of work that may append to the store or must read it as one snapshot:
7830
+
7831
+ | work | holds the store lock |
7832
+ |---|---|
7833
+ | `POST /verb/<name>`, including `GET /status` | for the whole call |
7834
+ | `GET /log/follow` | for the page, so no append this process makes lands under a verified read |
7835
+ | `GET /export` | for the whole snapshot, and the log's append lockfile as well, so no other process appends during it either |
7836
+ | `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) |
7837
+ | a hook call's WAIT | never |
7838
+ | `GET /verbs` | never |
7839
+
7840
+ So one hook call waiting on a human holds nothing. While it polls, the tenant's
7841
+ `status`, `queue` and follow answer, and a second hook call opens its own
7842
+ question and waits beside the first. A hook call runs on a worker thread of
7843
+ this process, because a hook's wait is a synchronous sleep and on the
7844
+ listener's own thread it would stop the listener; the verdict, its bytes and
7845
+ its exit code are `approval hook <harness>`'s, exactly as before. A new thread
7846
+ reads the log once BEFORE it asks for the store lock, so the cold walk of a
7847
+ mature log (hundreds of milliseconds) happens in parallel and never inside the
7848
+ lock; its reads under the lock are then warm.
7849
+
7850
+ **The pool is bounded, because each thread holds its own copy of the log**
7851
+ (about 26 MB idle, plus roughly 84 MB once it has read a 73k-record log). At
7852
+ most `--hook-threads` hook calls run at once, 16 by default, and no more
7853
+ threads than that ever exist. Up to `--hook-queue` further calls, 64 by
7854
+ default, wait for a slot in arrival order; a queued call has registered and
7855
+ requested nothing yet. A call that finds every slot and every place in line
7856
+ taken is refused at once with `serve-hook-saturated` (HTTP 503), carrying the
7857
+ harness's own block directive in `stdout` and `exit_code: 2`, and nothing is
7858
+ appended for it. Four finished threads are kept warm for the next call.
7859
+
7860
+ **Time in line is charged to the harness's ceiling.** The ceiling a harness
7861
+ puts on its hook (`--hook-harness-cap`, or the adapter's documented default)
7862
+ runs from the moment the call ARRIVED at this server. A call that waits for a
7863
+ slot or for the store lock spends that budget, and the hook judges, waits by
7864
+ and records on its request the ceiling less that wait, so any question it
7865
+ opens lapses at arrival plus ceiling minus the 60s margin, while the harness
7866
+ is still listening. A call that arrived with room and has less than the
7867
+ margin left when its slot or the lock arrives is refused
7868
+ `serve-hook-saturated` instead, appending nothing: a question opened then
7869
+ would still be on the approver's phone when the harness kills its asker. A
7870
+ ceiling that never had room is the hook's own `hook-harness-cap-too-short`,
7871
+ as before.
7872
+
7873
+ A torn read in a hook's poll (another writer's line caught half-landed, which a
7874
+ filesystem that grows a file a page at a time can show a reader) is read again
7875
+ on the next tick, up to five ticks in a row, before the hook treats the log as
7876
+ unreadable. This holds for the stdin form too, beside the daemon. Verb calls still run one at a time, in the
7877
+ order they arrive, because some of them are synchronous and blocking too (`run`
7878
+ spawns, and `wait` sleeps on the listener's thread, so an agent's `POST
7879
+ /verb/wait` still holds the listener for its own timeout; the hook route is the
7880
+ one a harness waits on).
7881
+
7882
+ Two appends never interleave. Inside this process the store lock is the
7883
+ reason: every append is inside one of the sections above, and the sections run
7884
+ one at a time. Across processes (a daemon, a CLI run beside this server) the
7885
+ reason is the one every `approval` process relies on: each append takes the
7886
+ log's lockfile and compares-and-appends against the head it read.
7887
+
7888
+ **A client that goes away is not answered, and does not spend.** When the HTTP
7889
+ connection of a waiting hook call closes before its answer, the call stops at
7890
+ its next poll tick without spending a grant and without withdrawing its
7891
+ question, and nothing is sent. A grant that lands afterwards is left for the
7892
+ harness's retry of the same command, which carries it and spends it once; an
7893
+ undecided question is adopted by that retry. A call still in line for a
7894
+ thread simply leaves the line. A grant already spent before the connection
7895
+ closed stays spent.
7896
+
7897
+ Closing the listener runs in this order: stop accepting connections; cancel
7898
+ every hook call the same way (waiting calls stop at their next tick, queued
7899
+ ones leave the line); terminate the hook threads from inside the store lock,
7900
+ which also waits out any verb or mutation section still running, so no thread
7901
+ is killed while holding the log's append lockfile; and only then destroy the
7902
+ remaining sockets. A question a cancelled call opened stays open for the retry
7903
+ grace, exactly as for an `approval hook` process that was killed mid-wait.
7904
+ Shutdown therefore takes as long as the longest work already holding the store
7905
+ lock (a `POST /verb/wait` runs to its own timeout), so a platform that kills
7906
+ the process a fixed time after SIGTERM (a container's stop timeout, a
7907
+ supervisor's kill timeout) should allow more than that, or the kill lands on
7908
+ work the lock was protecting. A hook call whose thread fails answers a
7909
+ refusal, `serve-hook-failed`, with the harness's own block directive in
7910
+ `stdout`.
7911
+
7912
+ ## muse
7913
+
7914
+ `approval muse` starts a local, single-tenant synthetic consumer facade. The
7915
+ operator fixes the tenant and distinct read/propose credentials in the launch
7916
+ environment and the store root with `--dir` before the listener binds to
7917
+ loopback. The proposal
7918
+ scope can register an in-memory envelope and request its declared action. The
7919
+ read scope sees status, pending requests and the canonical rendering of a live
7920
+ request. Both scopes are narrow: the listener has no grant, reject, execution,
7921
+ token, generic verb or export route. Human decisions use the separately
7922
+ configured Telegram listener. A pending request does not prove delivery to
7923
+ Telegram. See `docs/muse-connector.md` for the local HTTP contract, native
7924
+ evidence limits and prerequisites.
7925
+
6338
7926
  ## Constrained Codex preparation
6339
7927
 
6340
7928
  approval codex prepare is an artifact generator. It writes one fresh review
@@ -6473,7 +8061,9 @@ The deadline is the policy's `approval_ttl`, not a harness ceiling. Every hook
6473
8061
  adapter answers inside a timeout its harness sets, and the retry grace exists so
6474
8062
  a denial-by-deadline is recoverable; this transport has no timeout at all, so a
6475
8063
  human who answers in eleven minutes is answering rather than arriving too late.
6476
- `--wait` overrides it.
8064
+ `--wait` overrides it. The bridge therefore states no `--harness-cap` and the
8065
+ window shortening of APRV-423 never applies to it: there is no process here that
8066
+ something else will kill, so there is nothing for a cap to keep ahead of.
6477
8067
 
6478
8068
  It answers accept or decline only, in the vocabulary the request advertised
6479
8069
  through `availableDecisions`, matched exactly and never by prefix, so