approval-md 0.2.0 → 0.3.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 (235) hide show
  1. package/README.md +63 -24
  2. package/SPEC.md +57 -11
  3. package/dist/src/channels/contract.d.ts +34 -1
  4. package/dist/src/channels/contract.js +200 -7
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/telegram.d.ts +123 -11
  7. package/dist/src/channels/telegram.js +218 -23
  8. package/dist/src/channels/telegram.js.map +1 -1
  9. package/dist/src/channels/web.d.ts +9 -0
  10. package/dist/src/channels/web.js +17 -0
  11. package/dist/src/channels/web.js.map +1 -1
  12. package/dist/src/cli/amend.js +214 -30
  13. package/dist/src/cli/amend.js.map +1 -1
  14. package/dist/src/cli/attest.d.ts +9 -0
  15. package/dist/src/cli/attest.js +134 -7
  16. package/dist/src/cli/attest.js.map +1 -1
  17. package/dist/src/cli/channel-telegram.d.ts +99 -26
  18. package/dist/src/cli/channel-telegram.js +311 -13
  19. package/dist/src/cli/channel-telegram.js.map +1 -1
  20. package/dist/src/cli/channel.d.ts +9 -0
  21. package/dist/src/cli/channel.js +9 -0
  22. package/dist/src/cli/channel.js.map +1 -1
  23. package/dist/src/cli/codex-bridge.d.ts +819 -0
  24. package/dist/src/cli/codex-bridge.js +1607 -0
  25. package/dist/src/cli/codex-bridge.js.map +1 -0
  26. package/dist/src/cli/codex.d.ts +1 -1
  27. package/dist/src/cli/codex.js +304 -7
  28. package/dist/src/cli/codex.js.map +1 -1
  29. package/dist/src/cli/daemon.js +4 -1
  30. package/dist/src/cli/daemon.js.map +1 -1
  31. package/dist/src/cli/doctor.js +467 -12
  32. package/dist/src/cli/doctor.js.map +1 -1
  33. package/dist/src/cli/execute.js +25 -2
  34. package/dist/src/cli/execute.js.map +1 -1
  35. package/dist/src/cli/help.d.ts +6 -2
  36. package/dist/src/cli/help.js +165 -60
  37. package/dist/src/cli/help.js.map +1 -1
  38. package/dist/src/cli/hook-codex.d.ts +49 -1
  39. package/dist/src/cli/hook-codex.js +60 -1
  40. package/dist/src/cli/hook-codex.js.map +1 -1
  41. package/dist/src/cli/hook.d.ts +459 -3
  42. package/dist/src/cli/hook.js +1062 -114
  43. package/dist/src/cli/hook.js.map +1 -1
  44. package/dist/src/cli/import.js +1 -1
  45. package/dist/src/cli/import.js.map +1 -1
  46. package/dist/src/cli/main.js +5 -3
  47. package/dist/src/cli/main.js.map +1 -1
  48. package/dist/src/cli/policy-apply.d.ts +195 -0
  49. package/dist/src/cli/policy-apply.js +573 -0
  50. package/dist/src/cli/policy-apply.js.map +1 -0
  51. package/dist/src/cli/policy.js +14 -1
  52. package/dist/src/cli/policy.js.map +1 -1
  53. package/dist/src/cli/preflight.d.ts +151 -13
  54. package/dist/src/cli/preflight.js +398 -41
  55. package/dist/src/cli/preflight.js.map +1 -1
  56. package/dist/src/cli/sandbox.js +17 -1
  57. package/dist/src/cli/sandbox.js.map +1 -1
  58. package/dist/src/cli/scaffold.d.ts +1 -1
  59. package/dist/src/cli/scaffold.js +1 -1
  60. package/dist/src/cli/setup-channel.d.ts +9 -0
  61. package/dist/src/cli/setup-channel.js +28 -1
  62. package/dist/src/cli/setup-channel.js.map +1 -1
  63. package/dist/src/cli/setup-common.d.ts +3 -1
  64. package/dist/src/cli/setup-common.js +3 -2
  65. package/dist/src/cli/setup-common.js.map +1 -1
  66. package/dist/src/cli/setup.d.ts +2 -0
  67. package/dist/src/cli/setup.js +94 -2
  68. package/dist/src/cli/setup.js.map +1 -1
  69. package/dist/src/cli/up.js +115 -51
  70. package/dist/src/cli/up.js.map +1 -1
  71. package/dist/src/cli/values.js +3 -4
  72. package/dist/src/cli/values.js.map +1 -1
  73. package/dist/src/cli/verb-registry.js +174 -9
  74. package/dist/src/cli/verb-registry.js.map +1 -1
  75. package/dist/src/cli/wordmark.d.ts +2 -2
  76. package/dist/src/cli/wordmark.js +2 -2
  77. package/dist/src/codex/broker.d.ts +229 -0
  78. package/dist/src/codex/broker.js +548 -0
  79. package/dist/src/codex/broker.js.map +1 -0
  80. package/dist/src/codex/runner.d.ts +178 -0
  81. package/dist/src/codex/runner.js +231 -0
  82. package/dist/src/codex/runner.js.map +1 -0
  83. package/dist/src/codex/serve.d.ts +56 -0
  84. package/dist/src/codex/serve.js +98 -0
  85. package/dist/src/codex/serve.js.map +1 -0
  86. package/dist/src/codex/workspace-commit.d.ts +219 -0
  87. package/dist/src/codex/workspace-commit.js +549 -0
  88. package/dist/src/codex/workspace-commit.js.map +1 -0
  89. package/dist/src/core/advance-cycle.d.ts +51 -0
  90. package/dist/src/core/advance-cycle.js +66 -2
  91. package/dist/src/core/advance-cycle.js.map +1 -1
  92. package/dist/src/core/agents-md.d.ts +20 -18
  93. package/dist/src/core/agents-md.js +33 -31
  94. package/dist/src/core/agents-md.js.map +1 -1
  95. package/dist/src/core/attest.d.ts +215 -0
  96. package/dist/src/core/attest.js +317 -7
  97. package/dist/src/core/attest.js.map +1 -1
  98. package/dist/src/core/audit.d.ts +18 -0
  99. package/dist/src/core/audit.js +13 -0
  100. package/dist/src/core/audit.js.map +1 -1
  101. package/dist/src/core/channel-owner.d.ts +213 -0
  102. package/dist/src/core/channel-owner.js +358 -0
  103. package/dist/src/core/channel-owner.js.map +1 -0
  104. package/dist/src/core/command-class.d.ts +154 -0
  105. package/dist/src/core/command-class.js +673 -20
  106. package/dist/src/core/command-class.js.map +1 -1
  107. package/dist/src/core/commit-guard.d.ts +272 -0
  108. package/dist/src/core/commit-guard.js +424 -0
  109. package/dist/src/core/commit-guard.js.map +1 -0
  110. package/dist/src/core/daemon-actor.d.ts +45 -0
  111. package/dist/src/core/daemon-actor.js +54 -0
  112. package/dist/src/core/daemon-actor.js.map +1 -0
  113. package/dist/src/core/dark-session.d.ts +109 -8
  114. package/dist/src/core/dark-session.js +266 -82
  115. package/dist/src/core/dark-session.js.map +1 -1
  116. package/dist/src/core/decision-refusal.d.ts +23 -2
  117. package/dist/src/core/decision-refusal.js +24 -2
  118. package/dist/src/core/decision-refusal.js.map +1 -1
  119. package/dist/src/core/env-file.d.ts +5 -0
  120. package/dist/src/core/env-file.js +60 -1
  121. package/dist/src/core/env-file.js.map +1 -1
  122. package/dist/src/core/execute.d.ts +15 -2
  123. package/dist/src/core/execute.js +15 -2
  124. package/dist/src/core/execute.js.map +1 -1
  125. package/dist/src/core/gate.d.ts +86 -1
  126. package/dist/src/core/gate.js +81 -1
  127. package/dist/src/core/gate.js.map +1 -1
  128. package/dist/src/core/gesture-refusal.d.ts +166 -0
  129. package/dist/src/core/gesture-refusal.js +188 -0
  130. package/dist/src/core/gesture-refusal.js.map +1 -0
  131. package/dist/src/core/harness-version.d.ts +1 -1
  132. package/dist/src/core/harness-version.js +3 -1
  133. package/dist/src/core/harness-version.js.map +1 -1
  134. package/dist/src/core/instance.d.ts +59 -2
  135. package/dist/src/core/instance.js +113 -0
  136. package/dist/src/core/instance.js.map +1 -1
  137. package/dist/src/core/log.d.ts +39 -1
  138. package/dist/src/core/log.js.map +1 -1
  139. package/dist/src/core/policy-explain.d.ts +10 -0
  140. package/dist/src/core/policy-explain.js +32 -0
  141. package/dist/src/core/policy-explain.js.map +1 -1
  142. package/dist/src/core/policy-load.d.ts +41 -1
  143. package/dist/src/core/policy-load.js +21 -3
  144. package/dist/src/core/policy-load.js.map +1 -1
  145. package/dist/src/core/policy-match.d.ts +43 -0
  146. package/dist/src/core/policy-match.js +52 -0
  147. package/dist/src/core/policy-match.js.map +1 -1
  148. package/dist/src/core/policy-proposal.d.ts +52 -0
  149. package/dist/src/core/policy-proposal.js +102 -2
  150. package/dist/src/core/policy-proposal.js.map +1 -1
  151. package/dist/src/core/protected-path-guard.d.ts +117 -4
  152. package/dist/src/core/protected-path-guard.js +362 -48
  153. package/dist/src/core/protected-path-guard.js.map +1 -1
  154. package/dist/src/core/question-preempted.d.ts +141 -0
  155. package/dist/src/core/question-preempted.js +152 -0
  156. package/dist/src/core/question-preempted.js.map +1 -0
  157. package/dist/src/core/read-scope.d.ts +172 -0
  158. package/dist/src/core/read-scope.js +252 -0
  159. package/dist/src/core/read-scope.js.map +1 -0
  160. package/dist/src/core/sandbox.d.ts +81 -0
  161. package/dist/src/core/sandbox.js +190 -1
  162. package/dist/src/core/sandbox.js.map +1 -1
  163. package/dist/src/core/sender-identity.d.ts +476 -0
  164. package/dist/src/core/sender-identity.js +572 -0
  165. package/dist/src/core/sender-identity.js.map +1 -0
  166. package/dist/src/core/shlex.d.ts +102 -0
  167. package/dist/src/core/shlex.js +159 -0
  168. package/dist/src/core/shlex.js.map +1 -0
  169. package/dist/src/core/values.d.ts +18 -8
  170. package/dist/src/core/values.js +36 -1
  171. package/dist/src/core/values.js.map +1 -1
  172. package/dist/src/daemon/advance.d.ts +10 -0
  173. package/dist/src/daemon/advance.js +25 -4
  174. package/dist/src/daemon/advance.js.map +1 -1
  175. package/dist/src/daemon/daemon.js +9 -0
  176. package/dist/src/daemon/daemon.js.map +1 -1
  177. package/dist/src/daemon/git-evidence.d.ts +2 -2
  178. package/dist/src/daemon/git-evidence.js +1 -1
  179. package/dist/src/mcp/server.js +8 -0
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/docs/cli-reference.md +932 -32
  182. package/docs/codex-enforced-session.md +75 -2
  183. package/docs/codex-workspace-broker.md +118 -0
  184. package/package.json +3 -1
  185. package/schema/event.schema.json +538 -9
  186. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  187. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  188. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  189. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  190. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  191. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  192. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  193. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  194. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  195. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  196. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  197. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  198. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  199. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  200. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  201. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  202. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  203. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  204. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  205. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  206. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  207. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  208. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  209. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  210. package/schema/fixtures/policy/valid/canonical.json +1 -1
  211. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  212. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  213. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  214. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  215. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  216. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  217. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  218. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  219. package/schema/fixtures/values/invalid/version-float.json +1 -0
  220. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  221. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  222. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  223. package/schema/fixtures/values/valid/full.json +5 -7
  224. package/schema/fixtures/values/valid/minimal.json +1 -1
  225. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  226. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  227. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  228. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  229. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  230. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  231. package/schema/fixtures/values-md/valid/absent.md +1 -1
  232. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  233. package/schema/policy.schema.json +54 -2
  234. package/schema/values.schema.json +7 -11
  235. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -390,6 +390,13 @@ did left conflict markers inside the log mid-ceremony.
390
390
  So the ritual became deterministic code, on the `policy amend` precedent: when a
391
391
  hand-ritual proves dangerous, it becomes a verb the gate can read.
392
392
 
393
+ **You rarely type it any more (APRV-346).** `approval up`'s preflight calls this
394
+ function itself whenever the working log is a byte-for-byte extension of the
395
+ committed one, which is the state every records advance leaves behind. What is
396
+ left for a hand-run `approval log sync` is the fork: two chains that share a
397
+ prefix and then carry different records at the same `seq`. See
398
+ [up](#up) for what the preflight checks before it delegates.
399
+
393
400
  Everything runs inside ONE hold of the append lockfile. The lock is normally
394
401
  taken per append; here it spans the whole operation, because an append landing
395
402
  between the snapshot and the restore is exactly the interleaving that forks a
@@ -485,6 +492,12 @@ range they cover, and pushed to a short-lived records branch that exists for
485
492
  exactly that commit. Main is protected here, so the commit reaches it through a
486
493
  pull request; `--pr` opens that pull request through the ordinary `gh` path.
487
494
 
495
+ This verb carries the class `log.advance` for everyone who runs it, in a
496
+ session, in an orchestrator, or at a human terminal. The daemon's cadence
497
+ advance is the one that may carry `log.advance.daemon` instead, and only from
498
+ inside the daemon process: see the `--advance` paragraph under `daemon run`
499
+ (APRV-382).
500
+
488
501
  `--co-author "Name <email>"` adds one validated `Co-authored-by` trailer to the
489
502
  generated records commit and to the pull request body. When the day's pull
490
503
  request already exists, the verb preserves its body and adds the trailer once.
@@ -712,7 +725,21 @@ refusal {"ok":false,"error":{"code":"...","message":"..."}} on stderr
712
725
 
713
726
  `path` is the file that was hashed; the logged payload carries its basename only,
714
727
  so an exported log leaks no home directory. The event's payload is
715
- `{"policy_path":"APPROVAL.md","sha256":"<64 hex>"}`.
728
+ `{"policy_path":"APPROVAL.md","sha256":"<64 hex>","payload_hash":"<64 hex>"}`.
729
+
730
+ **`payload_hash`: the attested bytes, recoverable (APRV-356).** The verb also
731
+ writes the attested text to the payload store beside the log and binds that
732
+ file's hash on the record, so the policy IN FORCE can be produced rather than
733
+ only named. A digest cannot produce the file it names, and the privileged-gesture
734
+ rule (`policy.core` gestures from a channel, SPEC.md §10.3) has to be decided
735
+ against the policy in force rather than against the one being proposed. A reader
736
+ re-hashes the recovered text against `sha256` from the verified log, so the store
737
+ is checked rather than trusted, and a store that cannot be written REFUSES the
738
+ attestation: nothing is appended, because a record claiming a binding whose bytes
739
+ are absent is a worse artifact than no record. Records written before this
740
+ existed still validate and verify; a reader treats the absent field as the
741
+ pre-amendment state, where the in-force bytes are unrecoverable and the
742
+ fail-closed fallback applies.
716
743
 
717
744
  ### `--organ <path>`: the gate's organs (APRV-272)
718
745
 
@@ -769,6 +796,92 @@ current bytes carry no attestation. It never moves doctor's exit code: an
769
796
  unattested organ breaks nothing on this machine, and the enforcement for one is
770
797
  the guard in CI.
771
798
 
799
+ ### `--path <path>`: signing off a protected file (APRV-338)
800
+
801
+ SPEC.md's amendment-provenance rule says text that reached a protected file
802
+ without a grant carries `(Amended APRV-n, pending sign-off.)` and holds no more
803
+ authority than a proposal until a human ratifies it. Nothing recorded the
804
+ ratification: no event, no verb, and no way for CI or doctor to tell a ratified
805
+ amendment from a pending one. This flag is that record.
806
+
807
+ ```
808
+ approval policy attest --path SPEC.md --as human:carter
809
+ ```
810
+
811
+ One path per call, repository-relative (an absolute path under `--dir` is
812
+ accepted and recorded relative). The runtime hashes the bytes on disk; there is
813
+ no flag for the digest. The record is a **`gate.path.signed_off`** event:
814
+
815
+ ```
816
+ {"event":"gate.path.signed_off","actor":"human:<id>",
817
+ "payload":{"path":"SPEC.md","sha256":"<64 hex>"}}
818
+ ```
819
+
820
+ **Which paths.** Exactly those whose edits classify `policy.edit` or a
821
+ `policy.edit.*` sub-class: the built-in prose set (`CLAUDE.md`, `AGENTS.md`,
822
+ `.npmrc`, `.github/workflows/`) plus everything the live policy's
823
+ `protected_paths` widens to, including a path routed to a sub-class such as
824
+ `policy.edit.design`. The policy is loaded to answer that, and a policy that
825
+ does not load contributes nothing, which narrows what may be signed rather than
826
+ widening it.
827
+
828
+ Three refusals are specific to this flag, all exit 2:
829
+
830
+ ```
831
+ path-is-policy the policy file: use `approval policy attest` with no flag
832
+ path-is-core a gate organ (use --organ), the approval home, or the log
833
+ directory, which no verb ratifies
834
+ path-not-protected an ordinary file, or a path that is not repository-relative
835
+ ```
836
+
837
+ `--organ` and `--path` together are a usage error, as are `--policy` and
838
+ `--path`: each pair names two different claims and guessing which one was meant
839
+ is how the wrong record gets written. `--json` adds `signed_path`:
840
+
841
+ ```
842
+ success {"ok":true,"seq":7,"sha256":"<64 hex>","path":"/abs/SPEC.md",
843
+ "signed_path":"SPEC.md"}
844
+ ```
845
+
846
+ **Weaker than a grant, and read last.** A grant binds the exact hunk a human saw
847
+ in a prompt; a sign-off stands for the whole file. So the protected-path guard
848
+ asks about a sign-off only after its search for a grant covering the change has
849
+ come up empty, and the finding it prints says so in words. A change that a grant
850
+ does cover still passes on the grant and still names it. Signing off is for the
851
+ case the pending-sign-off suffix was invented for: text a human has read at that
852
+ commit and agrees with, for which no grant was ever taken.
853
+
854
+ **Signing off bytes that are on a branch.** The digest the guard checks is the
855
+ file's blob at the commit under review, and that is usually a pull request's
856
+ head rather than anything on disk in the primary checkout. `--dir` and `--log`
857
+ are separate flags for exactly this: `--dir` is the checkout whose bytes are
858
+ hashed and which the recorded path is relative to, `--log` is the log the record
859
+ is appended to. So a human ratifying an open pull request reads the change,
860
+ puts a checkout at that commit somewhere (a `git worktree`, or the branch
861
+ checked out in a scratch clone), and runs:
862
+
863
+ ```
864
+ approval policy attest --path SPEC.md \
865
+ --dir /path/to/checkout-at-that-commit \
866
+ --log /path/to/primary/.approval/log/events.jsonl \
867
+ --as human:<id>
868
+ ```
869
+
870
+ The record lands in the primary checkout's log, where a log advance carries it
871
+ to a records branch, and the guard then finds it for that path at that digest.
872
+ The verb never writes a log inside the worktree it hashed. If the digest it
873
+ prints is not the one the guard's failure named, the checkout is at the wrong
874
+ commit or the file has been edited since: the two must agree exactly, and a
875
+ mismatch is a sign-off on bytes nobody reviewed.
876
+
877
+ The verb classifies `policy.core` when `--path` is present (without it the verb
878
+ attests the gate's own configuration and stays pass-through), so under this
879
+ repository's policy the harness hook denies it to an agent with
880
+ `hook-class-human-only` before the verb's own `actor-not-human` refusal is
881
+ reached. `approval doctor`'s `pending-sign-off` row lists the protected files
882
+ that still carry the marker with no record over their current bytes; like
883
+ `gate-organs` it never moves doctor's exit code.
884
+
772
885
  ## policy amend
773
886
 
774
887
  **Progress, on stderr.** The verb re-verifies the whole chain and recovers the
@@ -832,6 +945,16 @@ resolution still prints in the semantic diff below for the human who attests it.
832
945
  Until APRV-296 every declared class had to be pinned, in both directions, and a
833
946
  one-line TTL amendment on 2026-09-07 took three runs to land because of it.
834
947
 
948
+ The one check every declared class still faces is REACHABILITY: a class nothing
949
+ can emit is a line that will never fire, and the ceremony refuses it rather than
950
+ leaving an operator believing in it for a year. Three ways to be reachable: the
951
+ command classifier's fixed table, a `protected_paths` entry routing a path
952
+ family to a `policy.edit` sub-class, and `RUNTIME_CLASSES` in
953
+ `src/core/command-class.ts`, which names the classes a runtime cycle asks the
954
+ gate for directly and no command spells (`log.advance.daemon` is the first,
955
+ APRV-382). A new class of that third kind needs its line there in the SAME build
956
+ the ceremony runs, or the amendment refuses `policy-suite-failed`.
957
+
835
958
  **And then the whole dogfood suite, still before the attestation.** The pin check
836
959
  is a subset of `tests/dogfood.test.ts`, and the seq 23351 ceremony passed the pins
837
960
  and went red on CI over a dogfood test about the values block. So `--commit` also
@@ -912,15 +1035,62 @@ base carries it, and a pins edit somebody else landed is not reverted by this
912
1035
  ceremony. The pin deltas print in the semantic diff beside the class deltas, ride
913
1036
  in the commit subject, and appear in `--json` as `pins`.
914
1037
 
915
- It refuses outside a git repository, and refuses when the index holds staged
916
- changes to anything beyond those three: a commit that swept in an unrelated
917
- staged edit would make "this commit is the amendment" false. On the branch flow
1038
+ It refuses outside a git repository (`commit-preconditions`), and refuses
1039
+ `staged-unrelated` when the index holds staged changes to anything beyond those
1040
+ three: a commit that swept in an unrelated staged edit would make "this commit is
1041
+ the amendment" false. It refuses `dirty-tree` when one of those three is staged
1042
+ in one state and modified again in the working tree, because the commit is
1043
+ assembled from the WORKING TREE and would otherwise carry bytes the operator's
1044
+ `git diff --cached` never showed. An unrelated *unstaged* path is deliberately
1045
+ not a refusal: the scratch index lays exactly the ceremony's paths over the
1046
+ remote's tree, so nothing else can reach the commit, and refusing over one would
1047
+ stop the ceremony in the checkout it is written for, where the daemon's envelope
1048
+ write-backs leave task files modified as a matter of course. On the branch flow
918
1049
  it also refuses when there is no `origin` remote, and when a `--branch` name
919
1050
  already exists. Every one of those refusals happens BEFORE the attestation, so a
920
1051
  refused `--commit` never leaves an attested policy without its commit. The same
921
1052
  holds for the fetch, the two base checks, the policy suite and the dogfood suite
922
1053
  above.
923
1054
 
1055
+ **`--pr`: the ceremony finishes its own job (APRV-341).** `--pr` is `--commit`
1056
+ plus "and publish it": it forces the BRANCH flow whatever the protection probe
1057
+ answered, so the amendment is committed on `origin/<default branch>` in a scratch
1058
+ index, pushed to `policy-amend-<seq>`, carried by a pull request, and armed with
1059
+ `gh pr merge <branch> --merge --auto`. The merge queue picks the strategy from
1060
+ there. `--pr --direct` and `--pr --no-publish` are usage errors: each pair asks
1061
+ for opposite ceremonies.
1062
+
1063
+ The pull request is OPENED or UPDATED. `gh pr list --head <branch> --state open`
1064
+ is asked first, and when one is already standing its title and body are edited
1065
+ rather than a second being opened, so a re-run of a ceremony that stopped
1066
+ half-way finishes rather than failing at `gh pr create`. A `gh` that cannot
1067
+ answer the question falls through to `create`, which is the path that was there
1068
+ before.
1069
+
1070
+ **The verb never switches branches.** On 2026-09-16 an agent-written runbook for
1071
+ the primary checkout ended with `git checkout main` after the amend commit, which
1072
+ rewound `APPROVAL.md` and `events.jsonl` under a live appender; the hook then
1073
+ appended 204 records on the stale chain and the log forked. `--pr` exists so that
1074
+ runbook does not: the commit is assembled with `git read-tree` into a scratch
1075
+ index and pushed by sha, the checkout ends the verb on the branch it started on,
1076
+ with the same HEAD, the same index and the same working tree, and the only file
1077
+ that moved is the log, which gained the attestation. A test compares all four
1078
+ before and after.
1079
+
1080
+ Without `--pr` (and on a box with no `gh`) the printed runbook does the same
1081
+ thing by hand, and it does not switch branches either (APRV-360): `approval log
1082
+ sync`, `git add`, `git commit`, `git push origin HEAD:refs/heads/policy-amend-<seq>`,
1083
+ `gh pr create --head`, `gh pr merge --auto --merge`. A fixture test pins the six
1084
+ commands and asserts that no printed line contains a `git checkout`, because a
1085
+ fallback nobody checks is where the branch switch came back.
1086
+
1087
+ The form printed before APRV-360 opened with `git checkout -b policy-amend-<seq>
1088
+ origin/main`. On 2026-09-18 the primary's main was fourteen commits behind, the
1089
+ switch refused rather than overwrite `QUEUE.md`, the working log and six
1090
+ payloads, and the ceremony stalled with an edited, attested, unpublished policy.
1091
+ `approval log sync` is the first line now for that reason: it brings the checkout
1092
+ current, and it refuses `log-diverged` rather than fast-forwarding over a fork.
1093
+
924
1094
  `--commit` also pushes, on both flows. When there is no `origin` to push to, the
925
1095
  direct flow reports the push as still to run rather than listing it among the
926
1096
  commands it ran. `--no-publish` stops the ceremony at the commit: nothing is
@@ -962,9 +1132,9 @@ beneath it.
962
1132
  sha256 8acbd01cda98
963
1133
 
964
1134
  Committed
965
- ✓ committed the policy and the log together:
1135
+ ✓ committed the policy, the log and the attested policy text together:
966
1136
 
967
- git add APPROVAL.md .approval/log/events.jsonl
1137
+ git add APPROVAL.md .approval/log/events.jsonl .approval/payloads/8acbd01cda98….json
968
1138
  git commit -m "Policy: amend APPROVAL.md: 1 class resolution(s) (attested seq 2)"
969
1139
 
970
1140
  Publishing
@@ -1057,10 +1227,16 @@ is the human rendering only.
1057
1227
  prints the SEMANTIC diff, computed by the real engine on both versions;
1058
1228
  4. runs the load advisory;
1059
1229
  5. asks for confirmation (skipped by `--yes` and `--dry-run`);
1060
- 6. attests: one `policy.updated` event, identical to `approval policy attest`;
1061
- 7. prints, or with `--commit` runs, the git ceremony — `git add <policy> <log>`
1062
- (plus the pins when they moved), a `git commit` citing the attestation seq,
1063
- and the push (and, on the branch flow, the branch and the pull request);
1230
+ 6. attests: one `policy.updated` event, identical to `approval policy attest`,
1231
+ which since APRV-356 also stores the attested bytes in the payload store and
1232
+ binds their hash on the record;
1233
+ 7. prints, or with `--commit` runs, the git ceremony — `git add <policy> <log>
1234
+ <attested bytes>` (plus the pins when they moved), a `git commit` citing the
1235
+ attestation seq, and the push (and, on the branch flow, the branch and the
1236
+ pull request). The store file rides in the same commit because a committed
1237
+ log carrying a binding whose bytes were never committed leaves the policy in
1238
+ force unrecoverable for everyone reading that copy, the CI protected-path
1239
+ guard included;
1064
1240
  8. publishes, unless `--no-publish`: a push the remote refuses is answered by
1065
1241
  the branch, push, pull request and auto-merge above, each reported as it
1066
1242
  lands, and a step that fails drops to the runbook from there.
@@ -1117,7 +1293,7 @@ attestation may still proceed.
1117
1293
  "publishing":null|{"attempted":true,"complete":true,
1118
1294
  "via":"direct"|"branch"|"recovery"|"none",
1119
1295
  "branch":null|"policy-amend-2","pushed":true,
1120
- "prUrl":null|"https://...",
1296
+ "prUrl":null|"https://...","prUpdated":false,
1121
1297
  "autoMerge":"armed"|"refused"|"not-attempted",
1122
1298
  "steps":[{"command":"git push origin main","ok":false}],
1123
1299
  "stoppedAt":null|"git push -u origin policy-amend-2",
@@ -1149,10 +1325,19 @@ the message.
1149
1325
  - `io` — the policy file or the log could not be read or written.
1150
1326
  - `load-failed` — `--require-load` and the policy does not load. Nothing was
1151
1327
  appended.
1152
- - `commit-preconditions` — `--commit` outside a git repository, with staged
1153
- changes beyond the policy, the log and the pins, or (branch flow) with no
1154
- origin remote or a `--branch` name already taken. Checked before the
1155
- attestation; nothing was appended.
1328
+ - `commit-preconditions` — `--commit` outside a git repository, with the policy
1329
+ and the log in different repositories, or (branch flow) with no origin remote
1330
+ or a `--branch` name already taken. Checked before the attestation; nothing
1331
+ was appended.
1332
+ - `staged-unrelated` — the index carries staged changes beyond the policy, the
1333
+ log and the pins (APRV-341). Its own code because its repair is its own: one
1334
+ `git restore --staged <path>`. Checked before the attestation; nothing was
1335
+ appended.
1336
+ - `dirty-tree` — one of those three is staged in one state and modified again in
1337
+ the working tree (APRV-341). The commit is assembled from the working tree, so
1338
+ it would carry bytes `git diff --cached` does not show. An unrelated *unstaged*
1339
+ path is not this refusal and never was. Checked before the attestation;
1340
+ nothing was appended.
1156
1341
  - `fetch-failed` / `base-policy-diverged` / `base-log-diverged` — the remote the
1157
1342
  amendment would be based on could not be fetched, carries a policy this edit
1158
1343
  was not written against, or carries a log this working log does not contain.
@@ -1181,6 +1366,114 @@ the message.
1181
1366
  - `log-unreadable` / `log-torn-tail` / `log-corrupt` — nothing is amended from a
1182
1367
  log that does not verify.
1183
1368
 
1369
+ ## policy apply
1370
+
1371
+ **The hand-paste this replaces (APRV-343).** Agents may not write `APPROVAL.md`:
1372
+ it is `policy.core`, and this project's policy holds that class human-only. So a
1373
+ policy change an agent proposes travels as a document under `docs/proposals/`
1374
+ that quotes each current line byte for byte beside its replacement, and the
1375
+ human pastes. Two things go wrong with a paste, and both have:
1376
+
1377
+ - **a whole-file copy reverts what it did not know about.** The prepared file was
1378
+ written against the policy as it stood when the proposal was drafted, so
1379
+ anything landed in between is silently undone. That is the failure
1380
+ `base-policy-diverged` catches for the commit, one step too late to help;
1381
+ - **a paste carries its wrapper.** One paste of a proposal page took the page's
1382
+ own fence with it, which hid a block from the loader (APRV-273).
1383
+
1384
+ `approval policy apply <proposal.md>` answers both by construction. It writes no
1385
+ byte that is not anchored to a byte it proved present in the live file, and it
1386
+ reads fences by their backtick run, so a four-backtick wrapper around a
1387
+ three-backtick block is the wrapper it is rather than part of the content.
1388
+
1389
+ ### The proposal format
1390
+
1391
+ A proposal is ordinary markdown. Anywhere in it, a fenced block **with a
1392
+ declared language** whose immediately preceding non-blank line is a label is a
1393
+ member of a pair:
1394
+
1395
+ | label | means |
1396
+ |---|---|
1397
+ | `Current:` | the bytes as they stand in the live policy |
1398
+ | `Replace with:` | what they become |
1399
+ | `Supersedes:` | optional: an earlier section's RESULT, to match instead |
1400
+
1401
+ A fence with no info string is skipped, which is APRV-273's hazard turned into a
1402
+ rule. A block whose label names no role is skipped too, so a proposal page can
1403
+ carry a `bash` block of commands to run afterwards without the applier mistaking
1404
+ it for policy text. Pairs apply in document order.
1405
+
1406
+ **Supersession is declared, never inferred from position.** A later section that
1407
+ rewrites a line an earlier section already rewrote quotes the earlier section's
1408
+ *result* under a `Supersedes:` label; the applier looks for that text when the
1409
+ section's own `Current` block is no longer in the file, which is exactly the
1410
+ state the earlier section left behind. Position could not carry this: two
1411
+ sections that touch one line are not in general in the order the file needs, and
1412
+ a rule inferred from order is a rule nobody can read off the page. A pair whose
1413
+ `Current` **and** `Supersedes` blocks both occur in the file is
1414
+ `proposal-ambiguous`: two spellings of one line is a question about which the
1415
+ file means, and no verb here will pick.
1416
+
1417
+ **Whole-file replacement is not accepted**, and that is the decision rather than
1418
+ an omission. The verb's whole value is that every byte it writes is anchored to
1419
+ a byte it proved present, which is what makes a stale proposal a refusal instead
1420
+ of a silent revert. A whole-file blob has no anchor. `approval policy amend`
1421
+ over a hand-edited file is already the supported way to replace the file
1422
+ deliberately.
1423
+
1424
+ **The values block is treated exactly as the policy block is**, by knowing
1425
+ nothing about either. The applier is a byte-level replacement over the whole
1426
+ file: it does not parse the policy, does not locate blocks, and does not care
1427
+ which fence a pair lands in. The values block is inert (SPEC §11.1 invariant
1428
+ 10), so applying one changes no verdict, and the attestation the amendment
1429
+ appends covers the whole file's bytes either way (SPEC §5.2, §5.3).
1430
+
1431
+ `docs/proposals/README.md` is the contract as a page for proposal authors, and
1432
+ `docs/proposals/approval-md-2026-09.md` is a worked example of every part of it.
1433
+
1434
+ ### What it does, in order
1435
+
1436
+ 1. Refuses an agent identity (`apply-agent-actor`) before reading anything.
1437
+ 2. Parses the proposal into ordered pairs.
1438
+ 3. Resolves every pair against an **in-memory** copy of the policy. A proposal
1439
+ whose third pair is stale writes nothing at all, so "a stale proposal cannot
1440
+ half-apply" is a property of the code rather than of the order somebody wrote
1441
+ the sections in.
1442
+ 4. Prints the replacements, each as its matched block and its replacement.
1443
+ 5. Asks for confirmation (`--yes` skips it, `--dry-run` stops here).
1444
+ 6. Writes the policy, then runs `approval policy amend --pr` in this process —
1445
+ which asks its OWN question about the semantic diff, because "are these the
1446
+ bytes" and "is this the policy" are different questions.
1447
+
1448
+ **It publishes by default (APRV-360).** The amendment runs with `--pr`, so the
1449
+ branch is created on the remote by refspec, the pull request is opened, the
1450
+ merge is armed, and the checkout ends where it started. Before this the amend
1451
+ ran with no flag: the policy was written and attested, and the operator was
1452
+ handed a procedure that began with a branch switch. On 2026-09-18 the primary
1453
+ refused that switch and the ceremony stalled with an edited, attested,
1454
+ unpublished policy, which is the one state in which every gated operation on the
1455
+ box refuses. `--no-publish` stops at the commit and is passed through to the
1456
+ amend; `--pr` is accepted and does nothing, because runbooks already say it, and
1457
+ `--pr` with `--no-publish` is a usage error.
1458
+
1459
+ `--no-amend` writes and stops, and says loudly that the policy is now edited and
1460
+ unattested. A run where every pair resolves and no byte moves is a success and a
1461
+ no-op: the proposal has already been applied.
1462
+
1463
+ **Refusal codes** (`error.code` with `--json`; frozen public API): `usage`,
1464
+ `io`, `apply-agent-actor`, `proposal-empty`, `proposal-malformed` (a `Current`
1465
+ with no `Replace with`, or the reverse), `proposal-stale` (a quoted current text
1466
+ is not in the file), `proposal-ambiguous` (it occurs more than once, or both it
1467
+ and its superseded text occur). Every one of them writes nothing.
1468
+
1469
+ Two outcomes are deliberately not in that union. Answering no at the
1470
+ confirmation is exit 0 with `aborted:` on stdout, exactly as `policy amend`
1471
+ answers it: nothing failed, and an error object at exit 0 would be a
1472
+ contradiction the caller has to resolve. An amendment that refuses has already
1473
+ printed its own code from its own frozen union, so this verb adds a sentence
1474
+ naming the state that leaves behind — the replacements written, the policy
1475
+ unattested — and returns the amendment's exit code unchanged.
1476
+
1184
1477
  ## register
1185
1478
 
1186
1479
  The task file is read only. Nothing is rewritten, so unknown frontmatter keys
@@ -1798,7 +2091,7 @@ refusal {"ok":false,"error":{"code":"...","message":"...","detail"?:"...",
1798
2091
  ## sandbox
1799
2092
 
1800
2093
  ```
1801
- approval sandbox [--allow-loopback] [--log <path>] -- <cmd> [args…]
2094
+ approval sandbox [--allow-loopback] [--read-jail] [--log <path>] -- <cmd> [args…]
1802
2095
  ```
1803
2096
 
1804
2097
  Runs a command with outbound network denied by the operating system. It appends
@@ -1836,6 +2129,15 @@ is a sandbox somebody turns off.
1836
2129
  server. It is a real widening: a port is a port, and anything listening on one is
1837
2130
  reachable from inside.
1838
2131
 
2132
+ **`--read-jail`** (APRV-347) goes the other way and makes the room smaller: file
2133
+ reads become deny-default, with the gate root, the scratch roots and a fixed
2134
+ runtime set opened by `subpath`. It is already on, without the flag, whenever
2135
+ the policy declares a `read_scope` block, which is the spelling an operator
2136
+ commits and attests; the flag is for trying it on one command first. There is no
2137
+ flag that turns the jail OFF where a policy asked for it, because a flag an agent
2138
+ can pass must only ever narrow what it can do. See
2139
+ [docs/sandboxed-exec.md](./sandboxed-exec.md) for the profile and its limits.
2140
+
1839
2141
  **Exit 127** means the command was NOT run: this machine has no working sandbox
1840
2142
  primitive, or the command is not on `PATH`. Unlike `approval run`, this verb
1841
2143
  fails closed on both: it makes one promise and has nothing else to offer.
@@ -2530,6 +2832,23 @@ The checks, at length:
2530
2832
  log sync` for a diverged log, `approval up` otherwise — never a `git` command:
2531
2833
  a repair line telling an operator to reset a branch would be doctor making the
2532
2834
  decision this project keeps human.
2835
+ - **attested-policy-on-main** — whether the policy the log vouches for is the
2836
+ policy `origin/<branch>` carries (APRV-342). `attestation` above asks whether
2837
+ the LOCAL file is attested; between a `policy amend` and its pull request
2838
+ merging that answer is yes while a fresh checkout of main carries the old
2839
+ policy with no attestation covering it, so every gate operation there refuses
2840
+ `policy-not-attested`. Nothing said so until this row: on 2026-09-16 `approval
2841
+ up` ran in exactly that state and reported "already at the remote tip". PASS
2842
+ when the attested hash equals the SHA-256 of `APPROVAL.md` at the remote tip.
2843
+ FAIL with `attested at seq N, not yet on main`, naming
2844
+ `policy-amend-<seq>` when this checkout has already seen that branch on the
2845
+ remote, and fixing with `approval policy amend --pr` — which opens the pull
2846
+ request or updates the open one, so the same command is right either way.
2847
+ SKIP with no attestation, outside a git checkout, and where there is no
2848
+ remote-tracking ref. **It fetches nothing**, for the reason
2849
+ `main-behind-origin` fetches nothing, and it looks for the amend branch among
2850
+ the remote-tracking refs rather than asking GitHub. `approval up`'s preflight
2851
+ prints the same sentence on stderr and never refuses on it.
2533
2852
  - **harness-version-unverified** — whether the harness binary hosting the
2534
2853
  PreToolUse hook changed since the log last saw a record from it (APRV-227).
2535
2854
  The only row that asks anything about a program outside this repository, and
@@ -2573,7 +2892,14 @@ The checks, at length:
2573
2892
  words SPEC.md §5.3 fixes: a file with no block is an operator who has declared
2574
2893
  no values, which is a state and not a fault. The only FAIL is a block that is
2575
2894
  present and unreadable, and its fix names the code rather than proposing a
2576
- repair, because what the block should say is the human's to write.
2895
+ repair, because what the block should say is the human's to write. The one
2896
+ exception is the block APRV-336 replaced (`version: 1` with a `wants` list):
2897
+ that is a correct document of the wrong vintage rather than a broken one, so
2898
+ the row's detail carries the loader's migration message and its fix names the
2899
+ three edits (fold `wants:` into `like:`, rename `responds:` to
2900
+ `communication:`, quote `version: "0.2"`) plus the re-attestation that the
2901
+ whole-file digest requires. The pass detail lists what the block declares,
2902
+ which are `love`, `like`, `dislike` and `communication` and nothing else.
2577
2903
  - **checkpoint** — how this log stands against its own human-signed checkpoints
2578
2904
  (APRV-257), running the same check as `approval log verify --checkpoints`, so
2579
2905
  two implementations of "does this log's own signature contradict it" cannot
@@ -2622,6 +2948,57 @@ The checks, at length:
2622
2948
  doctor does not interpret or merge the TOML hook tables. Malformed JSON
2623
2949
  FAILS; a different valid Codex hook profile SKIPS as undetermined rather than
2624
2950
  being called broken.
2951
+ - **autonomy-alias** — which rules of this policy still write the deprecated
2952
+ bare `supervised` (APRV-335). The spelling parses as `supervised-retro` and
2953
+ every gate enforces it as one, so the row is never a FAIL and never moves the
2954
+ exit code: a red line over a spelling would be doctor going red over prose.
2955
+ The two PASS shapes are the whole row. Where some rule uses it, the detail
2956
+ names each one, in the order the loader's own notes name them, and the `fix`
2957
+ is `approval policy amend`, which owns the edit and the re-attestation that
2958
+ edit costs. Where none does, the detail says so plainly, which is the answer
2959
+ an operator wants before a future schema version drops the alias. A policy
2960
+ that did not load is a SKIP: it names no level at all, and its own failure is
2961
+ reported by the attestation row and by `approval policy check`.
2962
+ - **pending-sign-off** — which protected files carry SPEC.md's
2963
+ `(Amended APRV-n, pending sign-off.)` marker with no `gate.path.signed_off`
2964
+ record over their current bytes (APRV-338). Informational and never a FAIL,
2965
+ for the two reasons `gate-organs` is: nothing on this machine is broken by
2966
+ unratified prose, and the enforcement that does bite is the CI-side
2967
+ protected-path guard. What the row buys is that the debt is visible at the
2968
+ terminal rather than discovered when a pull request fails. It reads the
2969
+ enumerated directories (`.`, `.github/workflows/`, `docs/`, `design/`) plus
2970
+ every path the policy's `protected_paths` names, keeps only what classifies
2971
+ `policy.edit` or a `policy.edit.*` sub-class, and reports a file whose current
2972
+ bytes ARE signed off as ratified rather than pending. The `fix` is
2973
+ `approval policy attest --path <p> --as human:<id>`, to be run after reading
2974
+ the file; a marker whose text was later granted through the gate should lose
2975
+ the suffix instead.
2976
+ - **sender-mapping** — which approvers a channel whose senders the policy maps
2977
+ can still recognize (APRV-324). A SKIP where no approver declares a `senders`
2978
+ block, which is every installation before the key existed: decisions are
2979
+ recorded against the identity the deciding process was launched with, no
2980
+ channel enforces a mapping, and nothing is wrong. A FAIL where the policy
2981
+ maps senders for a channel and lists an approver on that channel with no id
2982
+ of their own there — that person's next tap is refused `sender-unmapped` and
2983
+ nothing is recorded, so the file says they may decide on a surface where they
2984
+ cannot. A PASS where every approver a mapped channel reaches carries an id
2985
+ there. The `fix` is `approval policy amend`, which owns the edit and the
2986
+ re-attestation it costs. The row reads the policy and nothing else: no log,
2987
+ no network, no credential, and it prints nobody's account id — the mapping is
2988
+ in a file the operator can open, and a health row is read over shoulders.
2989
+ - **codex-auto-reviewer** — has anything other than this gate answered a
2990
+ question this gate exists to ask (APRV-378)? It reads
2991
+ `audit.question_preempted` and nothing else. A FAIL where one landed in the
2992
+ last 24 hours, naming the source, the question in the other party's terms and
2993
+ their verdict; a PASS where none did, mentioning any older ones; a SKIP where
2994
+ the chain did not verify. A PASS says the log holds no such record and NOT
2995
+ that a harness auto-reviewer is off: whether it runs is configuration this
2996
+ runtime cannot read, and a row written against a guessed configuration key
2997
+ would find nothing and report green, which is the worst direction a health
2998
+ check can fail in. The `fix` points at the harness's own configuration,
2999
+ because nothing here can turn another system's reviewer off.
3000
+
3001
+
2625
3002
 
2626
3003
  **`--json`** (one object on stdout):
2627
3004
 
@@ -2650,8 +3027,9 @@ reaches this backlog. A `supervised-live` class puts a declared `live_rate`
2650
3027
  fraction of its actions through the human gate BEFORE they run; those are
2651
3028
  ordinary manual requests with ordinary grants and tokens, a person has already
2652
3029
  answered them, and they are not drawn a second time for retrospective review. A
2653
- `supervised-retro` class — and the bare `supervised`, which is now an alias for
2654
- it — is what this page is about. `approval policy check` names the mode in its
3030
+ `supervised-retro` class — and the bare `supervised`, which is now a deprecated
3031
+ alias for it, still parsed and removed in a future schema version (APRV-335) —
3032
+ is what this page is about. `approval policy check` names the mode in its
2655
3033
  final line and in `outcome.supervision`.
2656
3034
 
2657
3035
  Supervised actions execute immediately and are audited afterwards. The daemon
@@ -3501,6 +3879,64 @@ leaves the sample exactly where it was: open, listed by `approval audit list`,
3501
3879
  and reviewable with `approval audit review <seq>`. Nothing in this channel can
3502
3880
  empty the backlog, which is the property a sampled-audit backlog exists to have.
3503
3881
 
3882
+ **A refused gesture leaves a record (APRV-355).** When the policy maps senders,
3883
+ a checkpoint signature or a review from an account the attested policy names
3884
+ nobody for is refused before any verb runs, and since this change the attempt is
3885
+ recorded: one **`audit.gesture_refused`**, with a `system:gate` actor, the
3886
+ channel, and a payload carrying `gesture`
3887
+ (`checkpoint-signature`, `review`, `review-note`), `code`
3888
+ (`sender-unmapped`, `sender-ambiguous`, `sender-key-unavailable`,
3889
+ `policy-not-attested`), the refusal
3890
+ `message`, the observed `sender`, and `actor` only where the runtime could name
3891
+ a person. Under a keyed mapping (APRV-370) the `sender.id` is the digest rather
3892
+ than the account, and `sender.hashed` is `true` beside it.
3893
+
3894
+ ```
3895
+ {"event":"audit.gesture_refused","actor":"system:gate","channel":"telegram",
3896
+ "payload":{"gesture":"checkpoint-signature","code":"sender-unmapped",
3897
+ "sender":{"channel":"telegram","id":"5551234567"},"message":"…"}}
3898
+ ```
3899
+
3900
+ It exists because the only refusal record before it,
3901
+ `audit.decision_refused`, requires an `action_key` and a `decision` of grant,
3902
+ reject or revoke, and a signature or a review has neither; writing one there
3903
+ would mean inventing both. The record is audit tier in the strict sense: it
3904
+ authorizes nothing, settles no request, charges no budget, is not sampled, and
3905
+ no enforcement path reads it. `approval log tail` and `approval log export`
3906
+ show it like any other record. Nothing about the refusal itself changed — no
3907
+ signature is appended and no review is recorded — and a listener whose policy
3908
+ maps no senders never reaches this path at all.
3909
+
3910
+ **A question answered by something other than the gate leaves a record
3911
+ (APRV-378).** Codex's app-server can resolve an approval with a model call
3912
+ before `approval codex bridge` is asked, and disclose it afterwards through an
3913
+ `item/autoApprovalReview` notification. The bridge stops the session on one
3914
+ (`bridge-auto-reviewer-active`), and it appends exactly one
3915
+ **`audit.question_preempted`** first, through the same append path as every
3916
+ other record:
3917
+
3918
+ ```
3919
+ {"event":"audit.question_preempted","actor":"system:gate",
3920
+ "payload":{"source":"codex-auto-reviewer","verdict":"accept",
3921
+ "question":{"id":"item_01H9","method":"item/autoApprovalReview/completed",
3922
+ "thread":"thread_7f2","turn":"turn_3"}}}
3923
+ ```
3924
+
3925
+ `source` is a closed set naming who answered, so the next system that does this
3926
+ gains a member rather than a type. `question` is the other party's own
3927
+ identifiers, because a record about their decision has to name it in their terms
3928
+ or a reader cannot go and find it on their side. `verdict` is their word
3929
+ verbatim and is ABSENT when the disclosure stated none: a record that said
3930
+ `accept` by default would be this runtime inventing somebody else's decision.
3931
+
3932
+ The record is audit tier in the strict sense: it authorizes nothing, settles no
3933
+ request, charges no budget, is not sampled, and no enforcement path reads it.
3934
+ `approval log tail` and `approval log export` show it like any other record, and
3935
+ `approval doctor`'s `codex-auto-reviewer` row reads it: fail when one landed in
3936
+ the last 24 hours, pass otherwise, and a pass says the log holds no such record
3937
+ rather than that any auto-reviewer is off. Nothing on this machine can say the
3938
+ latter, which is why the bridge probes (APRV-364) instead of reading a setting.
3939
+
3504
3940
  **A settled request stops looking live.** Every terminal state the listener
3505
3941
  observes for a message it sent edits that message: the text becomes the outcome
3506
3942
  (`✓ APPROVED`, `✗ REJECTED`, `✗ REVOKED`, `✗ EXPIRED`, `WITHDRAWN`) with the
@@ -3647,6 +4083,20 @@ A HUMAN commits those files: they are `policy.edit`. `docs/agent-sdk-hook.md`
3647
4083
  is the third caller: a Python Agent SDK application has no settings file, so it
3648
4084
  spawns this same verb from a hook callback (APRV-242).
3649
4085
 
4086
+ **`hook grok` is the one exception to the exit codes above (APRV-243).** Grok
4087
+ Build reads exit 2 as the deny and exit 0 as the allow, whatever stdout said,
4088
+ so on that harness alone a deny is exit 2 with `{"decision":"deny","reason":…}`
4089
+ on stdout, and the post-execution event exits 0 in every case rather than
4090
+ using 2 for visibility. Its envelope is camelCase (`toolName`, `toolInput`,
4091
+ `sessionId`, `hookEventName`, plus a `workspaceRoot` this runtime ignores in
4092
+ favour of `cwd`); snake_case is still read and wins when both spellings are
4093
+ present. `approval hook grok --help` prints the `.grok/hooks/pre-tool-use.json`
4094
+ the human commits, and that entry's own `timeout` must exceed `--timeout`.
4095
+ Grok Build FAILS OPEN on hook timeout, crash and malformed output, with no
4096
+ setting to change it, which the fail-closed invariant does not survive;
4097
+ `docs/grok-hook.md` states which cases the adapter cannot cover and is worth
4098
+ reading before the file is committed.
4099
+
3650
4100
  **Register the same command for the post-execution event too (APRV-145).** One
3651
4101
  binary answers two events, dispatched on `hook_event_name`. A `PostToolUse` or
3652
4102
  `PostToolUseFailure` run closes the delegated `execution.started` the
@@ -3679,8 +4129,11 @@ gate.self the "approval" CLI itself is pass-through
3679
4129
  Bash (Claude Code) and Shell (Cursor) commands are classified into SPEC.md §7
3680
4130
  action classes. A `git push` that names `refs/tags/*`, a bare `v`-prefixed
3681
4131
  semantic-version-shaped tag, `tag <name>`, `--tags`, or `--follow-tags` is
3682
- `release.publish`; force and mirror pushes remain `vcs.history.rewrite`, and
3683
- ordinary branch pushes retain their branch or trunk class. Claude file tools
4132
+ `release.publish`; force and mirror pushes remain `vcs.history.rewrite`; a push
4133
+ that DELETES a remote ref — `--delete`, `-d`, or a colon refspec such as
4134
+ `:refs/heads/x` — is `vcs.ref.delete` with the ref names bound (APRV-352), and a
4135
+ tag deletion keeps `release.publish`; ordinary branch pushes retain their branch
4136
+ or trunk class. Claude file tools
3684
4137
  (Edit, Write, MultiEdit, NotebookEdit) and
3685
4138
  Cursor Write/Delete are gated only when the file is policy-protected
3686
4139
  (`APPROVAL.md`, `.approval/`, `CLAUDE.md`, `AGENTS.md`, `.claude/settings*`,
@@ -3698,8 +4151,21 @@ its own flags are not parsed as this verb's.
3698
4151
  no human is asked. This union's spelling of the gate's `class-human-only`,
3699
4152
  which the detail names in full. The opposite repair to `hook-unclassified`:
3700
4153
  that one says declare a class, this one says a person runs the command.
4154
+ - `hook-harness-launch-unruled` — some class of the command is in the
4155
+ `harness.launch.*` family and this policy names no rule for it (APRV-354).
4156
+ The family resolves only under an explicit rule, `harness.launch.*` or
4157
+ `harness.launch.NAME`, and never under `defaults.autonomy`, because a grant
4158
+ of the class covers the launch and nothing the launched session then does.
4159
+ Distinct from `hook-unclassified` (the classifier had nothing to say; here it
4160
+ was clear and the policy is silent) and from `hook-class-human-only` (the
4161
+ policy has spoken and reserved the class; the repair there is for a person to
4162
+ run the command, and here it is to write a line).
3701
4163
  - `hook-opaque` — a construct whose effect cannot be read from the text
3702
- (`bash -c`, `eval`, backticks, a non-read substitution).
4164
+ (`eval`, `xargs`, backticks, a non-read substitution). A login shell around
4165
+ ONE inline script is classified by that script since APRV-380, so
4166
+ `zsh -lc 'git push origin main'` is `vcs.push.main`; a script file, an extra
4167
+ word, a redirection on the wrapper, an assignment prefix and a nested shell
4168
+ all stay opaque.
3703
4169
  - `hook-unparseable` — the command line could not be tokenized.
3704
4170
  - `hook-rejected` — a human said no.
3705
4171
  - `hook-revoked` — a granted approval was withdrawn.
@@ -3778,8 +4244,10 @@ importer collects the bullets under those headings into a second draft fence,
3778
4244
  ` ```yaml approval-values ` (SPEC.md §5.3), printed after the policy draft on
3779
4245
  stdout and written after it with `--out`; `--json` carries it as
3780
4246
  `values_draft`, or `null` when no such heading exists. Every bullet lands in
3781
- `wants` and none in `love`, `like` or `dislike`: grading is the human's act,
3782
- and an importer that guessed a grade would be putting words in their mouth. A
4247
+ `like`, the middle grade, and none in `love` or `dislike`: how strongly a line
4248
+ is meant is the human's to say, and an importer that reached for the strongest
4249
+ grade would be putting words in their mouth. (`like` is where what an operator
4250
+ asks for lives since APRV-336 folded `wants` into it.) A
3783
4251
  bullet over the schema's 200 characters is truncated with a warning rather than
3784
4252
  dropped, and bullets past the twentieth are kept as comments inside the fence,
3785
4253
  which is the same stance the permissions half takes on unmapped bullets. The
@@ -3911,6 +4379,19 @@ in the file said what the operator wanted the work to be like. The optional
3911
4379
  ` ```yaml approval-values ` block (SPEC.md §5.3) is that, and this verb prints
3912
4380
  it.
3913
4381
 
4382
+ **The keys.** `version`, the quoted string `"0.2"` and the only required one;
4383
+ the standing grades `love`, `like` and `dislike`, printed as `loves:`, `likes:`
4384
+ and `dislikes:`; and `communication`, one sentence or two on how the operator
4385
+ reads and answers, printed under `communication:`. APRV-336 settled that shape:
4386
+ a `wants` list for what the operator asks of an agent folded into `like`, since
4387
+ a request about behaviour and a preference about the output are graded by the
4388
+ same person in the same way and one list is easier to keep true, and `responds`
4389
+ became `communication`, which no longer reads as a sibling of `approval
4390
+ feedback`. A block written to the earlier format (`version: 1`, a `wants` list)
4391
+ is refused with the code `version-unsupported` and a message naming both edits,
4392
+ rather than with a schema violation a reader has to decode. The same code
4393
+ answers `version: 0.2` written without quotes, which YAML reads as a float.
4394
+
3914
4395
  **It is guidance, and it is never policy.** Every output form opens with the
3915
4396
  banner saying so, `--json` carries the same sentence in `note`, and the reason
3916
4397
  is the same discipline `journal read` applies in the opposite direction: a
@@ -4191,7 +4672,22 @@ the clock starts when the daemon starts), and at a clean shutdown when records
4191
4672
  are still owed. Every attempt goes through the gate as `agent:daemon`: the cycle
4192
4673
  registers, requests, and proceeds only where the policy lets it, so a
4193
4674
  `supervised-live` draw that selects the advance, or a class that resolves
4194
- `manual`, stops it with nothing committed and the question in the queue. A gated
4675
+ `manual`, stops it with nothing committed and the question in the queue.
4676
+
4677
+ **Which class it asks under, and which actor may advance autonomously
4678
+ (APRV-382).** Two classes, and the running process picks between them.
4679
+ `log.advance.daemon` is the daemon's own, asked only by the cadence advance
4680
+ inside `approval daemon run` and `approval up`; `log.advance` is what every
4681
+ other actor asks under, a session in a worktree and a human terminal alike. A
4682
+ policy may hold the daemon's class `autonomous` (this repository's does, since
4683
+ the advance publishes records the log already holds, appends nothing and decides
4684
+ nothing) while leaving the base class where it was. Nothing an agent can type
4685
+ reaches the looser line: `approval log advance` classifies `log.advance`
4686
+ whoever runs it. The choice is read from the process rather than from any
4687
+ argument, a policy that declares no rule for the daemon's class leaves the
4688
+ cadence gated exactly as it was, and a cycle that is NOT the daemon's whose
4689
+ class resolves `autonomous` is refused `advance-actor-not-daemon` before
4690
+ anything is appended. A gated
4195
4691
  or failed attempt is an `advance` line plus an `advance-refused` warning, and the
4196
4692
  next tick tries again — the cadence interval is the retry bound, so a refusal
4197
4693
  never loops. One records branch and ONE PULL REQUEST PER DAY: the first advance
@@ -4483,6 +4979,46 @@ Stop `up` before running Telegram setup or a standalone listener for its bot.
4483
4979
  One bot must have one polling runtime. After setup changes, reload the instance
4484
4980
  environment before starting `up` again.
4485
4981
 
4982
+ **Two refusals keep one bot to one instance (APRV-390).** Both are made before
4983
+ the daemon's first tick and before the first poll, and both exit 1 with a
4984
+ machine-readable code on stderr:
4985
+
4986
+ | code | what it means |
4987
+ | --- | --- |
4988
+ | `cross-instance-credential` | A credential variable holds a value this instance did not configure: an `.approval/env` line naming another instance's keystore item, or an export whose value is not what this instance's file resolves to today. The message carries the `unset` line that fixes it. |
4989
+ | `bot-owned-elsewhere` | One `getMe`, before any `getUpdates`, named a bot another instance on this machine has already claimed. The message names that instance's directory. |
4990
+
4991
+ `--allow-cross-instance` overrides both, starts, and prints on every run what it
4992
+ is overriding. Until APRV-390 the first of these was a warning printed on the
4993
+ way past a runtime that had already started, which is how a demo gate spent an
4994
+ evening polling the primary's bot.
4995
+
4996
+ The first refusal COMPARES VALUES rather than only reading names, because it is
4997
+ the one check that is about to use the value. That closes the case names cannot
4998
+ see: `approval env` never overrides a variable this shell has already exported,
4999
+ so a token re-stored in the keystore does not reach a terminal holding the old
5000
+ one, and the daemon answers 401 while the same line read by hand passes `getMe`.
5001
+ It also removes a false positive — an export whose value IS what the file
5002
+ resolves to is correct however it got there, so the documented `eval "$(approval
5003
+ env)"` is not a finding. `approval doctor` keeps the name-only rule; a
5004
+ diagnostic may not block on a keystore-unlock dialog. No value is printed on any
5005
+ path.
5006
+
5007
+ The `getMe` result is recorded against the instance in
5008
+ `.approval/channel-owner.json` (gitignored) and in a per-machine registry under
5009
+ the platform's user state directory, `approval/bots.json`. `APPROVAL_STATE_DIR`
5010
+ relocates that registry. A bot is identified by its id AND its Bot API base,
5011
+ because an id is unique within one deployment and nothing more: two gates
5012
+ pointed at two different `--api-base` values hold two different bots and neither
5013
+ refuses the other. Neither file is evidence, nothing reads them to widen a
5014
+ permission, and losing them costs one `getMe`. An unreachable Bot API is not a
5015
+ refusal: it says so and starts, because a captive portal is also a `getUpdates`
5016
+ that cannot conflict with anything.
5017
+
5018
+ A 409 that still happens at runtime — another machine, or a poller started
5019
+ outside this runtime — is reported once, with the instances the registry knows
5020
+ about, and its repeats are counted rather than reprinted.
5021
+
4486
5022
  **The ambient runtime: the daemon loop and every configured channel in one
4487
5023
  supervised foreground process.** `approval daemon run --with-channels` is the
4488
5024
  same verb spelled from the other side, and it reaches the same function before
@@ -4505,13 +5041,15 @@ because `git status` does not say what the upstream range changed. So the verb
4505
5041
  does all four, and `approval daemon run` runs the identical preflight from the
4506
5042
  identical module, printing the identical two lines.
4507
5043
 
4508
- It is allowed exactly three writes: a `--ff-only` merge, `npm run build`, and
5044
+ It is allowed exactly four writes: a `--ff-only` merge, `npm run build`,
4509
5045
  clearing an untracked `backlog/tasks/` file the incoming commit already contains
4510
- out of the merge's way (APRV-300, described below). It
5046
+ out of the merge's way (APRV-300, described below), and the reconcile `approval
5047
+ log sync` performs on its behalf (APRV-346, described below). It
4511
5048
  never resets, never stashes, never checks anything out, and never touches the
4512
- working log. That list is not caution for its own sake: a working `events.jsonl`
4513
- rewound through git underneath a live appender is fork 2 of 2026-08-20, the
4514
- incident `approval log sync` exists to prevent.
5049
+ working log itself. That list is not caution for its own sake: a working
5050
+ `events.jsonl` rewound through git underneath a live appender is fork 2 of
5051
+ 2026-08-20, the incident `approval log sync` exists to prevent, which is why the
5052
+ one write that does move that file is made by that verb and by nothing here.
4515
5053
 
4516
5054
  **Safe** means both of: this checkout is not AHEAD of the remote, and no path the
4517
5055
  upstream range changes is locally modified. When it is safe, the preflight
@@ -4521,11 +5059,45 @@ running. When it is not, it refuses, and changes nothing:
4521
5059
  | code | fires when | next |
4522
5060
  |---|---|---|
4523
5061
  | `up-preflight-behind-ahead` | `origin/<branch>..HEAD` is non-empty: this checkout carries commits the remote has never seen. A fast-forward is not the operation for that state, and choosing a side is a decision. | look at them (`git log --oneline origin/main..HEAD`), then push them or `git reset --keep` |
4524
- | `up-preflight-log-diverged` | the upstream range rewrites `.approval/log/events.jsonl` or `.approval/QUEUE.md`, and this working copy has uncommitted changes to one of them. The judgment a human could not make by eye. | `approval log sync` |
5062
+ | `up-preflight-log-diverged` | the upstream range rewrites `.approval/log/events.jsonl` or `.approval/QUEUE.md`, this working copy has uncommitted changes to one of them, and the two chains are **not** in a prefix relationship (or cannot be compared at all). The judgment a human could not make by eye. A working log that merely extends the committed one is reconciled instead, see below. | `approval log sync` |
4525
5063
  | `up-preflight-dirty-protected` | some other path the upstream range changes is locally modified, so `git merge --ff-only` would refuse rather than overwrite it. | look at the diff, or `approval up --no-preflight` |
4526
5064
  | `up-preflight-task-file-conflict` | an untracked file under `backlog/tasks/` stopped the fast-forward and it holds lines the incoming copy does not. Which version is wanted is a question, and no verb here will pick. | read the two copies, move yours aside, run `approval up` again |
4527
5065
  | `up-preflight-failed` | a write the preflight attempted did not complete: the fast-forward, or the rebuild. Not a judgment, so it is not in the union above; the message names the step, and for a build it names the exit code `npm run build` came back with. | `npm run build` to see the whole error, or `approval up --no-build` if you mean to run the stale one |
4528
5066
 
5067
+ **A working log that merely extends the committed one is reconciled, not
5068
+ refused (APRV-346).** Every records advance moves `origin/main`'s
5069
+ `.approval/log/events.jsonl` while the hook keeps appending locally, so
5070
+ "upstream changed the log and so did this working copy" is the normal state of
5071
+ the primary checkout after a merge. Refusing it sent the operator to `approval
5072
+ log sync` and then back to `approval up`, every time. So the collision is a
5073
+ question now: `core/log-reconcile.ts` — the same comparison `log sync` and
5074
+ doctor's `log-drift` row use — is asked how the working chain stands to the
5075
+ committed chain at the fetched tip, and:
5076
+
5077
+ - **`ahead`, `behind` or `equal`** — one chain contains the other whole, so
5078
+ adopting the longer one extends and rewinds nothing. The preflight calls
5079
+ `approval log sync` itself, which holds the append lock for its whole ceremony
5080
+ (snapshot, baseline, fast-forward, reconcile, rebuild the projections,
5081
+ post-verify), and prints one line: `synced: fast-forwarded to origin/main
5082
+ <sha>, kept K local records`. The `--json` stream carries it as a
5083
+ `preflight_sync` event and the `preflight` line's `log_synced` reads true.
5084
+ Nothing here reimplements any of that ceremony; it supplies a caller for it;
5085
+ - **`diverged`** — two appenders built different records on one predecessor.
5086
+ Hash chains do not merge, so this is the `up-preflight-log-diverged` refusal
5087
+ above, unchanged, and it is now the **only** case that needs a hand-run
5088
+ `approval log sync` (which will tell you the same thing, at more length).
5089
+
5090
+ Four conditions have to hold before the reconcile is even offered, and each of
5091
+ them answers "refuse" rather than "probably fine": the log is the repository's
5092
+ own `.approval/log/events.jsonl` (not some other file named with `--log`), no
5093
+ *other* path the upstream range touches is locally modified, both chains verify
5094
+ clean, and the relation is a prefix one. A `log sync` that refuses anyway — an
5095
+ appender that took the lock first (`log-sync-locked`), a fork that landed
5096
+ between the read and the ceremony, a git failure — comes back as the same
5097
+ `up-preflight-log-diverged` refusal with the sync's own code and sentence in
5098
+ YOUR STATE. Nothing starts, and the working log is exactly as `log sync` found
5099
+ it.
5100
+
4529
5101
  **An untracked task file no longer stops it (APRV-300).** A lane files
4530
5102
  `backlog/tasks/aprv-299` on its branch and its pull request merges, while the
4531
5103
  primary checkout holds the same path untracked from its own `backlog task
@@ -5342,6 +5914,51 @@ disables it whenever the policy names no variable, and this verb does not edit a
5342
5914
  attested policy file. It prints the block to add and the `approval policy amend`
5343
5915
  ceremony that attests it.
5344
5916
 
5917
+ ## setup sender-key
5918
+
5919
+ The operator-held key that turns a channel account id into the value an
5920
+ `approvers[id].senders` mapping carries (APRV-370). It exists for the deployment
5921
+ that PUBLISHES its policy and its log, where the raw account id is disclosed
5922
+ once in the file and then on every decision.
5923
+
5924
+ A plain unkeyed digest would not fix that. A Telegram account id is a ten-digit
5925
+ decimal number, the whole space is enumerable on a laptop, and a digest anybody
5926
+ can reverse states a privacy property it does not have. So the mapping value is
5927
+ `hmac-sha256:<hex>`, computed under this key, which is in the environment and
5928
+ never in the policy file.
5929
+
5930
+ Bare, the verb mints, stores and records the key, exactly as `setup sampling`
5931
+ does with its secret, and edits no policy file. The value is not printed and
5932
+ there is no verb that prints it.
5933
+
5934
+ With `--id <account-id>` it mints nothing and stores nothing. It reads the key
5935
+ from the environment, prints the `hmac-sha256:<hex>` for that account, and
5936
+ prints the `senders` line and a paste-ready proposal pair around it. That is the
5937
+ one thing an agent writing a policy proposal cannot do — the digest depends on a
5938
+ secret only the operator's machine holds — so the proposal page says to run it
5939
+ rather than carrying a placeholder:
5940
+
5941
+ ```sh
5942
+ approval setup sender-key # once, interactive, human-only
5943
+ eval "$(approval env)"
5944
+ approval setup sender-key --id 7345216485
5945
+ ```
5946
+
5947
+ Because it stores nothing and prints a value designed to be published, `--id` is
5948
+ the one `setup` path that runs without a terminal and accepts `--json`.
5949
+
5950
+ **The key is not an authenticator.** Nothing about the gate's safety rests on
5951
+ its secrecy: somebody who learns it learns which account ids a policy names,
5952
+ which is what the raw form told everybody. What it buys is that the published
5953
+ policy and the published log stop carrying the account.
5954
+
5955
+ **A listener that holds no key under a keyed mapping decides nothing** on that
5956
+ channel. Every tap is refused `sender-key-unavailable`, and there is no fallback
5957
+ to comparing the observed id against the raw entries: without the key the
5958
+ runtime cannot tell whether the account is also claimed by a keyed approver, so
5959
+ it cannot run the ambiguity check the mapping rests on. `approval doctor`'s
5960
+ `sender-mapping` row names the form in use and says whether the key resolves.
5961
+
5345
5962
  ## setup checkpoint
5346
5963
 
5347
5964
  Mints the Ed25519 keypair a human signs the log's head with (APRV-220's record,
@@ -5551,6 +6168,28 @@ a `human:<id>` and an `agent:` or `system:` actor is refused at exit 2. Exit 1
5551
6168
  means the far end refused: an invalid token, a 409 from a running listener, or no
5552
6169
  message reaching the bot before the deadline.
5553
6170
 
6171
+ **One bot per instance (APRV-390), and both names are derived.** The keystore
6172
+ item this verb creates is `approval-tg-token-<instance id>`, where the instance
6173
+ id is the eight hex digits `approval doctor` prints in its `keychain-scope` row,
6174
+ so two gates on one machine store two items. An operator who prefers a readable
6175
+ suffix may write their own name into `.approval/env` instead; nothing is
6176
+ migrated and `approval env --check` reports which item each variable resolves
6177
+ through. The variable is whatever the policy's
6178
+ `channels.telegram.token_env` declares; a policy that declares nothing gets
6179
+ `APPROVAL_TG_TOKEN`, which every other silent policy on the machine also gets,
6180
+ so a second gate on one machine declares a pair of its own (the packaged demo
6181
+ policy declares `APPROVAL_DEMO_TG_TOKEN` and `APPROVAL_DEMO_TG_CHAT`).
6182
+
6183
+ The `getMe` that proves the token also says which bot it is, and this verb
6184
+ records that against the instance in `.approval/channel-owner.json` (gitignored)
6185
+ and in a per-machine registry under the platform's user state directory,
6186
+ `approval/bots.json`. A bot another local instance has already claimed is
6187
+ refused before anything is written, naming that instance's directory; the values
6188
+ are open names and never a token. `--allow-cross-instance` records this instance
6189
+ as an owner anyway and says what it is doing. An instance set up before this
6190
+ existed keeps working: nothing is migrated, and the first `approval up` or
6191
+ listener start writes the record from its own `getMe`.
6192
+
5554
6193
  ## setup service
5555
6194
 
5556
6195
  **It writes one file: the launchd user agent or the systemd user unit that runs
@@ -5710,7 +6349,268 @@ the broker and runner present? POSIX ownership does not establish ACL custody,
5710
6349
  so this slice executes no manifest-selected binary and reports runtime versions
5711
6350
  unchecked. Unknown evidence is a refusal.
5712
6351
 
5713
- The first slice deliberately makes start and serve return codex-not-ready. An
6352
+ The first slice deliberately made start and serve return codex-not-ready. An
5714
6353
  npm install, generated config, or passing bundle check does not create a
5715
6354
  mandatory boundary. The whole approval codex family is operator-only and absent
5716
6355
  from the ordinary broad MCP catalog.
6356
+
6357
+ ### The workspace broker (APRV-325.2)
6358
+
6359
+ approval codex apply is the executable half. It takes an instance manifest and a
6360
+ proposal file, and the split between them is the design: the manifest supplies
6361
+ the acting identity (agent:codex-<instance_id>), the workspace root, the policy
6362
+ file and the log, and the proposal supplies operations and the SHA-256 of the
6363
+ policy it was built against. A proposal naming anything else, an actor, a root,
6364
+ a class, a token, a sandbox posture, is refused input-invalid rather than having
6365
+ the extra key ignored, because a caller that wrote one meant something by it.
6366
+
6367
+ The order of one apply, and why each step is where it is:
6368
+
6369
+ 1. Read the log under chain verification, read the policy once, hash those exact
6370
+ bytes, and check them against the latest attestation. An unattested or
6371
+ drifted policy refuses here, before a plan exists.
6372
+ 2. Compare the caller's expected digest. A mismatch is attestation-drift: the
6373
+ proposal was built against a policy nobody is enforcing.
6374
+ 3. Plan through src/codex/workspace-plan.ts, which does the path work: traversal,
6375
+ symlinks, hardlinks, case aliases, missing sources, existing destinations,
6376
+ preimage digests, and human-only classes refused before any preimage is read.
6377
+ 4. Register one action per distinct path class under a task id derived from the
6378
+ payload hash. Classes are never collapsed: the class is what the operator's
6379
+ roster, budget and autonomy are keyed to.
6380
+ 5. Authorize every leg. A leg the policy sends to a human refuses
6381
+ approval-required and names the action keys to grant; nothing is written, and
6382
+ the same proposal applies once a person has decided.
6383
+ 6. Start every leg before any byte moves. A later leg refusing to start closes
6384
+ the earlier ones execution.failed and leaves the workspace untouched by
6385
+ construction rather than by cleanup.
6386
+ 7. Take the workspace lock, then revalidate the plan under it. A revalidation
6387
+ that precedes the lock proves only what was true before another writer could
6388
+ act.
6389
+ 8. Stage the new bytes and the preimages on the same filesystem, fsync them,
6390
+ write a journal naming the before-state and the after-state, fsync that, and
6391
+ only then apply.
6392
+ 9. Read the workspace back. All-after completes every leg, all-before fails every
6393
+ leg, and anything else, a partial apply, a failed rollback, an endpoint that
6394
+ cannot be read, records execution.indeterminate with the reason
6395
+ workspace-commit-unknown on every leg and retains the journal and the lock.
6396
+
6397
+ approval codex recover reads that retained journal and reports before, after or
6398
+ mixed. It repairs nothing, and the restraint is the point: rolling a mixed
6399
+ workspace forward would guess which half the human approved, and rolling it back
6400
+ would delete the half that committed. Exit 1 means mixed. The resolution is a
6401
+ person's, through approval execution reconcile.
6402
+
6403
+ Custody is claimed only as far as the platform proves it. The broker takes an
6404
+ O_CREAT|O_EXCL lock, which excludes other cooperating brokers and nothing else,
6405
+ and then asks POSIX ownership and mode whether any other principal can write the
6406
+ directories it is about to touch. It reports os-exclusive only when both hold and
6407
+ advisory otherwise, and it always reports acl-unproven, because ownership and
6408
+ mode say nothing about ACLs. --require-exclusive-custody turns the weak answer
6409
+ into a refusal rather than a footnote.
6410
+
6411
+ approval codex serve publishes that broker over stdio as exactly one MCP tool,
6412
+ codex_workspace_apply, checked at call time as well as at list time. It is a
6413
+ different server from approval mcp serve, whose catalog is the whole verb
6414
+ registry and therefore grows: a constrained session has to reach one door, and
6415
+ the same door next month.
6416
+
6417
+ ### The confined session (APRV-325.3)
6418
+
6419
+ approval codex start prepares the room a session's shell runs in. With no
6420
+ -- <command> it reports the room and runs nothing, which is what an operator
6421
+ checking a host should not have to start Codex to learn. With one, it runs that
6422
+ command inside the room and exits with the child's own code.
6423
+
6424
+ The shell gets a disposable workspace under the system temporary directory, and
6425
+ that workspace is the only path it may write. The canonical workspace is
6426
+ readable and never writable. Reads are jailed to exactly those two roots
6427
+ (APRV-347), so the gate home, other repositories and everything else the
6428
+ operator's home holds are unreadable whether or not anyone thought to name them,
6429
+ and the credential denials are emitted after the jail's allows so they remain
6430
+ the last word on the vault, the environment map and the sealing keys. The
6431
+ environment is an allow-list rather than a filtered copy of the operator's, so a
6432
+ provider key nobody taught this runtime about is absent rather than forgotten.
6433
+ Outbound network is denied, loopback included. Descendants inherit all of it,
6434
+ which is the property that matters: a session does not write files by calling
6435
+ into this runtime, it spawns shells that do.
6436
+
6437
+ There is no opt-out flag and no unwrapped fallback. approval run has
6438
+ --no-sandbox because a human's grant over exact bytes is authority to reach the
6439
+ world; a confined session has no such authority to present, so a host with no
6440
+ sandbox mechanism refuses (sandbox-unsupported) where approval run would record
6441
+ unsupported and proceed. A command that cannot be resolved on the session PATH is
6442
+ refused rather than spawned outside the room.
6443
+
6444
+ The disposable workspace is removed when the session ends, so a replay of the
6445
+ same shell work starts from an empty room. Only a brokered change survives it,
6446
+ which is the whole arrangement: the shell cannot reach the canonical workspace,
6447
+ and approval codex apply is how a change that a policy admitted does.
6448
+
6449
+ ### The app-server bridge (APRV-361)
6450
+
6451
+ ```
6452
+ approval codex bridge --prompt <text> [--workspace <dir>] [-- <server command>]
6453
+ ```
6454
+
6455
+ approval codex bridge starts `codex app-server` and answers every approval
6456
+ request it raises through the policy and the log. Each
6457
+ `item/commandExecution/requestApproval` carries `command` and `cwd` on the same
6458
+ frame, minted by the harness runtime, and those two fields are exactly the pair
6459
+ the native hook lacks: a Codex `Bash` pre-event names only the command, so
6460
+ `approval hook codex` refuses every shell call as
6461
+ `hook-unsupported-execution-context` rather than bind bytes whose directory it
6462
+ does not know. Here the directory arrives with the question.
6463
+
6464
+ It reuses the hook's decision path rather than a copy of it. The request becomes
6465
+ the hook's own input (tool `Bash`, `tool_input.command` the string the server
6466
+ sent, `cwd` the directory it named) and goes through the same classifier, the
6467
+ same human-only refusal, the same sandbox requirement, the same loop floor and
6468
+ unattended guard, and the same register, request and wait against the verified
6469
+ view. What differs is where the answer goes: `{id, result: {decision}}` on the
6470
+ connection instead of a decision object on stdout.
6471
+
6472
+ The deadline is the policy's `approval_ttl`, not a harness ceiling. Every hook
6473
+ adapter answers inside a timeout its harness sets, and the retry grace exists so
6474
+ a denial-by-deadline is recoverable; this transport has no timeout at all, so a
6475
+ human who answers in eleven minutes is answering rather than arriving too late.
6476
+ `--wait` overrides it.
6477
+
6478
+ It answers accept or decline only, in the vocabulary the request advertised
6479
+ through `availableDecisions`, matched exactly and never by prefix, so
6480
+ `acceptWithExecpolicyAmendment` is not read as an accept. It never sends
6481
+ `acceptForSession` (standing authority for a whole session is a grant shape this
6482
+ project does not have), `cancel` or `abort` (those mean "stop the turn", and
6483
+ sending one would record an interruption as a denial). A request advertising
6484
+ nothing gets `accept` or `decline` and the report says the word was this
6485
+ runtime's own.
6486
+
6487
+ The vocabulary is eight spellings of those two words (`accept`, `approved`,
6488
+ `approve`, `allow`; `decline`, `denied`, `deny`, `reject`), and since APRV-367
6489
+ it is a type rather than a convention: the reply value cannot be constructed
6490
+ outside the list, one function turns a decision into bytes and re-checks
6491
+ membership there, and a word that somehow failed that check would be sent as a
6492
+ decline, since the only safe substitute for a word you cannot name is no. The
6493
+ match is case-insensitive, and what goes on the wire is this runtime's own
6494
+ spelling of the matched word. The `bridge-decisions` conformance suite pins the
6495
+ behaviour for a second implementation.
6496
+
6497
+ Its own refusals, beside the gate's:
6498
+
6499
+ ```
6500
+ bridge-request-unbound no command, no cwd, or no call identity on the request
6501
+ bridge-command-unbound a command string that names no argv this client can bind (APRV-362)
6502
+ bridge-file-change-unbound an item-based file change whose content this client cannot
6503
+ produce from the item/started frame its item id names: no such
6504
+ frame, an item that is not a fileChange, an empty change set, or
6505
+ a frame belonging to another thread or turn (APRV-379)
6506
+ bridge-file-change-already-completed
6507
+ item/completed for that item arrived before the question, so the
6508
+ change had finished before this client was asked (APRV-379)
6509
+ bridge-unknown-request a server request this client has no reading for
6510
+ ```
6511
+
6512
+ The **item-based file change is correlated, not guessed** (APRV-379). That API
6513
+ puts the content on an earlier `item/started` notification and the approval
6514
+ request refers to it by `itemId`, so the bridge keeps every item the thread
6515
+ announces and decides the request against the frame that id names: the paths
6516
+ take their classes, the payload binds the change set verbatim with
6517
+ `content_sha256` over it as received, and nothing parses the `diff`. The request
6518
+ carries no directory, so the paths resolve against the workspace the bridge
6519
+ named on `thread/start`, and one landing outside it is refused `hook-io`.
6520
+
6521
+ The **exec request binds words, not a rendering** (APRV-362). The item-based API
6522
+ delivers the command as one string, joined from the argv Codex will run, so the
6523
+ bridge un-joins it and the registered payload carries the string that arrived
6524
+ and the argv beside it. A string that is not readable as a join (an unterminated
6525
+ quote, a double quote outside a quoted run, a trailing backslash, or separation
6526
+ no join produces) is `bridge-command-unbound`, because approving it would
6527
+ approve this client's own re-parse. Byte equality with a re-rendering is
6528
+ deliberately not required: a join written for shell safety quotes more than this
6529
+ one does, and demanding equality would refuse ordinary traffic over a quoting
6530
+ rule nothing here records. A legacy argv array is rendered word by word rather
6531
+ than concatenated, so `["bash", "-lc", "rm -rf build"]` reaches the classifier
6532
+ as `bash -lc 'rm -rf build'` and not as five separate words.
6533
+
6534
+ A **legacy `applyPatchApproval` is decided rather than declined** (APRV-363).
6535
+ Its `fileChanges` map rides on the request, so there is nothing to correlate and
6536
+ nothing is re-rendered: the change is classified by the paths it names, through
6537
+ the same protected-path rules every other file tool uses, and the registered
6538
+ payload carries those paths, the change verbatim and `content_sha256` over the
6539
+ map as it arrived. A path that is absolute, or that resolves outside the
6540
+ directory the server named, is refused `hook-io`; a request with a map and no
6541
+ directory (`cwd` or `grantRoot`) is `bridge-request-unbound`. The item-based
6542
+ `item/fileChange/requestApproval`, which carries an identifier and no content,
6543
+ stays declined.
6544
+
6545
+ The thread is started with `approvalPolicy: untrusted` and `sandbox: read-only`.
6546
+ `untrusted` is the wire spelling of the source's `UnlessTrusted`, the only
6547
+ variant under which every command asks, and the server refuses the source name.
6548
+ There is no flag for it: a session gating an unknown fraction of itself is what
6549
+ the pin exists to prevent (APRV-366).
6550
+
6551
+ The pin is checked as well as sent, and a failure ends the run rather than
6552
+ declining one request:
6553
+
6554
+ ```
6555
+ bridge-thread-start-refused the server refused thread/start, so no thread
6556
+ exists and no policy was established; its own
6557
+ error is carried verbatim
6558
+ bridge-approval-policy-mismatch the server reported an effective approval
6559
+ policy that is not untrusted
6560
+ ```
6561
+
6562
+ A server that reports no policy at all is run against, because the observed
6563
+ 0.155.0 server echoes none and a client demanding an echo could not start. The
6564
+ report says which case it was: `thread.requested` is what went on the wire,
6565
+ `thread.effective` is what the server said, and `thread.confirmed` names where
6566
+ the claim comes from: `unconfirmed`, `reported` (a frame echoed the pin back)
6567
+ or `observed` (a probe command produced an approval request that reached this
6568
+ client). Both stops exit 4, as every other protocol stop in this verb does; the
6569
+ code in the report is the part to branch on.
6570
+
6571
+ **A preflight probe runs before every turn** (APRV-364). Codex's auto-reviewer
6572
+ can resolve an approval with a model call before this client is asked, and
6573
+ nothing in the protocol reports whether it is running, so the bridge watches one
6574
+ command instead of reading a setting. Each start asks a preflight turn for
6575
+ `true` and nothing else, before the operator's prompt, with no flag to skip it;
6576
+ it costs one turn per start. The probe's own request is declined immediately as
6577
+ an observation and never reaches the gate, so nothing is registered for it and
6578
+ no approver is asked about it.
6579
+
6580
+ ```
6581
+ bridge-preflight-void the preflight turn ran no command at all, so
6582
+ nothing was established; the report carries
6583
+ the turn's frames verbatim and nothing is
6584
+ retried. Run the verb again
6585
+ bridge-auto-reviewer-active an item/autoApprovalReview notification
6586
+ arrived in either turn: something other than
6587
+ this client answered a question
6588
+ ```
6589
+
6590
+ A probe that RAN without asking is `bridge-approval-policy-mismatch`, because a
6591
+ policy under which one command did not ask is not `untrusted` whatever the
6592
+ server reports about itself. A probe that was asked about lets the real turn
6593
+ run, and `thread.confirmed` becomes `observed`. That word is narrow on purpose:
6594
+ it says one question reached this client unanswered by anything else, and it is
6595
+ not a claim that the auto-reviewer is off. The report's `preflight` block
6596
+ carries the turn id, the command, the outcome (`asked`, `executed`, `void` or
6597
+ `pending`), the word sent, and, on a void, the frames.
6598
+
6599
+ **It is an advisory checkpoint and not a boundary**, for reasons
6600
+ docs/codex-app-server-bridge.md states in full: Codex's auto-reviewer can
6601
+ resolve a question before this client sees it, and the approval policy and
6602
+ sandbox posture decide how many questions exist. An open gate window is not
6603
+ honoured here either, which is the strict direction. The claim it supports is
6604
+ "this client decided every question this app-server child asked in this
6605
+ session", and nothing wider.
6606
+
6607
+ **Custody is the operating system's** (APRV-365). The server is started by this
6608
+ verb as its own child over stdio pipes: there is no socket, nothing binds a
6609
+ path, and no other process holds a descriptor to speak on, so the replay of a
6610
+ pending request to whatever connects next cannot arise inside one run. The
6611
+ scope of the claim is that child and that session; a Codex started outside this
6612
+ arrangement is a different process and nothing here observes it. The verb does
6613
+ not inspect the command after `--` for a shape that would attach to something
6614
+ already running instead: that would be a guess at another program's command
6615
+ line, and a check written against a guessed shape finds nothing while reporting
6616
+ that it looked.