approval-md 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (378) hide show
  1. package/README.md +85 -877
  2. package/SPEC.md +150 -19
  3. package/cli.js +66 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +53 -1
  8. package/dist/src/channels/contract.js +304 -7
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +394 -12
  22. package/dist/src/channels/telegram.js +621 -68
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/channels/web.d.ts +9 -0
  25. package/dist/src/channels/web.js +17 -0
  26. package/dist/src/channels/web.js.map +1 -1
  27. package/dist/src/cli/amend.js +564 -34
  28. package/dist/src/cli/amend.js.map +1 -1
  29. package/dist/src/cli/attest.d.ts +9 -0
  30. package/dist/src/cli/attest.js +177 -13
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/channel-relay.d.ts +108 -0
  33. package/dist/src/cli/channel-relay.js +334 -0
  34. package/dist/src/cli/channel-relay.js.map +1 -0
  35. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  36. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  37. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  38. package/dist/src/cli/channel-telegram.d.ts +347 -28
  39. package/dist/src/cli/channel-telegram.js +763 -28
  40. package/dist/src/cli/channel-telegram.js.map +1 -1
  41. package/dist/src/cli/channel.d.ts +10 -1
  42. package/dist/src/cli/channel.js +14 -1
  43. package/dist/src/cli/channel.js.map +1 -1
  44. package/dist/src/cli/codex-bridge.d.ts +819 -0
  45. package/dist/src/cli/codex-bridge.js +1607 -0
  46. package/dist/src/cli/codex-bridge.js.map +1 -0
  47. package/dist/src/cli/codex.d.ts +1 -1
  48. package/dist/src/cli/codex.js +304 -7
  49. package/dist/src/cli/codex.js.map +1 -1
  50. package/dist/src/cli/daemon.js +41 -6
  51. package/dist/src/cli/daemon.js.map +1 -1
  52. package/dist/src/cli/doctor.d.ts +63 -0
  53. package/dist/src/cli/doctor.js +1322 -122
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +17 -1
  56. package/dist/src/cli/execute.js +366 -22
  57. package/dist/src/cli/execute.js.map +1 -1
  58. package/dist/src/cli/exit-codes.d.ts +12 -0
  59. package/dist/src/cli/exit-codes.js +13 -0
  60. package/dist/src/cli/exit-codes.js.map +1 -1
  61. package/dist/src/cli/gate.d.ts +2 -0
  62. package/dist/src/cli/gate.js +233 -2
  63. package/dist/src/cli/gate.js.map +1 -1
  64. package/dist/src/cli/help.d.ts +14 -2
  65. package/dist/src/cli/help.js +395 -82
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook-codex.d.ts +49 -1
  68. package/dist/src/cli/hook-codex.js +60 -1
  69. package/dist/src/cli/hook-codex.js.map +1 -1
  70. package/dist/src/cli/hook.d.ts +828 -4
  71. package/dist/src/cli/hook.js +2972 -142
  72. package/dist/src/cli/hook.js.map +1 -1
  73. package/dist/src/cli/import.js +1 -1
  74. package/dist/src/cli/import.js.map +1 -1
  75. package/dist/src/cli/init.js +16 -7
  76. package/dist/src/cli/init.js.map +1 -1
  77. package/dist/src/cli/instructions.js +24 -3
  78. package/dist/src/cli/instructions.js.map +1 -1
  79. package/dist/src/cli/log-anchor.d.ts +55 -0
  80. package/dist/src/cli/log-anchor.js +199 -60
  81. package/dist/src/cli/log-anchor.js.map +1 -1
  82. package/dist/src/cli/main.d.ts +0 -12
  83. package/dist/src/cli/main.js +56 -3
  84. package/dist/src/cli/main.js.map +1 -1
  85. package/dist/src/cli/muse.d.ts +2 -0
  86. package/dist/src/cli/muse.js +62 -0
  87. package/dist/src/cli/muse.js.map +1 -0
  88. package/dist/src/cli/payload.js +79 -1
  89. package/dist/src/cli/payload.js.map +1 -1
  90. package/dist/src/cli/policy-apply.d.ts +195 -0
  91. package/dist/src/cli/policy-apply.js +573 -0
  92. package/dist/src/cli/policy-apply.js.map +1 -0
  93. package/dist/src/cli/policy.js +14 -1
  94. package/dist/src/cli/policy.js.map +1 -1
  95. package/dist/src/cli/preflight.d.ts +151 -13
  96. package/dist/src/cli/preflight.js +398 -41
  97. package/dist/src/cli/preflight.js.map +1 -1
  98. package/dist/src/cli/sandbox.js +17 -1
  99. package/dist/src/cli/sandbox.js.map +1 -1
  100. package/dist/src/cli/scaffold.d.ts +1 -1
  101. package/dist/src/cli/scaffold.js +7 -1
  102. package/dist/src/cli/scaffold.js.map +1 -1
  103. package/dist/src/cli/serve.d.ts +53 -0
  104. package/dist/src/cli/serve.js +270 -0
  105. package/dist/src/cli/serve.js.map +1 -0
  106. package/dist/src/cli/setup-channel.d.ts +9 -0
  107. package/dist/src/cli/setup-channel.js +28 -1
  108. package/dist/src/cli/setup-channel.js.map +1 -1
  109. package/dist/src/cli/setup-common.d.ts +3 -1
  110. package/dist/src/cli/setup-common.js +3 -2
  111. package/dist/src/cli/setup-common.js.map +1 -1
  112. package/dist/src/cli/setup.d.ts +2 -0
  113. package/dist/src/cli/setup.js +94 -2
  114. package/dist/src/cli/setup.js.map +1 -1
  115. package/dist/src/cli/up.js +134 -51
  116. package/dist/src/cli/up.js.map +1 -1
  117. package/dist/src/cli/values.js +3 -4
  118. package/dist/src/cli/values.js.map +1 -1
  119. package/dist/src/cli/verb-registry.d.ts +2 -0
  120. package/dist/src/cli/verb-registry.js +510 -47
  121. package/dist/src/cli/verb-registry.js.map +1 -1
  122. package/dist/src/cli/wordmark.d.ts +2 -2
  123. package/dist/src/cli/wordmark.js +2 -2
  124. package/dist/src/codex/broker.d.ts +229 -0
  125. package/dist/src/codex/broker.js +548 -0
  126. package/dist/src/codex/broker.js.map +1 -0
  127. package/dist/src/codex/runner.d.ts +178 -0
  128. package/dist/src/codex/runner.js +231 -0
  129. package/dist/src/codex/runner.js.map +1 -0
  130. package/dist/src/codex/serve.d.ts +56 -0
  131. package/dist/src/codex/serve.js +98 -0
  132. package/dist/src/codex/serve.js.map +1 -0
  133. package/dist/src/codex/workspace-commit.d.ts +219 -0
  134. package/dist/src/codex/workspace-commit.js +549 -0
  135. package/dist/src/codex/workspace-commit.js.map +1 -0
  136. package/dist/src/core/advance-cycle.d.ts +51 -0
  137. package/dist/src/core/advance-cycle.js +66 -2
  138. package/dist/src/core/advance-cycle.js.map +1 -1
  139. package/dist/src/core/agents-md.d.ts +20 -18
  140. package/dist/src/core/agents-md.js +33 -31
  141. package/dist/src/core/agents-md.js.map +1 -1
  142. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  143. package/dist/src/core/attest-bootstrap.js +135 -0
  144. package/dist/src/core/attest-bootstrap.js.map +1 -0
  145. package/dist/src/core/attest.d.ts +234 -2
  146. package/dist/src/core/attest.js +342 -7
  147. package/dist/src/core/attest.js.map +1 -1
  148. package/dist/src/core/audit.d.ts +73 -3
  149. package/dist/src/core/audit.js +65 -5
  150. package/dist/src/core/audit.js.map +1 -1
  151. package/dist/src/core/channel-lease.d.ts +322 -0
  152. package/dist/src/core/channel-lease.js +663 -0
  153. package/dist/src/core/channel-lease.js.map +1 -0
  154. package/dist/src/core/channel-owner.d.ts +213 -0
  155. package/dist/src/core/channel-owner.js +358 -0
  156. package/dist/src/core/channel-owner.js.map +1 -0
  157. package/dist/src/core/command-class.d.ts +205 -0
  158. package/dist/src/core/command-class.js +1433 -28
  159. package/dist/src/core/command-class.js.map +1 -1
  160. package/dist/src/core/commit-guard.d.ts +272 -0
  161. package/dist/src/core/commit-guard.js +424 -0
  162. package/dist/src/core/commit-guard.js.map +1 -0
  163. package/dist/src/core/daemon-actor.d.ts +45 -0
  164. package/dist/src/core/daemon-actor.js +54 -0
  165. package/dist/src/core/daemon-actor.js.map +1 -0
  166. package/dist/src/core/daemon-host.d.ts +178 -0
  167. package/dist/src/core/daemon-host.js +228 -0
  168. package/dist/src/core/daemon-host.js.map +1 -0
  169. package/dist/src/core/daemon-identity.d.ts +238 -0
  170. package/dist/src/core/daemon-identity.js +252 -0
  171. package/dist/src/core/daemon-identity.js.map +1 -0
  172. package/dist/src/core/dark-session.d.ts +109 -8
  173. package/dist/src/core/dark-session.js +266 -82
  174. package/dist/src/core/dark-session.js.map +1 -1
  175. package/dist/src/core/decision-refusal.d.ts +23 -2
  176. package/dist/src/core/decision-refusal.js +24 -2
  177. package/dist/src/core/decision-refusal.js.map +1 -1
  178. package/dist/src/core/env-file.d.ts +5 -0
  179. package/dist/src/core/env-file.js +60 -1
  180. package/dist/src/core/env-file.js.map +1 -1
  181. package/dist/src/core/execute.d.ts +15 -2
  182. package/dist/src/core/execute.js +15 -2
  183. package/dist/src/core/execute.js.map +1 -1
  184. package/dist/src/core/gate.d.ts +392 -6
  185. package/dist/src/core/gate.js +852 -24
  186. package/dist/src/core/gate.js.map +1 -1
  187. package/dist/src/core/gesture-refusal.d.ts +166 -0
  188. package/dist/src/core/gesture-refusal.js +188 -0
  189. package/dist/src/core/gesture-refusal.js.map +1 -0
  190. package/dist/src/core/harness-version.d.ts +128 -4
  191. package/dist/src/core/harness-version.js +181 -12
  192. package/dist/src/core/harness-version.js.map +1 -1
  193. package/dist/src/core/harness-wait.d.ts +82 -3
  194. package/dist/src/core/harness-wait.js +89 -3
  195. package/dist/src/core/harness-wait.js.map +1 -1
  196. package/dist/src/core/instance.d.ts +59 -2
  197. package/dist/src/core/instance.js +113 -0
  198. package/dist/src/core/instance.js.map +1 -1
  199. package/dist/src/core/log-subscribe.d.ts +21 -0
  200. package/dist/src/core/log-subscribe.js +16 -6
  201. package/dist/src/core/log-subscribe.js.map +1 -1
  202. package/dist/src/core/log-write-layer.d.ts +55 -0
  203. package/dist/src/core/log-write-layer.js +91 -0
  204. package/dist/src/core/log-write-layer.js.map +1 -0
  205. package/dist/src/core/log.d.ts +89 -22
  206. package/dist/src/core/log.js +197 -19
  207. package/dist/src/core/log.js.map +1 -1
  208. package/dist/src/core/payload-census.d.ts +51 -0
  209. package/dist/src/core/payload-census.js +125 -2
  210. package/dist/src/core/payload-census.js.map +1 -1
  211. package/dist/src/core/payload-store.d.ts +31 -0
  212. package/dist/src/core/payload-store.js +113 -8
  213. package/dist/src/core/payload-store.js.map +1 -1
  214. package/dist/src/core/payload.d.ts +18 -3
  215. package/dist/src/core/payload.js +20 -4
  216. package/dist/src/core/payload.js.map +1 -1
  217. package/dist/src/core/policy-diff.d.ts +10 -0
  218. package/dist/src/core/policy-diff.js +10 -2
  219. package/dist/src/core/policy-diff.js.map +1 -1
  220. package/dist/src/core/policy-explain.d.ts +17 -1
  221. package/dist/src/core/policy-explain.js +48 -1
  222. package/dist/src/core/policy-explain.js.map +1 -1
  223. package/dist/src/core/policy-load.d.ts +65 -1
  224. package/dist/src/core/policy-load.js +21 -3
  225. package/dist/src/core/policy-load.js.map +1 -1
  226. package/dist/src/core/policy-match.d.ts +81 -0
  227. package/dist/src/core/policy-match.js +87 -0
  228. package/dist/src/core/policy-match.js.map +1 -1
  229. package/dist/src/core/policy-proposal.d.ts +75 -0
  230. package/dist/src/core/policy-proposal.js +110 -4
  231. package/dist/src/core/policy-proposal.js.map +1 -1
  232. package/dist/src/core/protected-path-guard.d.ts +117 -4
  233. package/dist/src/core/protected-path-guard.js +362 -48
  234. package/dist/src/core/protected-path-guard.js.map +1 -1
  235. package/dist/src/core/question-preempted.d.ts +141 -0
  236. package/dist/src/core/question-preempted.js +152 -0
  237. package/dist/src/core/question-preempted.js.map +1 -0
  238. package/dist/src/core/read-scope.d.ts +182 -0
  239. package/dist/src/core/read-scope.js +256 -0
  240. package/dist/src/core/read-scope.js.map +1 -0
  241. package/dist/src/core/run-payload.d.ts +160 -0
  242. package/dist/src/core/run-payload.js +304 -0
  243. package/dist/src/core/run-payload.js.map +1 -0
  244. package/dist/src/core/sandbox.d.ts +81 -0
  245. package/dist/src/core/sandbox.js +190 -1
  246. package/dist/src/core/sandbox.js.map +1 -1
  247. package/dist/src/core/sender-identity.d.ts +501 -0
  248. package/dist/src/core/sender-identity.js +609 -0
  249. package/dist/src/core/sender-identity.js.map +1 -0
  250. package/dist/src/core/shlex.d.ts +102 -0
  251. package/dist/src/core/shlex.js +159 -0
  252. package/dist/src/core/shlex.js.map +1 -0
  253. package/dist/src/core/state.d.ts +52 -1
  254. package/dist/src/core/state.js +52 -6
  255. package/dist/src/core/state.js.map +1 -1
  256. package/dist/src/core/telegram-config.d.ts +79 -0
  257. package/dist/src/core/telegram-config.js +108 -0
  258. package/dist/src/core/telegram-config.js.map +1 -1
  259. package/dist/src/core/values.d.ts +18 -8
  260. package/dist/src/core/values.js +36 -1
  261. package/dist/src/core/values.js.map +1 -1
  262. package/dist/src/core/verify.d.ts +23 -1
  263. package/dist/src/core/verify.js +20 -2
  264. package/dist/src/core/verify.js.map +1 -1
  265. package/dist/src/daemon/advance.d.ts +10 -0
  266. package/dist/src/daemon/advance.js +25 -4
  267. package/dist/src/daemon/advance.js.map +1 -1
  268. package/dist/src/daemon/audit.d.ts +80 -5
  269. package/dist/src/daemon/audit.js +126 -6
  270. package/dist/src/daemon/audit.js.map +1 -1
  271. package/dist/src/daemon/daemon.d.ts +174 -2
  272. package/dist/src/daemon/daemon.js +307 -7
  273. package/dist/src/daemon/daemon.js.map +1 -1
  274. package/dist/src/daemon/git-evidence.d.ts +2 -2
  275. package/dist/src/daemon/git-evidence.js +1 -1
  276. package/dist/src/daemon/projection.d.ts +13 -2
  277. package/dist/src/daemon/projection.js +39 -6
  278. package/dist/src/daemon/projection.js.map +1 -1
  279. package/dist/src/mcp/server.d.ts +39 -3
  280. package/dist/src/mcp/server.js +30 -3
  281. package/dist/src/mcp/server.js.map +1 -1
  282. package/dist/src/muse/server.d.ts +16 -0
  283. package/dist/src/muse/server.js +238 -0
  284. package/dist/src/muse/server.js.map +1 -0
  285. package/dist/src/serve/archive.d.ts +158 -0
  286. package/dist/src/serve/archive.js +354 -0
  287. package/dist/src/serve/archive.js.map +1 -0
  288. package/dist/src/serve/arguments.d.ts +93 -0
  289. package/dist/src/serve/arguments.js +228 -0
  290. package/dist/src/serve/arguments.js.map +1 -0
  291. package/dist/src/serve/credentials.d.ts +91 -0
  292. package/dist/src/serve/credentials.js +143 -0
  293. package/dist/src/serve/credentials.js.map +1 -0
  294. package/dist/src/serve/follow.d.ts +86 -0
  295. package/dist/src/serve/follow.js +101 -0
  296. package/dist/src/serve/follow.js.map +1 -0
  297. package/dist/src/serve/hook-thread.d.ts +134 -0
  298. package/dist/src/serve/hook-thread.js +333 -0
  299. package/dist/src/serve/hook-thread.js.map +1 -0
  300. package/dist/src/serve/hook-worker.d.ts +134 -0
  301. package/dist/src/serve/hook-worker.js +173 -0
  302. package/dist/src/serve/hook-worker.js.map +1 -0
  303. package/dist/src/serve/server.d.ts +412 -0
  304. package/dist/src/serve/server.js +1251 -0
  305. package/dist/src/serve/server.js.map +1 -0
  306. package/docs/README-extended.md +766 -0
  307. package/docs/cli-reference.md +2563 -73
  308. package/docs/codex-enforced-session.md +75 -2
  309. package/docs/codex-workspace-broker.md +118 -0
  310. package/package.json +6 -2
  311. package/schema/event.schema.json +595 -9
  312. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  313. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  314. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  315. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  316. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  317. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  318. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  319. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  320. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  321. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  322. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  323. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  324. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  325. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  326. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  327. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  328. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  329. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  330. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  331. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  332. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  333. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  334. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  335. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  336. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  337. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  338. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  339. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  340. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  341. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  342. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  343. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  344. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  345. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  346. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  347. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  348. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  349. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  350. package/schema/fixtures/policy/valid/canonical.json +1 -1
  351. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  352. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  353. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  354. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  355. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  356. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  357. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  358. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  359. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  360. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  361. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  362. package/schema/fixtures/values/invalid/version-float.json +1 -0
  363. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  364. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  365. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  366. package/schema/fixtures/values/valid/full.json +5 -7
  367. package/schema/fixtures/values/valid/minimal.json +1 -1
  368. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  369. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  370. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  371. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  372. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  373. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  374. package/schema/fixtures/values-md/valid/absent.md +1 -1
  375. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  376. package/schema/policy.schema.json +100 -4
  377. package/schema/values.schema.json +7 -11
  378. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -65,11 +65,20 @@ tampering.
65
65
  ```
66
66
  clean {"status":"clean","records":3,"head":{"seq":3,"hash":"<64 hex>"}}
67
67
  torn-tail {"status":"torn-tail","records":3,"head":null,
68
- "intactThroughSeq":3,"message":"..."}
68
+ "intactThroughSeq":3,"tear":"nul-filled","tornBytes":456,
69
+ "intactBytes":2817,"message":"..."}
69
70
  corrupt {"status":"corrupt","records":null,"head":null,
70
71
  "firstBadSeq":2,"reason":"hash-mismatch","message":"..."}
71
72
  ```
72
73
 
74
+ On a torn tail, `tear` says which crash left it (APRV-440). `nul-filled` means
75
+ every byte of the unterminated tail is NUL: the crash-before-writeback
76
+ signature, where the file grew but its data never reached the disk, so nothing
77
+ was tampered and no record was half-written. `partial-line` is a writer that
78
+ died mid-line. `intactBytes` is where the verified records end, which is the
79
+ length to truncate the file to; `tornBytes` is what that removes. Nothing is
80
+ truncated by this verb.
81
+
73
82
  `head` is null for an empty log. `reason` is one of `malformed-line`,
74
83
  `schema-invalid`, `bad-alg`, `hash-mismatch`, `prev-mismatch`, `seq-gap`,
75
84
  `seq-duplicate`, `not-genesis`, `head-mismatch`. `anomalies` is ADDITIVE and
@@ -390,6 +399,13 @@ did left conflict markers inside the log mid-ceremony.
390
399
  So the ritual became deterministic code, on the `policy amend` precedent: when a
391
400
  hand-ritual proves dangerous, it becomes a verb the gate can read.
392
401
 
402
+ **You rarely type it any more (APRV-346).** `approval up`'s preflight calls this
403
+ function itself whenever the working log is a byte-for-byte extension of the
404
+ committed one, which is the state every records advance leaves behind. What is
405
+ left for a hand-run `approval log sync` is the fork: two chains that share a
406
+ prefix and then carry different records at the same `seq`. See
407
+ [up](#up) for what the preflight checks before it delegates.
408
+
393
409
  Everything runs inside ONE hold of the append lockfile. The lock is normally
394
410
  taken per append; here it spans the whole operation, because an append landing
395
411
  between the snapshot and the restore is exactly the interleaving that forks a
@@ -485,6 +501,17 @@ range they cover, and pushed to a short-lived records branch that exists for
485
501
  exactly that commit. Main is protected here, so the commit reaches it through a
486
502
  pull request; `--pr` opens that pull request through the ordinary `gh` path.
487
503
 
504
+ This verb carries the class `log.advance` for everyone who runs it, in a
505
+ session, in an orchestrator, or at a human terminal. The daemon's cadence
506
+ advance is the one that may carry `log.advance.daemon` instead, and only from
507
+ inside the daemon process: see the `--advance` paragraph under `daemon run`
508
+ (APRV-382).
509
+
510
+ An advance publishes the WHOLE log, so while its branch is open on origin it is
511
+ the log's publisher and `policy amend` stands aside: an amendment opened in that
512
+ window carries only the policy bytes and cannot conflict with the records. See
513
+ "Who publishes the log" under `policy amend` (APRV-420).
514
+
488
515
  `--co-author "Name <email>"` adds one validated `Co-authored-by` trailer to the
489
516
  generated records commit and to the pull request body. When the day's pull
490
517
  request already exists, the verb preserves its body and adds the trailer once.
@@ -656,9 +683,18 @@ channel; policy-authorized execution is never represented as a human grant.
656
683
  "autonomy":"autonomous","winner":true,
657
684
  "tieBreak":"specificity"|"strictest-autonomy"|
658
685
  "lexicographic"|"tied-specificity"}],
686
+ "agentRequest":{"allowed":false,"explicit":true,"pattern":null},
659
687
  "decisionPath":["...","..."]}
660
688
  ```
661
689
 
690
+ `agentRequest` (APRV-445) answers whether `approval propose` would admit this
691
+ class: `explicit` is whether the class is an exact key of `classes`, `pattern`
692
+ is the key whose `agent_may_request` decided (the exact key, or the most
693
+ specific declared `<prefix>.*` family that states one), and `allowed` is both.
694
+ It is read without the resolution above, because a wildcard match never makes a
695
+ class proposable. A `decisionPath` line appears only when some key states the
696
+ flag for this class.
697
+
662
698
  `specificity` is [literalSegments, wildcardSegments, totalSegments] (SPEC §5.2).
663
699
  `overridden.pattern` is null when the floor overrode `defaults.autonomy` rather
664
700
  than a rule.
@@ -712,7 +748,77 @@ refusal {"ok":false,"error":{"code":"...","message":"..."}} on stderr
712
748
 
713
749
  `path` is the file that was hashed; the logged payload carries its basename only,
714
750
  so an exported log leaks no home directory. The event's payload is
715
- `{"policy_path":"APPROVAL.md","sha256":"<64 hex>"}`.
751
+ `{"policy_path":"APPROVAL.md","sha256":"<64 hex>","payload_hash":"<64 hex>"}`.
752
+
753
+ **`payload_hash`: the attested bytes, recoverable (APRV-356).** The verb also
754
+ writes the attested text to the payload store beside the log and binds that
755
+ file's hash on the record, so the policy IN FORCE can be produced rather than
756
+ only named. A digest cannot produce the file it names, and the privileged-gesture
757
+ rule (`policy.core` gestures from a channel, SPEC.md §10.3) has to be decided
758
+ against the policy in force rather than against the one being proposed. A reader
759
+ re-hashes the recovered text against `sha256` from the verified log, so the store
760
+ is checked rather than trusted, and a store that cannot be written REFUSES the
761
+ attestation: nothing is appended, because a record claiming a binding whose bytes
762
+ are absent is a worse artifact than no record. Records written before this
763
+ existed still validate and verify; a reader treats the absent field as the
764
+ pre-amendment state, where the in-force bytes are unrecoverable and the
765
+ fail-closed fallback applies.
766
+
767
+ **No terminal is needed.** The identity is declared, so the verb runs the same
768
+ from a provisioning script with stdin at `/dev/null` as from a shell. The
769
+ `attest-requires-terminal` refusal belongs to the channel path (a tap cannot
770
+ attest a policy that maps no sender for that channel), never to this verb.
771
+
772
+ ### `--bootstrap`: a store's first policy, safe to re-run (APRV-449)
773
+
774
+ The plain verb is an unconditional assertion: it reads no log and appends a
775
+ `policy.updated` every time it runs. That is right for a human at a terminal and
776
+ wrong for a provisioning step that runs again on every update and recreate of a
777
+ hosted tenant, where a re-run would add an attestation per redeploy and, once
778
+ the tenant has attested or amended their own policy, would put the operator back
779
+ on record as its attester. `--bootstrap` reads the VERIFIED log first:
780
+
781
+ ```
782
+ approval policy attest --bootstrap --as human:<operator> [--json]
783
+ ```
784
+
785
+ | the log carries | answer | exit |
786
+ |---|---|---|
787
+ | no attestation | attests, exactly as the plain verb | 0 |
788
+ | an attestation of these exact bytes | `policy-already-attested`, nothing appended | 1 |
789
+ | an attestation of other bytes | `policy-amendment-required`, nothing appended | 1 |
790
+
791
+ Both refusals add `seq` and `attested_by` (the attestation in force and who made
792
+ it) to the error object:
793
+
794
+ ```
795
+ refusal {"ok":false,"error":{"code":"policy-already-attested","message":"...",
796
+ "seq":1,"attested_by":"human:carter"}} on stderr
797
+ ```
798
+
799
+ `policy-already-attested` is the expected answer on a re-run, so a provisioning
800
+ step branches on `error.code` and treats that one code as done.
801
+ `policy-amendment-required` means the bytes on disk moved after the store was
802
+ attested. The operator does not attest that change: a change to an attested
803
+ policy is an amendment, and an amendment is the approver's act through a channel
804
+ (SPEC.md §10.3). The append is compare-and-append against the head the decision
805
+ was read from, and binds the digest it checked, so an attestation landing in
806
+ between, or a file rewritten in between, refuses (`head-moved`, or `io` naming
807
+ the changed bytes) rather than stacking an operator attestation on top. A torn
808
+ log is exit 3 and a log that does not verify is `log-corrupt` at exit 1.
809
+
810
+ With `--bootstrap` the log is the store's: it resolves under `--dir`
811
+ (`<dir>/.approval/log/events.jsonl`) unless `--log` names one, so `approval
812
+ policy attest --bootstrap --dir "$STORE"` attests the store's own log from any
813
+ working directory. The plain verb keeps its working-directory default. A
814
+ refused append can leave one unbound copy of the policy text in the payload
815
+ store, because the text is stored before the append (APRV-356). It is not
816
+ removed: the record that moved the head may be another attestation of the same
817
+ bytes, which binds that very file. An unbound file is inert, and `approval
818
+ status` counts it under `payload_store.orphans`.
819
+
820
+ `--bootstrap` beside `--organ` or `--path` is a usage error. The whole
821
+ provisioning sequence is [docs/hosted-provisioning.md](hosted-provisioning.md).
716
822
 
717
823
  ### `--organ <path>`: the gate's organs (APRV-272)
718
824
 
@@ -769,6 +875,92 @@ current bytes carry no attestation. It never moves doctor's exit code: an
769
875
  unattested organ breaks nothing on this machine, and the enforcement for one is
770
876
  the guard in CI.
771
877
 
878
+ ### `--path <path>`: signing off a protected file (APRV-338)
879
+
880
+ SPEC.md's amendment-provenance rule says text that reached a protected file
881
+ without a grant carries `(Amended APRV-n, pending sign-off.)` and holds no more
882
+ authority than a proposal until a human ratifies it. Nothing recorded the
883
+ ratification: no event, no verb, and no way for CI or doctor to tell a ratified
884
+ amendment from a pending one. This flag is that record.
885
+
886
+ ```
887
+ approval policy attest --path SPEC.md --as human:carter
888
+ ```
889
+
890
+ One path per call, repository-relative (an absolute path under `--dir` is
891
+ accepted and recorded relative). The runtime hashes the bytes on disk; there is
892
+ no flag for the digest. The record is a **`gate.path.signed_off`** event:
893
+
894
+ ```
895
+ {"event":"gate.path.signed_off","actor":"human:<id>",
896
+ "payload":{"path":"SPEC.md","sha256":"<64 hex>"}}
897
+ ```
898
+
899
+ **Which paths.** Exactly those whose edits classify `policy.edit` or a
900
+ `policy.edit.*` sub-class: the built-in prose set (`CLAUDE.md`, `AGENTS.md`,
901
+ `.npmrc`, `.github/workflows/`) plus everything the live policy's
902
+ `protected_paths` widens to, including a path routed to a sub-class such as
903
+ `policy.edit.design`. The policy is loaded to answer that, and a policy that
904
+ does not load contributes nothing, which narrows what may be signed rather than
905
+ widening it.
906
+
907
+ Three refusals are specific to this flag, all exit 2:
908
+
909
+ ```
910
+ path-is-policy the policy file: use `approval policy attest` with no flag
911
+ path-is-core a gate organ (use --organ), the approval home, or the log
912
+ directory, which no verb ratifies
913
+ path-not-protected an ordinary file, or a path that is not repository-relative
914
+ ```
915
+
916
+ `--organ` and `--path` together are a usage error, as are `--policy` and
917
+ `--path`: each pair names two different claims and guessing which one was meant
918
+ is how the wrong record gets written. `--json` adds `signed_path`:
919
+
920
+ ```
921
+ success {"ok":true,"seq":7,"sha256":"<64 hex>","path":"/abs/SPEC.md",
922
+ "signed_path":"SPEC.md"}
923
+ ```
924
+
925
+ **Weaker than a grant, and read last.** A grant binds the exact hunk a human saw
926
+ in a prompt; a sign-off stands for the whole file. So the protected-path guard
927
+ asks about a sign-off only after its search for a grant covering the change has
928
+ come up empty, and the finding it prints says so in words. A change that a grant
929
+ does cover still passes on the grant and still names it. Signing off is for the
930
+ case the pending-sign-off suffix was invented for: text a human has read at that
931
+ commit and agrees with, for which no grant was ever taken.
932
+
933
+ **Signing off bytes that are on a branch.** The digest the guard checks is the
934
+ file's blob at the commit under review, and that is usually a pull request's
935
+ head rather than anything on disk in the primary checkout. `--dir` and `--log`
936
+ are separate flags for exactly this: `--dir` is the checkout whose bytes are
937
+ hashed and which the recorded path is relative to, `--log` is the log the record
938
+ is appended to. So a human ratifying an open pull request reads the change,
939
+ puts a checkout at that commit somewhere (a `git worktree`, or the branch
940
+ checked out in a scratch clone), and runs:
941
+
942
+ ```
943
+ approval policy attest --path SPEC.md \
944
+ --dir /path/to/checkout-at-that-commit \
945
+ --log /path/to/primary/.approval/log/events.jsonl \
946
+ --as human:<id>
947
+ ```
948
+
949
+ The record lands in the primary checkout's log, where a log advance carries it
950
+ to a records branch, and the guard then finds it for that path at that digest.
951
+ The verb never writes a log inside the worktree it hashed. If the digest it
952
+ prints is not the one the guard's failure named, the checkout is at the wrong
953
+ commit or the file has been edited since: the two must agree exactly, and a
954
+ mismatch is a sign-off on bytes nobody reviewed.
955
+
956
+ The verb classifies `policy.core` when `--path` is present (without it the verb
957
+ attests the gate's own configuration and stays pass-through), so under this
958
+ repository's policy the harness hook denies it to an agent with
959
+ `hook-class-human-only` before the verb's own `actor-not-human` refusal is
960
+ reached. `approval doctor`'s `pending-sign-off` row lists the protected files
961
+ that still carry the marker with no record over their current bytes; like
962
+ `gate-organs` it never moves doctor's exit code.
963
+
772
964
  ## policy amend
773
965
 
774
966
  **Progress, on stderr.** The verb re-verifies the whole chain and recovers the
@@ -832,6 +1024,16 @@ resolution still prints in the semantic diff below for the human who attests it.
832
1024
  Until APRV-296 every declared class had to be pinned, in both directions, and a
833
1025
  one-line TTL amendment on 2026-09-07 took three runs to land because of it.
834
1026
 
1027
+ The one check every declared class still faces is REACHABILITY: a class nothing
1028
+ can emit is a line that will never fire, and the ceremony refuses it rather than
1029
+ leaving an operator believing in it for a year. Three ways to be reachable: the
1030
+ command classifier's fixed table, a `protected_paths` entry routing a path
1031
+ family to a `policy.edit` sub-class, and `RUNTIME_CLASSES` in
1032
+ `src/core/command-class.ts`, which names the classes a runtime cycle asks the
1033
+ gate for directly and no command spells (`log.advance.daemon` is the first,
1034
+ APRV-382). A new class of that third kind needs its line there in the SAME build
1035
+ the ceremony runs, or the amendment refuses `policy-suite-failed`.
1036
+
835
1037
  **And then the whole dogfood suite, still before the attestation.** The pin check
836
1038
  is a subset of `tests/dogfood.test.ts`, and the seq 23351 ceremony passed the pins
837
1039
  and went red on CI over a dogfood test about the values block. So `--commit` also
@@ -912,15 +1114,125 @@ base carries it, and a pins edit somebody else landed is not reverted by this
912
1114
  ceremony. The pin deltas print in the semantic diff beside the class deltas, ride
913
1115
  in the commit subject, and appear in `--json` as `pins`.
914
1116
 
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
1117
+ **Who publishes the log (APRV-420).** The log is in that file set only when this
1118
+ amendment is the thing publishing it. On 2026-09-21 it always was, and PR 530
1119
+ paid for it: two records advances merged first carrying the same records (the
1120
+ attestation at seq 65736 and its payload reached main through PR 531), and git
1121
+ does not care that one side's appended lines are a prefix of the other's, so the
1122
+ amendment went DIRTY, lost its arm, and the repair was a hand merge of
1123
+ `events.jsonl` in a throwaway worktree.
1124
+
1125
+ The cause was two publishers carrying the same bytes, so the ceremony now leaves
1126
+ one of them holding it. It asks two questions of the base it is about to commit
1127
+ on, in this order:
1128
+
1129
+ 1. does the base's log already carry every record this attestation added? Then
1130
+ the log is published and the amendment has nothing to add to it.
1131
+ 2. is a records advance live, meaning origin carries a `records-log-*` branch?
1132
+ That advance publishes the log, so the amendment has nothing to add to it
1133
+ either. Whether the branch already carries THIS attestation is checked
1134
+ rather than assumed: the ceremony fetches the branch and compares chains.
1135
+ An advance pushed before the human signed does not carry it, which is the
1136
+ ordinary order; the ceremony then says so, the next advance publishes the
1137
+ record, and the pull request's protected-path guard holds the policy
1138
+ change until a records branch or main carries it.
1139
+
1140
+ Either way the commit is the policy bytes, the attested text and the pins, and a
1141
+ commit that does not touch `events.jsonl` cannot conflict on `events.jsonl`,
1142
+ whichever side lands first. Otherwise the amendment is the only publisher and
1143
+ carries the log, exactly as it always has. The ceremony prints which of the three
1144
+ happened, so the pull request's file list is never a surprise.
1145
+
1146
+ The question is asked with `git ls-remote`, not `gh`, so it answers the same on a
1147
+ box with no GitHub CLI and no token. And it fails toward CARRYING: anything the
1148
+ check cannot establish leaves the amendment holding the log. A dirty pull request
1149
+ is a nuisance; an attestation that reaches `main` in nobody's commit is a policy
1150
+ in force that the committed log does not record.
1151
+
1152
+ **A re-run repairs the pull request it finds (APRV-420).** A second run used to
1153
+ stop at `nothing to amend`, which is true and useless: the live policy does match
1154
+ its attestation, and the pull request carrying that attestation is open,
1155
+ unmergeable and unarmed. So when `--commit` or `--pr` is asked for and origin
1156
+ already carries the amendment branch, the re-run fetches the default branch and
1157
+ computes what it still LACKS — the policy bytes, the attested text, the pins, and
1158
+ the log under the rule above — and then:
1159
+
1160
+ - nothing lacking: the amendment landed in full. It says so and prints `gh pr
1161
+ close <branch> --delete-branch`.
1162
+ - something lacking: it rebuilds that commit on the CURRENT default branch and
1163
+ force-updates the branch, then re-arms `gh pr merge <branch> --merge --auto`.
1164
+ The trunk's log is a superset of the amendment's by construction, so rebuilding
1165
+ on it is the re-merge without a merge.
1166
+
1167
+ The rebuild is `commitOnBase`'s scratch index like every other commit this verb
1168
+ makes, so nothing is checked out and no live appender has the log moved
1169
+ underneath it. The force-push is the one in this codebase, and it is bounded: the
1170
+ remote branch must be a single commit on top of a commit the base already
1171
+ contains, which is the shape this ceremony creates and nothing else. A branch
1172
+ carrying anything more is reported with `git log --oneline <base>..<tip>` and
1173
+ left alone. `--dry-run` and `--no-publish` compute the repair, print the
1174
+ commands and run none of it. `--json` carries a `repair` object (`branch`,
1175
+ `state` of `landed`/`repaired`/`owed`/`failed`, `base`, `carried`, `commit`,
1176
+ `pushed`, `autoMerge`, `commands`, `message`), additively and always present:
1177
+ `null` on every run that had an amendment to make and on every no-op that found
1178
+ no branch standing. Only `failed` is a nonzero exit.
1179
+
1180
+ It refuses outside a git repository (`commit-preconditions`), and refuses
1181
+ `staged-unrelated` when the index holds staged changes to anything beyond those
1182
+ three: a commit that swept in an unrelated staged edit would make "this commit is
1183
+ the amendment" false. It refuses `dirty-tree` when one of those three is staged
1184
+ in one state and modified again in the working tree, because the commit is
1185
+ assembled from the WORKING TREE and would otherwise carry bytes the operator's
1186
+ `git diff --cached` never showed. An unrelated *unstaged* path is deliberately
1187
+ not a refusal: the scratch index lays exactly the ceremony's paths over the
1188
+ remote's tree, so nothing else can reach the commit, and refusing over one would
1189
+ stop the ceremony in the checkout it is written for, where the daemon's envelope
1190
+ write-backs leave task files modified as a matter of course. On the branch flow
918
1191
  it also refuses when there is no `origin` remote, and when a `--branch` name
919
1192
  already exists. Every one of those refusals happens BEFORE the attestation, so a
920
1193
  refused `--commit` never leaves an attested policy without its commit. The same
921
1194
  holds for the fetch, the two base checks, the policy suite and the dogfood suite
922
1195
  above.
923
1196
 
1197
+ **`--pr`: the ceremony finishes its own job (APRV-341).** `--pr` is `--commit`
1198
+ plus "and publish it": it forces the BRANCH flow whatever the protection probe
1199
+ answered, so the amendment is committed on `origin/<default branch>` in a scratch
1200
+ index, pushed to `policy-amend-<seq>`, carried by a pull request, and armed with
1201
+ `gh pr merge <branch> --merge --auto`. The merge queue picks the strategy from
1202
+ there. `--pr --direct` and `--pr --no-publish` are usage errors: each pair asks
1203
+ for opposite ceremonies.
1204
+
1205
+ The pull request is OPENED or UPDATED. `gh pr list --head <branch> --state open`
1206
+ is asked first, and when one is already standing its title and body are edited
1207
+ rather than a second being opened, so a re-run of a ceremony that stopped
1208
+ half-way finishes rather than failing at `gh pr create`. A `gh` that cannot
1209
+ answer the question falls through to `create`, which is the path that was there
1210
+ before.
1211
+
1212
+ **The verb never switches branches.** On 2026-09-16 an agent-written runbook for
1213
+ the primary checkout ended with `git checkout main` after the amend commit, which
1214
+ rewound `APPROVAL.md` and `events.jsonl` under a live appender; the hook then
1215
+ appended 204 records on the stale chain and the log forked. `--pr` exists so that
1216
+ runbook does not: the commit is assembled with `git read-tree` into a scratch
1217
+ index and pushed by sha, the checkout ends the verb on the branch it started on,
1218
+ with the same HEAD, the same index and the same working tree, and the only file
1219
+ that moved is the log, which gained the attestation. A test compares all four
1220
+ before and after.
1221
+
1222
+ Without `--pr` (and on a box with no `gh`) the printed runbook does the same
1223
+ thing by hand, and it does not switch branches either (APRV-360): `approval log
1224
+ sync`, `git add`, `git commit`, `git push origin HEAD:refs/heads/policy-amend-<seq>`,
1225
+ `gh pr create --head`, `gh pr merge --auto --merge`. A fixture test pins the six
1226
+ commands and asserts that no printed line contains a `git checkout`, because a
1227
+ fallback nobody checks is where the branch switch came back.
1228
+
1229
+ The form printed before APRV-360 opened with `git checkout -b policy-amend-<seq>
1230
+ origin/main`. On 2026-09-18 the primary's main was fourteen commits behind, the
1231
+ switch refused rather than overwrite `QUEUE.md`, the working log and six
1232
+ payloads, and the ceremony stalled with an edited, attested, unpublished policy.
1233
+ `approval log sync` is the first line now for that reason: it brings the checkout
1234
+ current, and it refuses `log-diverged` rather than fast-forwarding over a fork.
1235
+
924
1236
  `--commit` also pushes, on both flows. When there is no `origin` to push to, the
925
1237
  direct flow reports the push as still to run rather than listing it among the
926
1238
  commands it ran. `--no-publish` stops the ceremony at the commit: nothing is
@@ -962,9 +1274,9 @@ beneath it.
962
1274
  sha256 8acbd01cda98
963
1275
 
964
1276
  Committed
965
- ✓ committed the policy and the log together:
1277
+ ✓ committed the policy, the log and the attested policy text together:
966
1278
 
967
- git add APPROVAL.md .approval/log/events.jsonl
1279
+ git add APPROVAL.md .approval/log/events.jsonl .approval/payloads/8acbd01cda98….json
968
1280
  git commit -m "Policy: amend APPROVAL.md: 1 class resolution(s) (attested seq 2)"
969
1281
 
970
1282
  Publishing
@@ -1057,10 +1369,16 @@ is the human rendering only.
1057
1369
  prints the SEMANTIC diff, computed by the real engine on both versions;
1058
1370
  4. runs the load advisory;
1059
1371
  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);
1372
+ 6. attests: one `policy.updated` event, identical to `approval policy attest`,
1373
+ which since APRV-356 also stores the attested bytes in the payload store and
1374
+ binds their hash on the record;
1375
+ 7. prints, or with `--commit` runs, the git ceremony — `git add <policy> <log>
1376
+ <attested bytes>` (plus the pins when they moved), a `git commit` citing the
1377
+ attestation seq, and the push (and, on the branch flow, the branch and the
1378
+ pull request). The store file rides in the same commit because a committed
1379
+ log carrying a binding whose bytes were never committed leaves the policy in
1380
+ force unrecoverable for everyone reading that copy, the CI protected-path
1381
+ guard included;
1064
1382
  8. publishes, unless `--no-publish`: a push the remote refuses is answered by
1065
1383
  the branch, push, pull request and auto-merge above, each reported as it
1066
1384
  lands, and a step that fails drops to the runbook from there.
@@ -1117,7 +1435,7 @@ attestation may still proceed.
1117
1435
  "publishing":null|{"attempted":true,"complete":true,
1118
1436
  "via":"direct"|"branch"|"recovery"|"none",
1119
1437
  "branch":null|"policy-amend-2","pushed":true,
1120
- "prUrl":null|"https://...",
1438
+ "prUrl":null|"https://...","prUpdated":false,
1121
1439
  "autoMerge":"armed"|"refused"|"not-attempted",
1122
1440
  "steps":[{"command":"git push origin main","ok":false}],
1123
1441
  "stoppedAt":null|"git push -u origin policy-amend-2",
@@ -1149,10 +1467,19 @@ the message.
1149
1467
  - `io` — the policy file or the log could not be read or written.
1150
1468
  - `load-failed` — `--require-load` and the policy does not load. Nothing was
1151
1469
  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.
1470
+ - `commit-preconditions` — `--commit` outside a git repository, with the policy
1471
+ and the log in different repositories, or (branch flow) with no origin remote
1472
+ or a `--branch` name already taken. Checked before the attestation; nothing
1473
+ was appended.
1474
+ - `staged-unrelated` — the index carries staged changes beyond the policy, the
1475
+ log and the pins (APRV-341). Its own code because its repair is its own: one
1476
+ `git restore --staged <path>`. Checked before the attestation; nothing was
1477
+ appended.
1478
+ - `dirty-tree` — one of those three is staged in one state and modified again in
1479
+ the working tree (APRV-341). The commit is assembled from the working tree, so
1480
+ it would carry bytes `git diff --cached` does not show. An unrelated *unstaged*
1481
+ path is not this refusal and never was. Checked before the attestation;
1482
+ nothing was appended.
1156
1483
  - `fetch-failed` / `base-policy-diverged` / `base-log-diverged` — the remote the
1157
1484
  amendment would be based on could not be fetched, carries a policy this edit
1158
1485
  was not written against, or carries a log this working log does not contain.
@@ -1181,6 +1508,114 @@ the message.
1181
1508
  - `log-unreadable` / `log-torn-tail` / `log-corrupt` — nothing is amended from a
1182
1509
  log that does not verify.
1183
1510
 
1511
+ ## policy apply
1512
+
1513
+ **The hand-paste this replaces (APRV-343).** Agents may not write `APPROVAL.md`:
1514
+ it is `policy.core`, and this project's policy holds that class human-only. So a
1515
+ policy change an agent proposes travels as a document under `docs/proposals/`
1516
+ that quotes each current line byte for byte beside its replacement, and the
1517
+ human pastes. Two things go wrong with a paste, and both have:
1518
+
1519
+ - **a whole-file copy reverts what it did not know about.** The prepared file was
1520
+ written against the policy as it stood when the proposal was drafted, so
1521
+ anything landed in between is silently undone. That is the failure
1522
+ `base-policy-diverged` catches for the commit, one step too late to help;
1523
+ - **a paste carries its wrapper.** One paste of a proposal page took the page's
1524
+ own fence with it, which hid a block from the loader (APRV-273).
1525
+
1526
+ `approval policy apply <proposal.md>` answers both by construction. It writes no
1527
+ byte that is not anchored to a byte it proved present in the live file, and it
1528
+ reads fences by their backtick run, so a four-backtick wrapper around a
1529
+ three-backtick block is the wrapper it is rather than part of the content.
1530
+
1531
+ ### The proposal format
1532
+
1533
+ A proposal is ordinary markdown. Anywhere in it, a fenced block **with a
1534
+ declared language** whose immediately preceding non-blank line is a label is a
1535
+ member of a pair:
1536
+
1537
+ | label | means |
1538
+ |---|---|
1539
+ | `Current:` | the bytes as they stand in the live policy |
1540
+ | `Replace with:` | what they become |
1541
+ | `Supersedes:` | optional: an earlier section's RESULT, to match instead |
1542
+
1543
+ A fence with no info string is skipped, which is APRV-273's hazard turned into a
1544
+ rule. A block whose label names no role is skipped too, so a proposal page can
1545
+ carry a `bash` block of commands to run afterwards without the applier mistaking
1546
+ it for policy text. Pairs apply in document order.
1547
+
1548
+ **Supersession is declared, never inferred from position.** A later section that
1549
+ rewrites a line an earlier section already rewrote quotes the earlier section's
1550
+ *result* under a `Supersedes:` label; the applier looks for that text when the
1551
+ section's own `Current` block is no longer in the file, which is exactly the
1552
+ state the earlier section left behind. Position could not carry this: two
1553
+ sections that touch one line are not in general in the order the file needs, and
1554
+ a rule inferred from order is a rule nobody can read off the page. A pair whose
1555
+ `Current` **and** `Supersedes` blocks both occur in the file is
1556
+ `proposal-ambiguous`: two spellings of one line is a question about which the
1557
+ file means, and no verb here will pick.
1558
+
1559
+ **Whole-file replacement is not accepted**, and that is the decision rather than
1560
+ an omission. The verb's whole value is that every byte it writes is anchored to
1561
+ a byte it proved present, which is what makes a stale proposal a refusal instead
1562
+ of a silent revert. A whole-file blob has no anchor. `approval policy amend`
1563
+ over a hand-edited file is already the supported way to replace the file
1564
+ deliberately.
1565
+
1566
+ **The values block is treated exactly as the policy block is**, by knowing
1567
+ nothing about either. The applier is a byte-level replacement over the whole
1568
+ file: it does not parse the policy, does not locate blocks, and does not care
1569
+ which fence a pair lands in. The values block is inert (SPEC §11.1 invariant
1570
+ 10), so applying one changes no verdict, and the attestation the amendment
1571
+ appends covers the whole file's bytes either way (SPEC §5.2, §5.3).
1572
+
1573
+ `docs/proposals/README.md` is the contract as a page for proposal authors, and
1574
+ `docs/proposals/approval-md-2026-09.md` is a worked example of every part of it.
1575
+
1576
+ ### What it does, in order
1577
+
1578
+ 1. Refuses an agent identity (`apply-agent-actor`) before reading anything.
1579
+ 2. Parses the proposal into ordered pairs.
1580
+ 3. Resolves every pair against an **in-memory** copy of the policy. A proposal
1581
+ whose third pair is stale writes nothing at all, so "a stale proposal cannot
1582
+ half-apply" is a property of the code rather than of the order somebody wrote
1583
+ the sections in.
1584
+ 4. Prints the replacements, each as its matched block and its replacement.
1585
+ 5. Asks for confirmation (`--yes` skips it, `--dry-run` stops here).
1586
+ 6. Writes the policy, then runs `approval policy amend --pr` in this process —
1587
+ which asks its OWN question about the semantic diff, because "are these the
1588
+ bytes" and "is this the policy" are different questions.
1589
+
1590
+ **It publishes by default (APRV-360).** The amendment runs with `--pr`, so the
1591
+ branch is created on the remote by refspec, the pull request is opened, the
1592
+ merge is armed, and the checkout ends where it started. Before this the amend
1593
+ ran with no flag: the policy was written and attested, and the operator was
1594
+ handed a procedure that began with a branch switch. On 2026-09-18 the primary
1595
+ refused that switch and the ceremony stalled with an edited, attested,
1596
+ unpublished policy, which is the one state in which every gated operation on the
1597
+ box refuses. `--no-publish` stops at the commit and is passed through to the
1598
+ amend; `--pr` is accepted and does nothing, because runbooks already say it, and
1599
+ `--pr` with `--no-publish` is a usage error.
1600
+
1601
+ `--no-amend` writes and stops, and says loudly that the policy is now edited and
1602
+ unattested. A run where every pair resolves and no byte moves is a success and a
1603
+ no-op: the proposal has already been applied.
1604
+
1605
+ **Refusal codes** (`error.code` with `--json`; frozen public API): `usage`,
1606
+ `io`, `apply-agent-actor`, `proposal-empty`, `proposal-malformed` (a `Current`
1607
+ with no `Replace with`, or the reverse), `proposal-stale` (a quoted current text
1608
+ is not in the file), `proposal-ambiguous` (it occurs more than once, or both it
1609
+ and its superseded text occur). Every one of them writes nothing.
1610
+
1611
+ Two outcomes are deliberately not in that union. Answering no at the
1612
+ confirmation is exit 0 with `aborted:` on stdout, exactly as `policy amend`
1613
+ answers it: nothing failed, and an error object at exit 0 would be a
1614
+ contradiction the caller has to resolve. An amendment that refuses has already
1615
+ printed its own code from its own frozen union, so this verb adds a sentence
1616
+ naming the state that leaves behind — the replacements written, the policy
1617
+ unattested — and returns the amendment's exit code unchanged.
1618
+
1184
1619
  ## register
1185
1620
 
1186
1621
  The task file is read only. Nothing is rewritten, so unknown frontmatter keys
@@ -1231,6 +1666,24 @@ hash must equal the declared `payload_hash` and it is filed in
1231
1666
  bytes from. Supply it here once and no channel needs `--payload-dir` or
1232
1667
  `--payloads` at all.
1233
1668
 
1669
+ For an action `approval run` will execute, produce those bytes with `approval
1670
+ payload run -- <cmd…>` rather than by hand. Since APRV-401 the payload carries
1671
+ the size and digest of the script the argv names, and a hand-written
1672
+ `{"argv":…,"cwd":…}` for a command that names a script is a declaration the
1673
+ execution will refuse `payload-mismatch`, correctly, because it describes bytes
1674
+ nobody bound:
1675
+
1676
+ ```
1677
+ approval payload run --hash -- bash scripts/install.sh # the declaration
1678
+ approval payload run -- bash scripts/install.sh \
1679
+ | approval request <task> --action <key> --as agent:<id> --payload -
1680
+ ```
1681
+
1682
+ Material supplied here is checked against the declared hash and never against
1683
+ the filesystem: this verb does not know where the run will happen, and the
1684
+ guarantee is that `approval run` recomputes the same value from the tree it is
1685
+ about to spawn into and refuses on any difference.
1686
+
1234
1687
  When the policy permits an unattended action, supplied material is still checked
1235
1688
  against the registered action's task, class and payload hash and retained before
1236
1689
  `proceed:true` is returned. This creates no approval event and does not require
@@ -1252,6 +1705,129 @@ refusal {"ok":false,"error":{"code":"...","message":"...",
1252
1705
  The refusal goes to stderr, and `seq` is the `budget.exceeded` record that WAS
1253
1706
  appended.
1254
1707
 
1708
+ ## propose
1709
+
1710
+ `approval propose --class <c> --key <k> --summary <s> --payload-json <json>`
1711
+ registers one action and requests it in one call (APRV-445, amended SPEC.md
1712
+ §10.1). It exists for a requester that holds its payload in memory and has no
1713
+ file on the gate's machine: an agent in a sandbox reaching `approval serve`
1714
+ with the agent credential, which refuses every path flag. `--payload-json` is
1715
+ the payload as the flag's VALUE, a JSON object of at most 262144 bytes of UTF-8
1716
+ as sent; an array, string, number or `null` is a usage error, and a larger
1717
+ payload is refused `payload-too-large` at exit 2 before it is parsed.
1718
+
1719
+ **Why this verb may name its class.** `request` never does, so that an agent
1720
+ cannot call a `financial.spend` a `read.web`. Here the agent is also the
1721
+ registrant, so the bound is the operator's: the class must be an EXACT key of
1722
+ the policy's `classes` (a wildcard rule that happens to match does not count),
1723
+ and that key or a declared `<prefix>.*` family of it must set
1724
+ `agent_may_request: true` (SPEC.md §5.2). Anything else refuses
1725
+ `class-not-agent-requestable` and registers nothing. A `human-only` member
1726
+ under an open family refuses `class-human-only`, also before anything is
1727
+ registered. The check runs again inside the request's own read-check-append
1728
+ cycle, so a re-attestation in between cannot slip a closed class through. What
1729
+ the agent picks AMONG the opened classes is still its own statement: an
1730
+ operator who opens a manual class and an autonomous one under the same family
1731
+ is trusting the agent to pick the right one, and the record names the class it
1732
+ picked.
1733
+
1734
+ ```yaml approval-policy
1735
+ classes:
1736
+ intent.publish.*: { autonomy: manual, agent_may_request: true }
1737
+ intent.publish.inferred.index: { autonomy: manual }
1738
+ intent.publish.stated.index: { autonomy: autonomous }
1739
+ ```
1740
+
1741
+ **The key** must begin with `<class>:` and an id of printable characters with
1742
+ no whitespace or control characters (refused
1743
+ `key-class-mismatch`, exit 2): `intent.publish.inferred.index:<intention id>`.
1744
+ It is recorded verbatim as `action_key`, and a consumer reads the class from the
1745
+ record rather than parsing it back out of the key. The key is at most 1024 bytes
1746
+ and `--summary` at most 4096; a lone UTF-16 surrogate in either or anywhere in
1747
+ the payload, and a number JSON parses to Infinity (`1e400`), are usage errors. An
1748
+ integer beyond 2^53 is hashed as the double it parses to, so send large ids as
1749
+ strings.
1750
+
1751
+ **Do not open a `supervised-live` class to agents.** The live draw would decide,
1752
+ per proposal, whether a human is asked at all, which the requester can neither
1753
+ predict nor explain to the person whose words it is proposing.
1754
+
1755
+ **The task id** is `propose:` and the first 32 hex digits of the SHA-256 over
1756
+ the RFC 8785 canonical JSON of `[actor, class, key]`. The actor is the tenant
1757
+ half: one `approval serve` process is one store and one fixed `agent:<id>`. Two
1758
+ actors proposing one key derive two tasks, and the second registration is
1759
+ refused `task-already-registered` because the key is already declared under the
1760
+ first.
1761
+
1762
+ **Records.** One `task.registered` (actor = the caller, envelope `origin.app:
1763
+ approval-propose`, one action: the class, the summary, `idempotency_key` = the
1764
+ key, `payload_hash`) and one `approval.requested` exactly as `request` writes it,
1765
+ with `action_key` = the key verbatim, `summary`, `payload_hash`, `display_hash`,
1766
+ `policy_sha256` and `execution: "harness"` (a grant mints no token; the
1767
+ requester proceeds on the grant and records it with `start`). The summary is in
1768
+ the log in cleartext, twice; the payload is never in the log, only in the
1769
+ payload store (`.approval/payloads/<hash>.json`, mode 0600 in a 0700 directory),
1770
+ which the tenant credential's `GET /export` includes and the agent credential
1771
+ can never read back. Set `payload_retention` so the daemon prunes bytes whose
1772
+ action has finished.
1773
+
1774
+ **Idempotency.** The same class, key and bytes append nothing and answer the
1775
+ request's standing with `idempotent: true` while it still means something:
1776
+ pending, granted and spendable, rejected, revoked, or executed (`state:
1777
+ "executed"`, after `start`). The same call ASKS AGAIN, under the same
1778
+ registration, when the request can no longer be answered usefully: withdrawn,
1779
+ expired, a grant whose request window lapsed (a grant has no window of its own,
1780
+ so a tap at hour 71 of 72 authorizes nothing at hour 73), or a grant or pending
1781
+ request pinned to a policy that has since been re-attested (a pending one is
1782
+ first withdrawn, reason `superseded`). Two identical calls racing to register
1783
+ are one proposal. The same key with different bytes refuses
1784
+ `duplicate-request` while a request is live and `payload-mismatch` otherwise: a
1785
+ key is bound to the bytes it was first proposed with. A plain `approval request`
1786
+ on a `propose:` task is refused `task-is-proposal`.
1787
+
1788
+ **Off the manual path.** A class that resolves `autonomous` or `supervised` is
1789
+ registered and answered at once with `decision: "autonomous"` (or
1790
+ `"supervised"`) and no approval record. Act, then record it with `start`.
1791
+
1792
+ **`--json`** (one object on stdout):
1793
+
1794
+ ```
1795
+ manual {"ok":true,"task":"propose:<32 hex>","action_key":"<k>","class":"<c>",
1796
+ "payload_hash":"<64 hex>","decision":"requested","state":"requested",
1797
+ "seq":7,"idempotent":false}
1798
+ retry {...,"state":"granted","seq":9,"idempotent":true}
1799
+ autonomous {...,"decision":"autonomous","state":null,"seq":null,"idempotent":false}
1800
+ ```
1801
+
1802
+ Poll a `requested` proposal with `approval wait <task> --timeout 0`.
1803
+
1804
+ ## start
1805
+
1806
+ `approval start <task> --action <key> --payload-json <json>` appends the one
1807
+ `execution.started` for an action its requester is about to carry out itself,
1808
+ outside any harness hook (APRV-445). Proposals only: any other task is refused
1809
+ `task-not-proposal`. REQUESTER-ONLY: the caller must be the actor
1810
+ that registered the task (`not-requester`). The bytes presented must hash to the
1811
+ registered `payload_hash` (`payload-mismatch`), so what is done is what was
1812
+ proposed.
1813
+
1814
+ It writes through the two existing harness spenders and nothing else. A key
1815
+ whose request is pending or granted and unspent spends its harness grant: refused `not-granted` while
1816
+ pending or after a rejection, `expired` after the window, `policy-drift` under
1817
+ a re-attested policy. A key with no such request (none, or one withdrawn,
1818
+ expired, rejected or revoked, which binds nothing) is recorded as authorized by
1819
+ the policy itself: refused `not-granted` for a manual class and `class-human-only`
1820
+ for a human-only one, and charged against the budgets. A second start of one key
1821
+ is `already-executed`. The record carries `execution: "harness"`, which is why no
1822
+ `execution.completed` ever follows it.
1823
+
1824
+ **`--json`**:
1825
+
1826
+ ```
1827
+ {"ok":true,"task":"propose:<32 hex>","action_key":"<k>","class":"<c>",
1828
+ "authorization":"grant"|"policy","seq":12}
1829
+ ```
1830
+
1255
1831
  ## grant
1256
1832
 
1257
1833
  Legal only on a request that is awaiting a decision. A second decision is
@@ -1426,8 +2002,8 @@ refusal {"ok":false,"error":{"code":"...","message":"...","state"?:"..."}}
1426
2002
 
1427
2003
  ## gate refusal codes
1428
2004
 
1429
- The vocabulary every gate verb (register, request, grant, reject, revoke,
1430
- withdraw, expire) returns in `error.code` with `--json`. Frozen public API: an agent branches on it
2005
+ The vocabulary every gate verb (register, request, propose, start, grant,
2006
+ reject, revoke, withdraw, expire) returns in `error.code` with `--json`. Frozen public API: an agent branches on it
1431
2007
  to decide whether to fix itself, stop retrying, or ask a human.
1432
2008
 
1433
2009
  - `policy-not-attested` — policy unattested or its bytes changed since
@@ -1491,7 +2067,8 @@ to decide whether to fix itself, stop retrying, or ask a human.
1491
2067
  it, or is withdrawing one it already withdrew. Distinct from `already-decided`:
1492
2068
  nobody answered, and nobody can now. Request the action again.
1493
2069
  - `not-requester` — a withdrawal was attempted by an actor other than the one
1494
- that opened the request. Only the party that asked may take the question back;
2070
+ that opened the request, or a `start` by an actor other than the one that
2071
+ registered the task. Only the party that asked may take the question back;
1495
2072
  anyone else who wants it gone rejects it.
1496
2073
  - `expired` — the TTL lapsed, judged from the request's own `ts`.
1497
2074
  - `not-expired` — expire was called before the TTL lapsed, or the policy declares
@@ -1529,6 +2106,20 @@ to decide whether to fix itself, stop retrying, or ask a human.
1529
2106
  at the verb (exit 2) rather than a member of this union.
1530
2107
  - `log-unreadable` (exit 4) / `log-torn-tail` (exit 3) / `log-corrupt` (exit 1):
1531
2108
  nothing is authorized from a log that does not verify.
2109
+ - `class-not-agent-requestable` — `propose` named a class the policy has not
2110
+ opened to agents: not an exact key of `classes`, or neither that key nor a
2111
+ declared `<prefix>.*` family sets `agent_may_request: true`. Nothing is
2112
+ registered or appended; the repair is a policy edit and a human attestation.
2113
+ - `payload-too-large` (exit 2) — a `propose` or `start` payload over 262144
2114
+ bytes of UTF-8. Refused before it is parsed; nothing is stored.
2115
+ - `task-is-proposal` — a plain `request` on a task `propose` registered, or a
2116
+ task file claiming an id in the `propose:` namespace. Nothing is appended.
2117
+ - `task-not-proposal` — `start` on a task `propose` did not register.
2118
+ - `key-class-mismatch` (exit 2) — a `propose` key that does not begin with
2119
+ `<class>:` and an identifier.
2120
+ - `contended` — `propose` restarted its bounded number of times on a log that
2121
+ kept moving under it and stopped; nothing more was appended. Read the
2122
+ standing with `wait --timeout 0`, or call again.
1532
2123
  - `append-failed` — the append itself failed; the exit code follows the cause.
1533
2124
  `head-moved` means the log grew between this command's read and its write, so
1534
2125
  nothing was written. Since APRV-236 you see it only after the command has
@@ -1660,9 +2251,23 @@ measurement.
1660
2251
  Content binding (amended SPEC.md §6.2, §10.4): run computes the hash of the argv
1661
2252
  and cwd it is about to spawn, always, and presents that. The command IS the
1662
2253
  action here, and an executor that had to be told what it was running could be
1663
- told wrong. `--payload-hash` is consequently a CHECK and never a substitute
2254
+ told wrong.
2255
+
2256
+ Since APRV-401 that computation includes THE SCRIPT THE ARGV NAMES. A known
2257
+ interpreter followed by a path operand, or a path at `argv[0]`, carries that
2258
+ file's size and SHA-256 inside the hashed payload, so a grant over `bash
2259
+ install.sh` binds the script's bytes rather than its path: a script edited
2260
+ between the declaration and the run hashes differently and is refused
2261
+ `payload-mismatch`, with nothing appended and the grant still live for the
2262
+ approved bytes. An argv naming no script hashes exactly as it always did.
2263
+ `approval payload run` prints the same value, which is how a requester obtains
2264
+ the declaration in the first place; `docs/run-payload-binding.md` states the
2265
+ rule and the six things it does not reach.
2266
+
2267
+ `--payload-hash` is consequently a CHECK and never a substitute
1664
2268
  (APRV-140): a value differing from the recomputed one is refused
1665
- `payload-mismatch` before the child exists and before anything is appended. An
2269
+ `payload-mismatch` before the child exists and before anything is appended, and
2270
+ the refusal names the script it bound so the repair is visible. An
1666
2271
  action whose payload is content rather than an argv (an email body, a record
1667
2272
  write, a message and its recipients) is executed through the adapter contract of
1668
2273
  §10.4, `approval adapter email`, which hashes those bytes itself; a token for
@@ -1798,7 +2403,7 @@ refusal {"ok":false,"error":{"code":"...","message":"...","detail"?:"...",
1798
2403
  ## sandbox
1799
2404
 
1800
2405
  ```
1801
- approval sandbox [--allow-loopback] [--log <path>] -- <cmd> [args…]
2406
+ approval sandbox [--allow-loopback] [--read-jail] [--log <path>] -- <cmd> [args…]
1802
2407
  ```
1803
2408
 
1804
2409
  Runs a command with outbound network denied by the operating system. It appends
@@ -1836,6 +2441,15 @@ is a sandbox somebody turns off.
1836
2441
  server. It is a real widening: a port is a port, and anything listening on one is
1837
2442
  reachable from inside.
1838
2443
 
2444
+ **`--read-jail`** (APRV-347) goes the other way and makes the room smaller: file
2445
+ reads become deny-default, with the gate root, the scratch roots and a fixed
2446
+ runtime set opened by `subpath`. It is already on, without the flag, whenever
2447
+ the policy declares a `read_scope` block, which is the spelling an operator
2448
+ commits and attests; the flag is for trying it on one command first. There is no
2449
+ flag that turns the jail OFF where a policy asked for it, because a flag an agent
2450
+ can pass must only ever narrow what it can do. See
2451
+ [docs/sandboxed-exec.md](./sandboxed-exec.md) for the profile and its limits.
2452
+
1839
2453
  **Exit 127** means the command was NOT run: this machine has no working sandbox
1840
2454
  primitive, or the command is not on `PATH`. Unlike `approval run`, this verb
1841
2455
  fails closed on both: it makes one promise and has nothing else to offer.
@@ -1852,6 +2466,33 @@ costs, and the carve-outs.
1852
2466
 
1853
2467
  ## wait
1854
2468
 
2469
+ `--timeout 0` (also `0s`, `0ms`) reads the current state once and never sleeps
2470
+ (APRV-445): an undecided request answers `timeout`, exit 6, at once, and a
2471
+ decided one answers as it always would. Use it to poll through `approval serve`,
2472
+ which runs every verb call whole under the store lock that hook calls take for
2473
+ their appends (APRV-427), so a wait that sleeps there holds the tenant's hook
2474
+ traffic for as long as it sleeps (APRV-441). The
2475
+ duration grammar has no zero anywhere else. `--withdraw-on-timeout` is refused
2476
+ beside it (exit 2): a status read that withdrew would retract every question it
2477
+ looked at.
2478
+
2479
+ **What a waiter is told is what the request still authorizes** (APRV-445). A
2480
+ grant whose request window has lapsed reads `expired` (exit 3), not `granted`. A
2481
+ pending request pinned to a policy hash that is no longer the attested one (any
2482
+ task), a HARNESS grant so pinned (`execution: "harness"`, every proposal
2483
+ included: its spend refuses `policy-drift`), or a request the runtime withdrew
2484
+ for `policy-drift`, reads `void`, **exit 7**: the answer can never be used, and
2485
+ the repair is to ask again (for a proposal, the same `propose` call re-files). A
2486
+ token grant is NOT void after a re-attest: `approval run` spends it without
2487
+ comparing policy hashes, so `wait && run` keeps working and `wait` reads
2488
+ `granted`. Precedence: rejected > withdrawn >
2489
+ void > expired > granted > executed > nothing-to-wait-for. An action that ran
2490
+ on the policy's own authority after its request ended (a `start` after a
2491
+ withdrawal and a re-tiering) reads `executed`, status `executed`, exit 0. A
2492
+ registered task with no requests answers `nothing-to-wait-for` at exit 0, and an
2493
+ unknown task id is refused `not-registered` at exit 1 (APRV-428), so poll the
2494
+ task id `propose` returned.
2495
+
1855
2496
  Polls the log and writes nothing — not even the `approval.expired` event it may
1856
2497
  derive: expiry is judged lazily from the request's own timestamp, and
1857
2498
  materialising it is `approval expire`'s job, not a reader's. The one exception is
@@ -1860,9 +2501,27 @@ materialising it is `approval expire`'s job, not a reader's. The one exception i
1860
2501
  For `approval wait` the exit code IS the decision (SPEC.md §10.1). The
1861
2502
  overloading of 1 (integrity / rejected) and 3 (torn tail / expired) is
1862
2503
  deliberate: wait appends nothing and cannot fail a chain verification of its
1863
- own, and `--json` names the outcome exactly (`granted | rejected | withdrawn |
1864
- expired | timeout`) for callers that need more than a number. Flagged for human
1865
- review.
2504
+ own, and `--json` names the outcome exactly (`granted | executed | rejected |
2505
+ withdrawn | void | expired | nothing-to-wait-for | timeout`) for callers that need more than a
2506
+ number. Flagged for human review.
2507
+
2508
+ **Nothing to wait for, and nothing registered** (APRV-428). Exit 0 has always
2509
+ answered at once for a task with no requests, because only the manual path
2510
+ produces requests and the supervised path tells an agent its `proceed: true`
2511
+ needs no wait. Its `status` is `nothing-to-wait-for`, never `granted`: a
2512
+ registered task with no `approval.requested` at all (no action declared, none
2513
+ reached the manual path, or none requested yet), or one whose every granted
2514
+ action has already started executing, so every grant is spent. `granted` means
2515
+ at least one granted action has not executed yet, and only that. The per-action
2516
+ rows are unchanged; an executed grant still reads `granted` there.
2517
+
2518
+ A task with no `task.registered` record is refused before anything is derived:
2519
+ `not-registered`, the gate union's code for exactly that condition (SPEC.md
2520
+ §11.2), at exit 1, the code `approval request` exits for the same refusal.
2521
+ Before APRV-428 such a wait answered `{"ok":true,"status":"granted","actions":[]}`,
2522
+ so a typo read as a grant. Through `approval serve` the verb's refusal passes
2523
+ through unchanged: the body's `stderr` carries the same object and `exit_code`
2524
+ is 1.
1866
2525
 
1867
2526
  `withdrawn` (APRV-106) reuses exit **1** rather than claiming a new number. The
1868
2527
  exit table in `src/cli/exit-codes.ts` is frozen public API and agents already
@@ -1890,10 +2549,11 @@ itself fails is reported on stderr and leaves the request live; the exit code is
1890
2549
 
1891
2550
  ```
1892
2551
  decided {"ok":true,"task":"task-042",
1893
- "status":"granted"|"rejected"|"withdrawn"|"expired",
2552
+ "status":"granted"|"rejected"|"withdrawn"|"expired"|"nothing-to-wait-for",
1894
2553
  "actions":[{"action_key":"...","state":"granted","seq":4}]}
1895
2554
  timeout {"ok":false,"task":"task-042","status":"timeout",
1896
2555
  "actions":[{"action_key":"...","state":"requested","seq":3}]}
2556
+ refused {"ok":false,"error":{"code":"not-registered","message":"..."}} (stderr, exit 1)
1897
2557
  ```
1898
2558
 
1899
2559
  **Sealed token delivery** (APRV-105). Under policy `defaults.token_delivery:
@@ -1925,8 +2585,8 @@ so the paste path is preserved rather than replaced.
1925
2585
  passed, listing the keys actually retracted; the default shape is unchanged.
1926
2586
 
1927
2587
  `state` is the per-action derived state; `status` is the whole task's outcome,
1928
- with rejected/revoked outranking withdrawn, withdrawn outranking expired, and
1929
- expired outranking granted. `--timeout` and `--interval` take the SPEC.md §5.2
2588
+ with rejected/revoked outranking withdrawn, withdrawn outranking expired,
2589
+ expired outranking granted, and granted outranking nothing-to-wait-for. `--timeout` and `--interval` take the SPEC.md §5.2
1930
2590
  duration grammar, `<positive integer><ms|s|m|h|d|w>`.
1931
2591
 
1932
2592
  ## queue
@@ -1950,11 +2610,16 @@ requested, and how much of the TTL is left.
1950
2610
  {"ok":true,"pending":[{"action_key":"task-042:chaser","task":"task-042",
1951
2611
  "class":"communicate.email.external","est_cost_usd":"0.02",
1952
2612
  "requested_ts":"2026-08-06T10:00:00.000Z","seq":3,
1953
- "ttl_remaining_ms":3599000}]}
2613
+ "ttl_remaining_ms":3599000,"ttl_ms":3600000}]}
1954
2614
  ```
1955
2615
 
1956
- `pending` is `[]` for an empty inbox. `ttl_remaining_ms` is null when the policy
1957
- declares no `defaults.approval_ttl` (no TTL means no lapse).
2616
+ `pending` is `[]` for an empty inbox. Both TTL fields are measured against the
2617
+ window the gate judges the request by (APRV-423): the policy's
2618
+ `defaults.approval_ttl`, narrowed by the harness ceiling the requesting hook
2619
+ declared, if any. `ttl_ms` is that whole window and `ttl_remaining_ms` is what is
2620
+ left of it; both are null only when nothing bounds the request (no TTL and no
2621
+ cap means no lapse). A hook-opened request under a policy with no TTL therefore
2622
+ shows its real remaining window here rather than `null`.
1958
2623
 
1959
2624
  ## gate
1960
2625
 
@@ -2095,9 +2760,47 @@ informational: it moves neither the health verdict nor the exit code. An empty
2095
2760
  store is the normal state of a repo that has never made a request carrying
2096
2761
  `--payload`. (`approval doctor` is where an unwritable store is a failure.)
2097
2762
 
2763
+ **attestation** names the policy in force: its state, the seq of the attestation
2764
+ the log last recorded, and `attested_by`, the actor of that record (APRV-449).
2765
+ The identity is read from the verified record and never from the file, so a
2766
+ hosted tenant can see whether their starting policy is the operator's
2767
+ bootstrap or their own later act (docs/hosted-provisioning.md). It is the
2768
+ identity the attesting process declared, which the runtime does not
2769
+ authenticate. `attested_by` is `null` when nothing was attested; on
2770
+ `hash-mismatch` it names who attested the bytes the live file no longer
2771
+ matches. The text row reads `attested (seq 1, by human:carter)`, and `approval
2772
+ doctor`'s attestation row carries the same name.
2773
+
2098
2774
  **anomalies** are informational for the same reason `approval log verify`
2099
2775
  declined to refuse on them: status does not get to overrule that.
2100
2776
 
2777
+ **refusals** is the two refusal families, counted, with the newest five of each
2778
+ (APRV-376). `decision` counts `audit.decision_refused`: a human decided and the
2779
+ gate would not take it, which is APRV-235's record. `gesture` counts
2780
+ `audit.gesture_refused`: a human made a gesture that is not a decision (a
2781
+ checkpoint signature, a review, a review note) and the surface refused it before
2782
+ any verb ran, which is APRV-355's. Until this field existed neither was reported
2783
+ anywhere, and an operator could reach them only through `approval log tail` and
2784
+ `approval log export`. The question the field answers is small enough that the
2785
+ chain was a poor place to answer it: told that taps from an account they did not
2786
+ map are being refused, an operator wants to know how many and from which account.
2787
+
2788
+ Each entry carries the `seq`, the surface's own refusal code verbatim, and the
2789
+ observed `sender` when the record carries one, in the form the record carries it
2790
+ (`hashed: true` marks APRV-370's keyed digest rather than a raw account id). The
2791
+ count is every record of that family in the log; the listing is the newest five,
2792
+ newest first, because a row that grew with the log would push the rest of the
2793
+ report off a terminal for a fact that decides nothing.
2794
+
2795
+ It is informational, exactly as `coverage` and `anomalies` are: it moves neither
2796
+ `healthy` nor the exit code. A refusal is the gate having worked, and a report
2797
+ that went red because the gate turned away a stranger's tap would teach an
2798
+ operator to stop reading it. It reads only verified records (SPEC.md §11.1
2799
+ invariant 1), which is also why a log that does not verify reports no refusals
2800
+ rather than reporting some it cannot stand behind. A family with no records is
2801
+ absent rather than zero, and the whole field is absent when both families are, so
2802
+ a repository where nothing has been refused emits the object it always emitted.
2803
+
2101
2804
  **coverage** is one line of `approval coverage` (APRV-245): the commits git
2102
2805
  recorded on THIS branch, counted against the verified log. The range is the
2103
2806
  merge base with `origin/main` to `HEAD`, so what it measures is what this branch
@@ -2118,7 +2821,7 @@ does not know the flag exists.
2118
2821
  **What it reports**, in one object:
2119
2822
 
2120
2823
  - `attestation` — attested | hash-mismatch | not-attested | unreadable, with the
2121
- seq of the governing `policy.updated` record.
2824
+ seq of the governing `policy.updated` record and `attested_by`, its actor.
2122
2825
  - `verification` — the latest chain verdict, and the record count (null when
2123
2826
  corrupt).
2124
2827
  - `dangling` — executions the runtime meant to watch and never closed. Not
@@ -2141,12 +2844,25 @@ does not know the flag exists.
2141
2844
  how many the log records as pruned, and how many are unbound. Informational.
2142
2845
  - `anomalies` — additive and present only when non-empty: gate-typed events whose
2143
2846
  `ts` steps backwards by more than 2s. Informational.
2847
+ - `refusals` — additive and present only when the log carries a refused decision
2848
+ or a refused gesture: `{count, recent}` per family, each recent entry naming the
2849
+ `seq`, the refusal `code` and the observed `sender` where there was one.
2850
+ Informational.
2851
+ - `daemon` — `{id, source, allowed}`, always present (APRV-383): the daemon
2852
+ instance id a daemon run against THIS log would write onto every record it
2853
+ appends, whether it came from `APPROVAL_DAEMON_ID` (`source: "environment"`) or
2854
+ was derived from the instance (`source: "derived"`), and the `daemons` allowlist
2855
+ the attested policy puts in force. `id: null` means `APPROVAL_DAEMON_ID` holds
2856
+ something that is not a usable id, so a daemon started here would be refused
2857
+ every append. `allowed: null` means no restriction, which is not the same fact as
2858
+ an empty list. Informational: nothing here moves `healthy` or the exit code, and
2859
+ being listed grants a daemon nothing beyond the ability to write.
2144
2860
 
2145
2861
  **`--json`** (one object on stdout):
2146
2862
 
2147
2863
  ```
2148
2864
  {"ok":true,"healthy":false,
2149
- "attestation":{"state":"attested","seq":1},
2865
+ "attestation":{"state":"attested","seq":1,"attested_by":"human:carter"},
2150
2866
  "verification":{"status":"clean","records":6},
2151
2867
  "dangling":[{"action_key":"...","task":"...","ts":"...","seq":5}],
2152
2868
  "budgets":[{"limit":"global.daily_usd","scope":"global",
@@ -2158,12 +2874,16 @@ does not know the flag exists.
2158
2874
  "reconciliation":[{"seq":18,"ts":"...","action_key":"...","task":"...",
2159
2875
  "class":"records.write","obligation":"gated-revert","review_seq":17}],
2160
2876
  "payload_store":{"present":true,"files":2,"pruned":0,"orphans":0,
2161
- "note":"..."}}
2877
+ "note":"..."},
2878
+ "refusals":{"decision":{"count":2,"recent":[{"seq":21,"code":"policy-drift"}]},
2879
+ "gesture":{"count":1,"recent":[{"seq":24,"code":"sender-unmapped",
2880
+ "sender":{"channel":"telegram","id":"5551234567"}}]}},
2881
+ "daemon":{"id":"daemon-3f2a9c11","source":"derived","allowed":null}}
2162
2882
  ```
2163
2883
 
2164
- `ok` is true whenever status ran; `healthy` is the verdict. `attestation.seq` is
2165
- null for not-attested and unreadable. `note` carries the unrebuildable warning
2166
- verbatim.
2884
+ `ok` is true whenever status ran; `healthy` is the verdict. `attestation.seq` and
2885
+ `attestation.attested_by` are null for not-attested and unreadable. `note`
2886
+ carries the unrebuildable warning verbatim.
2167
2887
 
2168
2888
  ## coverage
2169
2889
 
@@ -2355,7 +3075,8 @@ The checks, at length:
2355
3075
  `--as`: this reports what the next command will find.
2356
3076
  - **attestation** — anything other than "the live bytes match" makes every gated
2357
3077
  operation refuse, and that refusal reads like "the policy says no" when it
2358
- means "the policy is unverified".
3078
+ means "the policy is unverified". A pass names the seq and the attester read
3079
+ from that record (`attested at seq 1 by human:carter`, APRV-449).
2359
3080
  - **log** — a torn tail and a corrupt log are both failures here; neither is
2360
3081
  repaired, and doctor never truncates a torn line.
2361
3082
  - **telegram** — `getMe` against `--api-base`, when both variables are set;
@@ -2372,7 +3093,17 @@ The checks, at length:
2372
3093
  `--payload` request creates it); an existing directory this process cannot
2373
3094
  write FAILS, because a request already accepted by the gate would refuse
2374
3095
  `payload-store-failed` mid ceremony. The probe creates and removes one empty
2375
- file and reads no payload.
3096
+ file. Since APRV-457 the row also reads back every payload a verified record
3097
+ binds (pruned ones excepted) and FAILS on two distinct readings. An empty or
3098
+ all-NUL file, or a missing file whose record proves the store held it
3099
+ (`policy.proposed`, `policy.updated`, or `approval.requested` carrying
3100
+ `display_hash`), is the crash-before-writeback signature: nothing was
3101
+ tampered and the bytes are gone from this store; the fix moves a torn file
3102
+ aside and restores a copy that `approval payload hash` confirms. A file
3103
+ holding any non-NUL byte that does not verify is NOT the crash signature
3104
+ (a partial writeback leaving a valid prefix and a NUL tail lands here too, the stricter reading) and reads as tampering or
3105
+ corruption; the fix moves it aside as `.suspect`. Doctor moves, restores and
3106
+ deletes nothing.
2376
3107
  - **audit-sampling** — sampling fails open by design (SPEC.md §5.2), so an
2377
3108
  unconfigured sampler silently audits nothing; this states the disabled reason
2378
3109
  out loud. A sampler nobody configured skips; a half-configured one fails,
@@ -2447,17 +3178,25 @@ The checks, at length:
2447
3178
  of the gate, so the repair is a line for a human to commit, printed by
2448
3179
  `approval instructions hook`.
2449
3180
  - **harness-hook-wiring** — whether THIS checkout's `.claude/settings.json`
2450
- registers `approval hook` for PreToolUse over every gated tool (Bash, Edit,
2451
- Write, NotebookEdit). SKIP, named, when the file is absent, unreadable, or
3181
+ registers `approval hook claude-code` for PreToolUse over every gated tool.
3182
+ The roster is read from the adapter that answers the calls (Bash, Edit, Write,
3183
+ MultiEdit, NotebookEdit since APRV-408, where it was a hand list that had
3184
+ drifted behind them). SKIP, named, when the file is absent, unreadable, or
2452
3185
  registers the hook for only some tools: a spawned-agent worktree without the
2453
3186
  entry is how the APRV-151 bypasses happened, and a session started elsewhere
2454
3187
  may still be hooked, so this row can only speak for the checkout it runs in.
2455
3188
  PASS means the entry is present on disk, and says so plainly: it is not proof
2456
- the running session loaded it. The check that trusts no session is the
2457
- CI-side grant cross-check (`scripts/protected-path-guard.mjs`) over the
2458
- committed log, which since APRV-202 requires every added and removed line of a
2459
- protected path to trace to the bound material of a grant, rather than only
2460
- that the path was granted at some point in the week.
3189
+ the running session loaded it. The one FAIL is a handler whose `--dir` names a
3190
+ checkout other than this one, which answers from another policy, another log
3191
+ and another open window. Two informational lines never decide anything: the
3192
+ adapter read tools the matcher leaves out (Read, Glob, Grep) are named as the
3193
+ documented default they are, and a matcher tool the adapter handles as none of
3194
+ its own is named as the unclassified `allow` it will actually receive. The
3195
+ check that trusts no session is the CI-side grant cross-check
3196
+ (`scripts/protected-path-guard.mjs`) over the committed log, which since
3197
+ APRV-202 requires every added and removed line of a protected path to trace to
3198
+ the bound material of a grant, rather than only that the path was granted at
3199
+ some point in the week.
2461
3200
  - **keychain-scope** — whose keystore items this instance's `.approval/env`
2462
3201
  names, answered from the NAMES alone so that it too can never block on an
2463
3202
  unlock dialog. FAIL for an item whose eight-hex scope suffix belongs to
@@ -2530,6 +3269,23 @@ The checks, at length:
2530
3269
  log sync` for a diverged log, `approval up` otherwise — never a `git` command:
2531
3270
  a repair line telling an operator to reset a branch would be doctor making the
2532
3271
  decision this project keeps human.
3272
+ - **attested-policy-on-main** — whether the policy the log vouches for is the
3273
+ policy `origin/<branch>` carries (APRV-342). `attestation` above asks whether
3274
+ the LOCAL file is attested; between a `policy amend` and its pull request
3275
+ merging that answer is yes while a fresh checkout of main carries the old
3276
+ policy with no attestation covering it, so every gate operation there refuses
3277
+ `policy-not-attested`. Nothing said so until this row: on 2026-09-16 `approval
3278
+ up` ran in exactly that state and reported "already at the remote tip". PASS
3279
+ when the attested hash equals the SHA-256 of `APPROVAL.md` at the remote tip.
3280
+ FAIL with `attested at seq N, not yet on main`, naming
3281
+ `policy-amend-<seq>` when this checkout has already seen that branch on the
3282
+ remote, and fixing with `approval policy amend --pr` — which opens the pull
3283
+ request or updates the open one, so the same command is right either way.
3284
+ SKIP with no attestation, outside a git checkout, and where there is no
3285
+ remote-tracking ref. **It fetches nothing**, for the reason
3286
+ `main-behind-origin` fetches nothing, and it looks for the amend branch among
3287
+ the remote-tracking refs rather than asking GitHub. `approval up`'s preflight
3288
+ prints the same sentence on stderr and never refuses on it.
2533
3289
  - **harness-version-unverified** — whether the harness binary hosting the
2534
3290
  PreToolUse hook changed since the log last saw a record from it (APRV-227).
2535
3291
  The only row that asks anything about a program outside this repository, and
@@ -2544,7 +3300,14 @@ The checks, at length:
2544
3300
  the `fix` is the promptless self-test in `docs/claude-code-hook.md`, one
2545
3301
  supervised-class tool call, after which the row is green. PASS says only that
2546
3302
  the versions match — the field is self-reported (SPEC.md §11.1 invariant 4)
2547
- and reduces nothing anywhere, so a match is not proof the hook fired.
3303
+ and reduces nothing anywhere, so a match is not proof the hook fired. Since
3304
+ APRV-415 the row carries a second finding for one harness: a Hermes below the
3305
+ **fail-closed version floor** (a build at or after `main` `118984d7` of
3306
+ 2026-09-20) FAILS, because `v0.21.3` ignores `fail_closed` silently and every
3307
+ broken hook on it proceeds. That half is read from `hermes --version` rather than
3308
+ from a record — Hermes prints its build stamp behind a non-ASCII separator the
3309
+ write boundary refuses, so no record can carry it — and it compares the build
3310
+ date and the upstream commit, because the two builds report the same semver.
2548
3311
  - **live-draw** — whether a daemon is answering `supervised-live` draws for this
2549
3312
  log (APRV-208). SKIP when the policy declares no live class: no draw is ever
2550
3313
  made, and a missing socket is nothing. It FAILS in three shapes, all of them
@@ -2573,7 +3336,14 @@ The checks, at length:
2573
3336
  words SPEC.md §5.3 fixes: a file with no block is an operator who has declared
2574
3337
  no values, which is a state and not a fault. The only FAIL is a block that is
2575
3338
  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.
3339
+ repair, because what the block should say is the human's to write. The one
3340
+ exception is the block APRV-336 replaced (`version: 1` with a `wants` list):
3341
+ that is a correct document of the wrong vintage rather than a broken one, so
3342
+ the row's detail carries the loader's migration message and its fix names the
3343
+ three edits (fold `wants:` into `like:`, rename `responds:` to
3344
+ `communication:`, quote `version: "0.2"`) plus the re-attestation that the
3345
+ whole-file digest requires. The pass detail lists what the block declares,
3346
+ which are `love`, `like`, `dislike` and `communication` and nothing else.
2577
3347
  - **checkpoint** — how this log stands against its own human-signed checkpoints
2578
3348
  (APRV-257), running the same check as `approval log verify --checkpoints`, so
2579
3349
  two implementations of "does this log's own signature contradict it" cannot
@@ -2622,6 +3392,73 @@ The checks, at length:
2622
3392
  doctor does not interpret or merge the TOML hook tables. Malformed JSON
2623
3393
  FAILS; a different valid Codex hook profile SKIPS as undetermined rather than
2624
3394
  being called broken.
3395
+ - **autonomy-alias** — which rules of this policy still write the deprecated
3396
+ bare `supervised` (APRV-335). The spelling parses as `supervised-retro` and
3397
+ every gate enforces it as one, so the row is never a FAIL and never moves the
3398
+ exit code: a red line over a spelling would be doctor going red over prose.
3399
+ The two PASS shapes are the whole row. Where some rule uses it, the detail
3400
+ names each one, in the order the loader's own notes name them, and the `fix`
3401
+ is `approval policy amend`, which owns the edit and the re-attestation that
3402
+ edit costs. Where none does, the detail says so plainly, which is the answer
3403
+ an operator wants before a future schema version drops the alias. A policy
3404
+ that did not load is a SKIP: it names no level at all, and its own failure is
3405
+ reported by the attestation row and by `approval policy check`.
3406
+ - **pending-sign-off** — which protected files carry SPEC.md's
3407
+ `(Amended APRV-n, pending sign-off.)` marker with no `gate.path.signed_off`
3408
+ record over their current bytes (APRV-338). Informational and never a FAIL,
3409
+ for the two reasons `gate-organs` is: nothing on this machine is broken by
3410
+ unratified prose, and the enforcement that does bite is the CI-side
3411
+ protected-path guard. What the row buys is that the debt is visible at the
3412
+ terminal rather than discovered when a pull request fails. It reads the
3413
+ enumerated directories (`.`, `.github/workflows/`, `docs/`, `design/`) plus
3414
+ every path the policy's `protected_paths` names, keeps only what classifies
3415
+ `policy.edit` or a `policy.edit.*` sub-class, and reports a file whose current
3416
+ bytes ARE signed off as ratified rather than pending. The `fix` is
3417
+ `approval policy attest --path <p> --as human:<id>`, to be run after reading
3418
+ the file; a marker whose text was later granted through the gate should lose
3419
+ the suffix instead.
3420
+ - **sender-mapping** — which approvers a channel whose senders the policy maps
3421
+ can still recognize (APRV-324). A SKIP where no approver declares a `senders`
3422
+ block, which is every installation before the key existed: decisions are
3423
+ recorded against the identity the deciding process was launched with, no
3424
+ channel enforces a mapping, and nothing is wrong. A FAIL where the policy
3425
+ maps senders for a channel and lists an approver on that channel with no id
3426
+ of their own there — that person's next tap is refused `sender-unmapped` and
3427
+ nothing is recorded, so the file says they may decide on a surface where they
3428
+ cannot. A PASS where every approver a mapped channel reaches carries an id
3429
+ there. The `fix` is `approval policy amend`, which owns the edit and the
3430
+ re-attestation it costs. The row reads the policy and nothing else: no log,
3431
+ no network, no credential, and it prints nobody's account id — the mapping is
3432
+ in a file the operator can open, and a health row is read over shoulders.
3433
+ - **codex-auto-reviewer** — has anything other than this gate answered a
3434
+ question this gate exists to ask (APRV-378)? It reads
3435
+ `audit.question_preempted` and nothing else. A FAIL where one landed in the
3436
+ last 24 hours, naming the source, the question in the other party's terms and
3437
+ their verdict; a PASS where none did, mentioning any older ones; a SKIP where
3438
+ the chain did not verify. A PASS says the log holds no such record and NOT
3439
+ that a harness auto-reviewer is off: whether it runs is configuration this
3440
+ runtime cannot read, and a row written against a guessed configuration key
3441
+ would find nothing and report green, which is the worst direction a health
3442
+ check can fail in. The `fix` points at the harness's own configuration,
3443
+ because nothing here can turn another system's reviewer off.
3444
+ - **daemon-identity** — which daemon id a daemon run against this log would
3445
+ write onto every record it appends, and whether the attested policy admits it
3446
+ (APRV-383). The id is `APPROVAL_DAEMON_ID` from this shell where it is set and
3447
+ otherwise `daemon-` plus the instance id the `keychain-scope` row names, so the
3448
+ two rows describe one gate. A FAIL where `APPROVAL_DAEMON_ID` is set to
3449
+ something that is not an id: a daemon started from this shell would be refused
3450
+ `daemon-id-invalid` on every append, so it would read, render and report while
3451
+ writing nothing, and the `fix` is that one variable. A loud SKIP where the
3452
+ attested policy's `daemons` list does not admit the id, naming what the list
3453
+ does admit — expected where the daemon for this log runs on another machine,
3454
+ which is the hosted case the key exists for, and wrong if that daemon is meant
3455
+ to be this one. A PASS where the list admits it, and where there is no list at
3456
+ all, which is every installation that never adopts the key. Being listed grants
3457
+ nothing beyond the ability to write: no verdict, budget, floor, draw or token
3458
+ reads the id. The row reads the policy and one variable's SHAPE, prints no
3459
+ value on any path, and asks no running daemon anything.
3460
+
3461
+
2625
3462
 
2626
3463
  **`--json`** (one object on stdout):
2627
3464
 
@@ -2650,8 +3487,9 @@ reaches this backlog. A `supervised-live` class puts a declared `live_rate`
2650
3487
  fraction of its actions through the human gate BEFORE they run; those are
2651
3488
  ordinary manual requests with ordinary grants and tokens, a person has already
2652
3489
  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
3490
+ `supervised-retro` class — and the bare `supervised`, which is now a deprecated
3491
+ alias for it, still parsed and removed in a future schema version (APRV-335) —
3492
+ is what this page is about. `approval policy check` names the mode in its
2655
3493
  final line and in `outcome.supervision`.
2656
3494
 
2657
3495
  Supervised actions execute immediately and are audited afterwards. The daemon
@@ -3291,6 +4129,16 @@ This starts the standalone Telegram component. For normal operation, use
3291
4129
  `up` or another listener polling the same bot, even for a different policy
3292
4130
  project: competing `getUpdates` calls produce Telegram HTTP 409.
3293
4131
 
4132
+ A bot whose updates go to a webhook cannot be long-polled at all, so this verb
4133
+ and `approval up` refuse `webhook-registered` in the same preflight that asks
4134
+ `getMe`, naming the origin that holds it. That preflight also takes this gate's
4135
+ transport lease (`.approval/daemon/telegram-transport.lock`), so a second
4136
+ listener or a webhook runner started in the same project refuses
4137
+ `telegram-poller-running` naming the pid that holds it, rather than both
4138
+ processes putting every request on the phone under their own nonce. See
4139
+ [`channel telegram webhook`](#channel-telegram-webhook), which is the other
4140
+ transport and removes both its registration and its lease when it exits.
4141
+
3294
4142
  **Delivery is per cycle, not only at startup.** Before every `getUpdates` the
3295
4143
  listener re-derives the pending queue from the verified log and sends whatever
3296
4144
  it has not already sent, so a request appended while this listener is running
@@ -3501,6 +4349,64 @@ leaves the sample exactly where it was: open, listed by `approval audit list`,
3501
4349
  and reviewable with `approval audit review <seq>`. Nothing in this channel can
3502
4350
  empty the backlog, which is the property a sampled-audit backlog exists to have.
3503
4351
 
4352
+ **A refused gesture leaves a record (APRV-355).** When the policy maps senders,
4353
+ a checkpoint signature or a review from an account the attested policy names
4354
+ nobody for is refused before any verb runs, and since this change the attempt is
4355
+ recorded: one **`audit.gesture_refused`**, with a `system:gate` actor, the
4356
+ channel, and a payload carrying `gesture`
4357
+ (`checkpoint-signature`, `review`, `review-note`), `code`
4358
+ (`sender-unmapped`, `sender-ambiguous`, `sender-key-unavailable`,
4359
+ `policy-not-attested`), the refusal
4360
+ `message`, the observed `sender`, and `actor` only where the runtime could name
4361
+ a person. Under a keyed mapping (APRV-370) the `sender.id` is the digest rather
4362
+ than the account, and `sender.hashed` is `true` beside it.
4363
+
4364
+ ```
4365
+ {"event":"audit.gesture_refused","actor":"system:gate","channel":"telegram",
4366
+ "payload":{"gesture":"checkpoint-signature","code":"sender-unmapped",
4367
+ "sender":{"channel":"telegram","id":"5551234567"},"message":"…"}}
4368
+ ```
4369
+
4370
+ It exists because the only refusal record before it,
4371
+ `audit.decision_refused`, requires an `action_key` and a `decision` of grant,
4372
+ reject or revoke, and a signature or a review has neither; writing one there
4373
+ would mean inventing both. The record is audit tier in the strict sense: it
4374
+ authorizes nothing, settles no request, charges no budget, is not sampled, and
4375
+ no enforcement path reads it. `approval log tail` and `approval log export`
4376
+ show it like any other record. Nothing about the refusal itself changed — no
4377
+ signature is appended and no review is recorded — and a listener whose policy
4378
+ maps no senders never reaches this path at all.
4379
+
4380
+ **A question answered by something other than the gate leaves a record
4381
+ (APRV-378).** Codex's app-server can resolve an approval with a model call
4382
+ before `approval codex bridge` is asked, and disclose it afterwards through an
4383
+ `item/autoApprovalReview` notification. The bridge stops the session on one
4384
+ (`bridge-auto-reviewer-active`), and it appends exactly one
4385
+ **`audit.question_preempted`** first, through the same append path as every
4386
+ other record:
4387
+
4388
+ ```
4389
+ {"event":"audit.question_preempted","actor":"system:gate",
4390
+ "payload":{"source":"codex-auto-reviewer","verdict":"accept",
4391
+ "question":{"id":"item_01H9","method":"item/autoApprovalReview/completed",
4392
+ "thread":"thread_7f2","turn":"turn_3"}}}
4393
+ ```
4394
+
4395
+ `source` is a closed set naming who answered, so the next system that does this
4396
+ gains a member rather than a type. `question` is the other party's own
4397
+ identifiers, because a record about their decision has to name it in their terms
4398
+ or a reader cannot go and find it on their side. `verdict` is their word
4399
+ verbatim and is ABSENT when the disclosure stated none: a record that said
4400
+ `accept` by default would be this runtime inventing somebody else's decision.
4401
+
4402
+ The record is audit tier in the strict sense: it authorizes nothing, settles no
4403
+ request, charges no budget, is not sampled, and no enforcement path reads it.
4404
+ `approval log tail` and `approval log export` show it like any other record, and
4405
+ `approval doctor`'s `codex-auto-reviewer` row reads it: fail when one landed in
4406
+ the last 24 hours, pass otherwise, and a pass says the log holds no such record
4407
+ rather than that any auto-reviewer is off. Nothing on this machine can say the
4408
+ latter, which is why the bridge probes (APRV-364) instead of reading a setting.
4409
+
3504
4410
  **A settled request stops looking live.** Every terminal state the listener
3505
4411
  observes for a message it sent edits that message: the text becomes the outcome
3506
4412
  (`✓ APPROVED`, `✗ REJECTED`, `✗ REVOKED`, `✗ EXPIRED`, `WITHDRAWN`) with the
@@ -3542,6 +4448,253 @@ rather than a query:
3542
4448
 
3543
4449
  The raw execution token is never in the JSON stream.
3544
4450
 
4451
+ ## channel telegram webhook
4452
+
4453
+ The same channel, the other arrival (APRV-424). `listen` holds a socket open
4454
+ against `getUpdates` for as long as the gate exists, which needs one
4455
+ always-running process per bot and forbids a second poller on the same token.
4456
+ This registers a URL with `setWebhook` and serves the callback, so Telegram
4457
+ posts each update as it happens and a host that sleeps between requests can run
4458
+ a gate.
4459
+
4460
+ **Only the arrival changes, and that is the whole claim.** The pending queue is
4461
+ re-derived from the verified log every cycle by the same `dispatchPending`. The
4462
+ handlers are the same four function objects `listen` registers. A tap becomes a
4463
+ decision through the same `handleUpdate`, the same `callback_query.from.id`
4464
+ reading, the same `approvers.<id>.senders` resolution, the same
4465
+ `recordChannelDecision` and the same annotate-after-decision edit. There is no
4466
+ second decision path and no second copy of the sender mapping:
4467
+ `tests/telegram-webhook.test.ts` drives one callback through both transports and
4468
+ compares the records the log ends up holding, field for field.
4469
+
4470
+ ### The secret, which is the whole of the authentication
4471
+
4472
+ `APPROVAL_TG_WEBHOOK_SECRET` is REQUIRED and is read from the launch
4473
+ environment, never from a file in the tree (SPEC.md §11.1 invariant 7, the rule
4474
+ `approval serve`'s two credentials follow). It is Telegram's own `secret_token`:
4475
+ set on `setWebhook`, echoed on every delivery in the
4476
+ `X-Telegram-Bot-Api-Secret-Token` header, compared here in constant time over
4477
+ SHA-256 digests.
4478
+
4479
+ A webhook URL is a public endpoint, and nothing in a request body could tell a
4480
+ delivery from a forgery — a self-reported field never reduces scrutiny (§11.1
4481
+ invariant 4). So the header is checked FIRST, before the path, the method and
4482
+ the body, and a post without the matching value is refused with its own code,
4483
+ counted, and reported on stderr. **A refusal is never a decision, and it appends
4484
+ nothing to the log.** That is the rule `TELEGRAM_ANOMALY_KINDS` states for
4485
+ ignored callbacks, and it binds harder here: an endpoint the internet can reach
4486
+ that could append would be an endpoint anyone could use to pad the record a
4487
+ human is asked to trust.
4488
+
4489
+ The value is refused at startup when it is unset, shorter than 24 characters, or
4490
+ holds a character outside Telegram's `A-Z a-z 0-9 _ -`. It appears in no policy,
4491
+ no log, no record, no message and no error line: the channel redacts it from
4492
+ everything that leaves it, exactly as it redacts the bot token.
4493
+
4494
+ | refusal | what happened |
4495
+ |---|---|
4496
+ | `webhook-secret-mismatch` | no `X-Telegram-Bot-Api-Secret-Token`, or not the registered value |
4497
+ | `webhook-duplicate-secret-header` | more than one of that header; malformed, and not resolvable by choosing |
4498
+ | `webhook-malformed-request` | the request line and `Host` header do not form a URL (400) |
4499
+ | `webhook-unknown-path` | a path this process does not serve (404) |
4500
+ | `webhook-method-not-allowed` | the right path, the wrong method; Telegram POSTs |
4501
+ | `webhook-body-too-large` | over 256 KiB; drained before the refusal is written |
4502
+ | `webhook-body-unreadable` | the body could not be read off the socket, or is not JSON |
4503
+ | `webhook-not-an-update` | JSON that is not an Update object |
4504
+ | `webhook-transport-conflict` | this channel is already receiving updates by long poll |
4505
+ | `webhook-handler-failed` | the channel threw; the log is what says what was recorded |
4506
+
4507
+ Startup refuses in its own vocabulary before anything binds:
4508
+ `webhook-secret-missing`, `webhook-secret-weak`, `webhook-secret-charset`,
4509
+ `webhook-url-missing`, `webhook-url-insecure`, `webhook-url-port`,
4510
+ `webhook-url-userinfo`, `webhook-path-invalid`, `webhook-path-mismatch`,
4511
+ `webhook-cycle`, and `webhook-registration-failed` for a `setWebhook` the Bot
4512
+ API turned down (its status and its own description, scrubbed of the token and
4513
+ the secret), plus every `channel telegram listen` refusal, which this verb
4514
+ inherits because it needs the same bot token, chat, identity, log and policy.
4515
+
4516
+ Four of those are the review's, and each one closes a start that used to
4517
+ succeed and then not work:
4518
+
4519
+ - **`--path` must be the url's own path.** `telegram/webhook` with no leading
4520
+ slash, or `/hook/` against a `--url` ending `/hook`, registered cleanly and
4521
+ then answered every real delivery 404 after verifying its secret. `--path` is
4522
+ an explicit restatement of the url's path and nothing else; rewriting belongs
4523
+ in the proxy in front of this process.
4524
+ - **`--url` may not carry userinfo.** `https://user:pw@host/hook` is a
4525
+ credential on a command line. It is refused, never stripped.
4526
+ - **The bind port may not be 0**, and must be inside 1..65535. Port 0 asks the
4527
+ kernel for an ephemeral port, and this transport is a fixed public url
4528
+ forwarded to a fixed local address: a receiver on a port nobody registered
4529
+ verifies nothing and answers nothing.
4530
+ - **Every line that prints the url prints its origin and a redacted path**, in
4531
+ the start-up banner, in `webhook_started` and in refusals: with two segments
4532
+ or more the first is kept and the rest becomes `<path redacted>`, a path of
4533
+ exactly one segment is replaced whole (that is the shape a tunnel's own token
4534
+ takes), and a query string becomes `<query redacted>`. A tunnel that hands out
4535
+ `https://host/hook/<random>` puts a bearer value in a path, and this runtime
4536
+ does not copy it into three more places. The local bind line is redacted the
4537
+ same way, because it carries the same path. The operator has the whole of it
4538
+ already: they typed it into `--url`.
4539
+
4540
+ ### The proxy or tunnel is required, and this process holds no certificate
4541
+
4542
+ Telegram delivers to HTTPS only, on port 443, 80, 88 or 8443. This process
4543
+ terminates no TLS and holds no certificate, exactly as `approval serve` does
4544
+ not: `--url` names the PUBLIC address your proxy or tunnel answers on, and
4545
+ `--port` / `--listen` names this process's own bind, which is `127.0.0.1:4683`
4546
+ unless you say otherwise. A routable bind takes both `--listen <host:port>` and
4547
+ `--allow-non-loopback` and prints a banner, because the secret arrives in a
4548
+ header and a cleartext hop hands it to whoever is on it.
4549
+
4550
+ So a working deployment is a reverse proxy (nginx, Caddy, a cloud load balancer)
4551
+ or a tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel) terminating TLS on a
4552
+ name you control and forwarding to the loopback bind. Without one there is
4553
+ nothing to register: a plain-HTTP `--url`, or one on another port, is refused
4554
+ before any call is made rather than attempted and rejected by the Bot API.
4555
+
4556
+ ### One transport per bot, refused on both sides
4557
+
4558
+ `getUpdates` is refused by the Bot API outright while a webhook is set, so the
4559
+ two are alternatives and never a pair. Four checks say so before anything runs,
4560
+ and they are in that order because the first needs no network:
4561
+
4562
+ - **A lease per gate.** `approval up`, `approval channel telegram listen` and
4563
+ this verb take a lockfile in the gate's own derived state directory,
4564
+ `.approval/daemon/telegram-transport.lock`, holding the holder's pid, which
4565
+ transport it is running and when it started. It is created with `O_EXCL`, so
4566
+ two processes racing cannot both believe they took it, and a holder that is
4567
+ no longer running is reclaimed automatically (a crash must not lock a gate
4568
+ out of its own channel). A second taker refuses `telegram-poller-running`
4569
+ when a poller holds it and `webhook-registered` when a webhook does, naming
4570
+ the pid and the mode; a directory the lease cannot be written in refuses
4571
+ `telegram-lease-unavailable`, because this file is the only thing keeping one
4572
+ gate from running two transports. Both transports release it on a clean stop.
4573
+
4574
+ **A holder is a process, not a number.** Pids are reused, so the probe asks
4575
+ three questions: does a process with that number exist, can this process
4576
+ signal it, and did it start before the lease was written. A number that is
4577
+ gone, one owned by a process this one cannot signal, and one belonging to a
4578
+ process that started *after* the lease are all reclaimed, and the line says
4579
+ which of the three it was. Only a process that is running, signalable and
4580
+ older than its own lease keeps the gate. Where the platform will not report a
4581
+ start time, a running pid keeps the gate, which is the stricter reading.
4582
+
4583
+ **Taking a lease over is a critical section, and the section is owned.**
4584
+ Reclaiming happens inside a sibling `O_EXCL` lock that carries the entering
4585
+ process's pid and a nonce. Inside it the lease record must still be exactly
4586
+ the one that was judged (same pid, same `started_at`), the lock must still
4587
+ name this entry at the moment of the write, and the lock is removed on the
4588
+ way out only while it still does. Without the first, two processes that had
4589
+ both read one dead holder took turns deleting each other's live lease;
4590
+ without the other two, a process that stalled inside the section renamed its
4591
+ lease over the one that replaced it and deleted the new holder's lock. The
4592
+ replacement is a rename, so the lockfile is never absent and never half
4593
+ written. A reclaim lock is evicted only when its own process is gone, judged
4594
+ by the probe above: its age is reported and decides nothing, because "five
4595
+ seconds have passed" says nothing about whether anybody is still inside. A
4596
+ lock this build cannot read names no process, so the take refuses and names
4597
+ the file rather than forcing it.
4598
+
4599
+ This is the check the other three cannot make. Two processes started in the
4600
+ SAME project — one `approval up` long-polling, one `approval channel telegram
4601
+ webhook` — passed all of them: `getWebhookInfo` is empty while the poller is
4602
+ the one running, and the ownership registry compares instance ids, which are
4603
+ equal because it is the same instance. Each then ran its own dispatch cycle
4604
+ over its own state against one log, so every request reached the phone twice
4605
+ under two nonces and only one copy could resolve a tap.
4606
+ - **What the Bot API says.** `listen` and `approval up` ask `getWebhookInfo` in
4607
+ the same preflight that asks `getMe`, and refuse `webhook-registered` naming
4608
+ the origin that holds the bot. For a poller an unreachable Bot API is not a
4609
+ refusal (a captive portal says nothing about which transport owns a bot) and
4610
+ the HTTP 409 path is the backstop. For THIS verb it is: a probe it could not
4611
+ make refuses `webhook-probe-failed`, because the next call would be a
4612
+ `setWebhook` that overwrites a registration this process never saw.
4613
+ - **Any existing registration, including the same url, refuses.** Telegram
4614
+ allows one webhook per bot and keeps the last registration, so a second host
4615
+ registering takes the taps: it overwrites the first host's `secret_token`, and
4616
+ from then on every real tap arrives at the first host with a secret it does
4617
+ not recognise, indistinguishable from a forgery. `--reclaim` is how an
4618
+ operator takes the bot deliberately; with it, a url that matches the
4619
+ registered one (host lowercased, trailing slash dropped) is reported as a
4620
+ re-registration and anything else as a takeover that stops the other receiver
4621
+ being posted to.
4622
+
4623
+ **One case needs no flag: this gate's own runner was killed.** A `SIGKILL`
4624
+ leaves the registration in place, because the dead process never reached
4625
+ `deleteWebhook`, and a restart that refused every time would teach operators
4626
+ to put `--reclaim` in the unit file, which retires the protection above for
4627
+ good in exchange for a crash recovery. So a restart may re-register the SAME
4628
+ normalised url without the flag when the lease it just reclaimed was this
4629
+ gate's own, written by a webhook runner, and that process is **gone**. A dead
4630
+ poller's lease does not count, a different url does not count, and a gate
4631
+ with no such lease does not count. Nor do the other two reclaim reasons: a
4632
+ lease reclaimed because its pid is owned by a process this one cannot signal,
4633
+ or because the number has been reused, is the right call for the lease and no
4634
+ evidence at all that the runner which registered the url has stopped. Those
4635
+ keep the refusal and the `--reclaim` demand.
4636
+ - Within one process, `TelegramChannel.claimTransport` refuses the second claim.
4637
+
4638
+ A clean stop (SIGINT, SIGTERM) removes the webhook, so long polling works again
4639
+ afterwards. Pending updates are kept in both directions: a tap that arrived
4640
+ while the process was down is the approver's answer, and the gate is what
4641
+ decides whether it is still honourable.
4642
+
4643
+ ### Redelivery, and the cycle
4644
+
4645
+ Telegram retries a delivery it did not get a 2xx for, so the same tap can arrive
4646
+ twice. Nothing deduplicates it: the second one reaches the gate through the same
4647
+ path as the first and is refused `already-decided`, with the first human answer
4648
+ standing — the property a button pressed twice has had since the channel
4649
+ shipped. Updates are handled one at a time, in arrival order, and the response
4650
+ is written after the update has been handled.
4651
+
4652
+ `--cycle <duration>` (default 30s) is the dispatch period, the webhook's
4653
+ equivalent of the poll loop's per-cycle dispatch: it re-derives the pending
4654
+ queue and sends what has not been sent. A cycle also runs immediately after each
4655
+ handled update, so a paced listener's next question follows a tap without
4656
+ waiting for the timer.
4657
+
4658
+ **A stop waits for the update it is holding.** Ctrl-C stops the receiver
4659
+ accepting, then drains: an update mid-append keeps its socket, finishes its
4660
+ decision and writes its response, and only then is `deleteWebhook` called and
4661
+ the stopped line printed. A second Ctrl-C during the drain is absorbed rather
4662
+ than killing the process, and a request that never finishes is dropped after ten
4663
+ seconds, because a stop has to end. `deleteWebhook` on that path is given five
4664
+ seconds rather than the channel's usual thirty, for the same reason: a stop an
4665
+ operator has asked for twice should not sit behind an unreachable Bot API, and
4666
+ a removal that does not land is reported as a webhook still registered.
4667
+
4668
+ ### The daemon id it stamps (APRV-448)
4669
+
4670
+ Every record this process appends (a decision a tap delivers, the refusal of
4671
+ one, anything a dispatch cycle writes) carries the `daemon` field, with the id
4672
+ `approval status` reports for the same store: `APPROVAL_DAEMON_ID` from the
4673
+ launch environment, or `daemon-` plus the instance id derived from the log's
4674
+ `.approval` directory, which is the id the daemon loop derives. The started line
4675
+ names it (`records stamped daemon <id> (<source>)`) and `webhook_started`
4676
+ carries it as `daemon`. A declared id that is not a usable id refuses the verb
4677
+ before the bot is claimed or a port is bound, exit 2.
4678
+
4679
+ The attested policy's `daemons` list governs this process at the write
4680
+ boundary, as it does the daemon and [`approval serve`](#serve). It is re-read at
4681
+ startup and at the top of every dispatch cycle, which is this process's tick: a
4682
+ list a human narrows and re-attests takes effect within one cycle (a cycle runs
4683
+ after every handled update and every `--cycle`), and a resolution that fails
4684
+ leaves the previous list standing. A tap whose decision is refused
4685
+ `daemon-not-allowed` appends nothing, and the refusal is reported on stderr. The
4686
+ process declares the id without marking itself the daemon.
4687
+
4688
+ ```json
4689
+ {"event":"webhook_started","url":"https://gate.example/telegram/<path redacted>",
4690
+ "host":"127.0.0.1","port":4683,"path":"/telegram/<path redacted>","cycle_ms":30000,
4691
+ "daemon":"daemon-3f2a9c11"}
4692
+ {"event":"stopped","notified":1,"updates":1,"decisions":1,"pollErrors":0,
4693
+ "anomalies":{"foreign-chat":0,"malformed-callback":0,"unknown-callback":0,
4694
+ "key-mismatch":0},
4695
+ "webhook":{"requests":2,"updates":1,"refusals":{"webhook-secret-mismatch":1}}}
4696
+ ```
4697
+
3545
4698
  ## channel telegram health
3546
4699
 
3547
4700
  Makes no network call. A health check that contacted the Bot API would announce
@@ -3555,6 +4708,194 @@ belong to a RUNNING listener: they are on its stderr as they happen, in its
3555
4708
  Which variables are read comes from the policy, so a renamed variable reads back
3556
4709
  as the name you set.
3557
4710
 
4711
+ ## channel relay
4712
+
4713
+ A third arrival for a human gesture, beside a Telegram tap and a terminal
4714
+ (APRV-455). An operator's control plane holds an authenticated session for a
4715
+ person (the Agent Village control plane holds an EdgeOS session for each
4716
+ resident), shows them a request or their policy, and posts what they did to
4717
+ this verb. The post carries the gesture, the EdgeOS `/humans/me` id of the
4718
+ session that made it, the policy hash or the action key it answers, a nonce and
4719
+ an `issued_at`. The daemon resolves the id against the attested policy exactly
4720
+ as it resolves a Telegram `from.id`, and records the gesture through the same
4721
+ `recordChannelDecision` a tap goes through.
4722
+
4723
+ ```
4724
+ approval channel relay [--listen [host:]port | --port <n>] [--allow-non-loopback]
4725
+ [--proposer agent:<id>] [--policy <p>] [--dir <p>]
4726
+ [--log <p>] [--json]
4727
+ ```
4728
+
4729
+ ### Trust level
4730
+
4731
+ **The daemon trusts the relay's attribution.** A post that carries the relay
4732
+ secret is the operator's control plane reporting which EdgeOS account acted,
4733
+ and the daemon believes that report the way it believes the Telegram Bot API's
4734
+ report of `callback_query.from.id`. That is operator trust, stated in
4735
+ APRV-422's words: no hosted service has authority over decisions. The relay
4736
+ carries a person's gesture and decides nothing. Which approver the account is,
4737
+ whether that approver may decide this request or attest this policy, and
4738
+ whether the request is still answerable are all decided by the runtime, from
4739
+ the verified log and the attested policy, with the same codes a tap gets.
4740
+
4741
+ What the relay cannot do, by construction:
4742
+
4743
+ - **Act as anyone of its own.** It takes no `--as` and reads no
4744
+ `APPROVAL_HUMAN`. Every gesture is attributed to the account
4745
+ `approvers.<id>.senders.edgeos` maps, or refused `sender-unmapped` with one
4746
+ `audit.decision_refused`. A policy that maps no EdgeOS account refuses every
4747
+ gesture the same way; there is no fallback to a configured identity.
4748
+ - **Read a mapping nobody has attested.** Every gesture, decisions included,
4749
+ resolves its sender against the policy IN FORCE: the verified bytes the
4750
+ latest attestation names, recovered from the payload store. A mapping
4751
+ sitting unattested on disk (the relay's own `propose` puts one there) decides
4752
+ nothing until a human attests it. Where the in-force bytes cannot be
4753
+ recovered, a decision is refused `policy-not-attested` (an attestation,
4754
+ `attest-requires-terminal`) and nothing is appended; a log that cannot be
4755
+ read keeps its own code.
4756
+ - **Choose who may sign for a policy change.** An attestation is resolved
4757
+ against the policy IN FORCE, never the one being attested, so an account a
4758
+ proposal adds or repoints cannot accept the proposal that names it, and an
4759
+ amendment that introduces the EdgeOS mapping is refused
4760
+ `attest-requires-terminal` (the operator bootstrap of APRV-449 is what maps
4761
+ the resident the first time).
4762
+ - **Hold a token.** A grant that mints a token for the deciding surface has it
4763
+ dropped unread; the response says only `token_issued`. Requests in the Agent
4764
+ Village are harness-executed or sealed, so none is lost; a manual-delivery
4765
+ request granted here has a token nobody holds and is asked again.
4766
+ - **Be reached through `approval serve` or `approval mcp serve`.** It is its
4767
+ own process, port and credential. The registry marks it `human_only`, so no
4768
+ wrapper publishes it, and neither serve credential reaches it.
4769
+ - **Be started by an agent.** The attribution a relay records is exactly as
4770
+ strong as control over who launches it, because its launcher chooses the
4771
+ secret. The hook classifier holds `approval channel relay` as `policy.core`
4772
+ (human-only in the reference policy), so an agent session cannot start one; a
4773
+ deployment starts it from its own launcher as the store's user. A keyed
4774
+ `senders.edgeos` mapping adds a second lock: a process without
4775
+ `APPROVAL_SENDER_KEY` cannot resolve any account.
4776
+
4777
+ ### The secret
4778
+
4779
+ `APPROVAL_RELAY_SECRET` is REQUIRED and is read from the launch environment,
4780
+ never from a file in the tree (SPEC.md §11.1 invariant 7). At least 24
4781
+ characters of `A-Z a-z 0-9 _ -` (generate it: `openssl rand -hex 32`), with the
4782
+ same value given to the control plane; surrounding whitespace is stripped at
4783
+ start, so the control plane sends the trimmed value. Under the `APPROVAL_`
4784
+ prefix it is withheld from every child `approval run` spawns. The control plane sends it
4785
+ in `x-approval-relay-secret`; it is compared in constant time over SHA-256
4786
+ digests, BEFORE the path, the method or the body, and a duplicate header is
4787
+ refused rather than resolved. **A refusal never reaches the gate and appends
4788
+ nothing to the log**: it is counted, written to stderr, and answered with its
4789
+ code. The secret appears in no response, no log record and no stderr line.
4790
+
4791
+ Loopback by default (port 4684). There is no TLS here; a routable bind takes
4792
+ `--listen <host:port> --allow-non-loopback` and prints a banner every start.
4793
+
4794
+ ### The body
4795
+
4796
+ `POST /relay/gesture`, one JSON object, closed: an unknown field is refused, so
4797
+ nothing that looks like authority (an `actor`, a second sender, a `ts`) rides
4798
+ along on a valid post.
4799
+
4800
+ ```json
4801
+ {"gesture":"propose","policy_sha256":"<64 hex>","nonce":"<16..128 of A-Za-z0-9_->","issued_at":"2026-10-03T12:00:00Z"}
4802
+ {"gesture":"attest","policy_sha256":"<64 hex>","sender":{"channel":"edgeos","id":"<EdgeOS id>"},"nonce":"...","issued_at":"..."}
4803
+ {"gesture":"decline","policy_sha256":"<64 hex>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
4804
+ {"gesture":"grant","action_key":"<the request's key>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
4805
+ {"gesture":"reject","action_key":"<the request's key>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
4806
+ ```
4807
+
4808
+ - `sender.channel` is `edgeos` and nothing else. `sender.id` is the raw
4809
+ `/humans/me` id: an ASCII letter or digit, then up to 127 letters, digits,
4810
+ `.`, `_` or `-`, the grammar `senders.edgeos` pins. Never a digest (a keyed
4811
+ policy is resolved with `APPROVAL_SENDER_KEY` in the relay's environment) and
4812
+ never an email address.
4813
+ - `issued_at` is RFC 3339 in UTC ending in `Z`, within five minutes of the
4814
+ relay's clock either side. It is used for that window and recorded nowhere;
4815
+ every record's `ts` is the runtime's own.
4816
+ - `nonce` is claimed before the gesture is applied, in a ledger of exclusively
4817
+ linked files under the gate's `.approval/daemon/relay-nonces/`, kept for eleven
4818
+ minutes. A replay is refused `relay-nonce-replayed` at this process, after a
4819
+ restart, or at a second relay on the same gate. A retry is a new post with a
4820
+ new nonce, and the gate answers a repeated decision `already-decided`.
4821
+
4822
+ `propose` is the control plane's half of the onboarding review. It writes the
4823
+ rendered policy into the store, then posts its hash: the relay re-hashes the
4824
+ file and refuses `relay-policy-mismatch` when the bytes are not the ones named,
4825
+ then appends a `policy.proposed` under `--proposer` (an `agent:` id, default
4826
+ `agent:edgeos-relay`: the relay is a machine, and a `human:` proposer would say
4827
+ a person proposed what nobody typed) with the semantic diff against the
4828
+ verified in-force text and a deadline one hour out, after which the prompt
4829
+ retires from every queue. A retry of an open proposal of the same bytes returns it with
4830
+ `"existing":true`. It also admits bytes ALREADY in force (`"reaffirm":true`):
4831
+ the review most residents make is "accept, unchanged", and that acceptance is
4832
+ still their attestation. A reaffirmation changes the attester of record and
4833
+ nothing else. The prompt also reaches Telegram, where the resident's tap
4834
+ answers it equally.
4835
+
4836
+ **Until a proposal is attested, the bytes on disk are not the policy in
4837
+ force.** Every gated call refuses `policy-not-attested` in that window (a
4838
+ grant through the relay included), exactly as during any amendment. So the
4839
+ control plane proposes and collects the acceptance in the same screen, and
4840
+ restores the in-force bytes when the resident declines or the proposal lapses.
4841
+ A `decline` appends `policy.declined` and leaves the attestation in force
4842
+ where it was, the operator's included: it is a refusal to adopt the proposed
4843
+ bytes, never a withdrawal of the policy already attested.
4844
+
4845
+ `attest` / `decline` answer the open proposal of that hash. `attest` appends
4846
+ `policy.updated` with `actor: human:<approver>` and `payload.sender
4847
+ {channel: edgeos, id}`. A hash that names no open proposal (superseded,
4848
+ answered, never proposed) is refused `proposal-not-found`, and a file changed
4849
+ under the proposal is refused `proposal-stale`; neither attests anything.
4850
+
4851
+ `grant` / `reject` answer a pending request exactly as a Telegram tap does,
4852
+ including `expired`, `policy-drift` (with its `audit.decision_refused` and
4853
+ withdrawal), `already-decided` and `actor-not-approver`.
4854
+
4855
+ ### Responses
4856
+
4857
+ | status | body | meaning |
4858
+ | --- | --- | --- |
4859
+ | 200 | `{"ok":true,"gesture":…,"event":…,"seq":…,"actor":…}` | recorded (`propose` returns `sha256`, `seq`, `existing`, `reaffirm`, `changes`, `loads`) |
4860
+ | 409 | `{"ok":false,"gesture":…,"refusal":{"code","message"}}` | the gate or the decision surface refused; the code is from `gate_refusal_codes` or `channel_decision_refusal_codes` |
4861
+ | 4xx/5xx | `{"error":{"code","message"}}` | the relay refused the post; the code is from `relay_refusal_codes`, and nothing was appended |
4862
+
4863
+ `relay_refusal_codes` (frozen, pinned in `conformance/vectors/refusal-unions.v1.json`):
4864
+ `relay-secret-mismatch`, `relay-duplicate-secret-header` (401),
4865
+ `relay-malformed-request` (400), `relay-unknown-path` (404),
4866
+ `relay-method-not-allowed` (405), `relay-body-too-large` (413),
4867
+ `relay-body-unreadable`, `relay-body-invalid`, `relay-sender-invalid`,
4868
+ `relay-gesture-stale` (400), `relay-nonce-replayed` (409),
4869
+ `relay-nonce-unavailable` (503, the ledger could not record the nonce, so
4870
+ nothing was decided), `relay-policy-mismatch` (409), `relay-handler-failed`
4871
+ (500).
4872
+
4873
+ Start-up refusals (exit 2, `{"error":{"code"}}` under `--json`):
4874
+ `relay-secret-missing`, `relay-secret-weak`, `relay-secret-charset`,
4875
+ `relay-proposer-invalid`, `relay-bind-invalid`. An unreadable log exits 4, a
4876
+ torn tail 3, a corrupt chain 1.
4877
+
4878
+ With `--json`, stdout carries one object per line: `relay_started` (`host`,
4879
+ `port`, `path`, `proposer`), one `relay_gesture` per gesture that reached the
4880
+ gate (`gesture`, `ok`, and `seq`/`record`/`actor` or `code`), and `stopped`
4881
+ with the counters. Never a body, a sender id or the secret.
4882
+
4883
+ There is no transport lease. A Telegram bot admits one receiver; a relay has no
4884
+ such constraint, because compare-and-append, the gate's idempotency and the
4885
+ nonce ledger (each claim written whole and linked into place, so exactly one
4886
+ claimant wins) make two relays on one gate safe. A second relay on the same
4887
+ port fails to bind.
4888
+
4889
+ **Who is listening on the port is the caller's check.** The secret travels in a
4890
+ header, so whoever holds the loopback port when the control plane posts
4891
+ receives it. In a sandbox where another uid can bind loopback (the Agent
4892
+ Village's `hermes` user), the control plane checks that the listener on 4684
4893
+ belongs to the relay's own user before it posts, the same foreign-listener
4894
+ rule the hook applies to `serve`, and posts to the literal `127.0.0.1`, never
4895
+ `localhost`, which another uid could answer on `[::1]`. A unix socket in a directory only the relay's
4896
+ user can write, or a signature over the body in place of a bearer secret, would
4897
+ remove the check; neither is in this version.
4898
+
3558
4899
  ## quickstart
3559
4900
 
3560
4901
  `approval quickstart [--dir <path>] [--api-base <url>]` is the human-only solo setup ceremony. It
@@ -3605,17 +4946,20 @@ code. A re-run in a scaffolded directory writes nothing and exits 0. A directory
3605
4946
  carrying `APPROVALS.md` (the SPEC.md §5 fallback filename) already has a policy:
3606
4947
  init reports `policy-exists` and writes no `APPROVAL.md` beside it.
3607
4948
 
3608
- Payloads are tracked. `.approval/payloads/` is deliberately not ignored: those
3609
- bytes are what each approval bound to, and evidence belongs in the history. To
3610
- ignore them instead, add `.approval/payloads/` yourself — the log keeps every
3611
- `payload_hash`, but the bytes behind them stop being rebuildable.
4949
+ Payloads are ignored (APRV-445; they were tracked before it). `.approval/payloads/`
4950
+ holds the exact bytes each approval bound to, and for a proposal those are a
4951
+ person's own words, so the merged block lists it. To keep the bytes in history
4952
+ as evidence instead, remove that line yourself; the log keeps every
4953
+ `payload_hash` either way. Files in the store are written 0600 in a 0700
4954
+ directory whatever the umask.
3612
4955
 
3613
4956
  Keys are not. The merged block ignores `.approval/*.sqlite` (a projection),
3614
4957
  `.approval/vault.enc`, `.approval/env`, `.approval/keys/`, `.approval/**/*.tmp-*`
3615
4958
  and `.approval-journal/`. `.approval/keys/` is the one that matters most and is
3616
4959
  easiest to miss: it holds the X25519 private halves of sealed token delivery, and
3617
- because the payload store beside it is tracked on purpose, `.approval/` is a
3618
- directory people `git add` from. A key committed there opens that action's
4960
+ because the payload store beside it was tracked by default before APRV-445 (and
4961
+ still is wherever an operator chose that), `.approval/` is a directory people
4962
+ `git add` from. A key committed there opens that action's
3619
4963
  `token_sealed` for everyone holding the log. The line is written whether or not
3620
4964
  your policy has opted into `token_delivery: sealed`, and `approval doctor`'s
3621
4965
  `sealed-keys` row reports a key that is tracked or in a store no line covers.
@@ -3647,6 +4991,95 @@ A HUMAN commits those files: they are `policy.edit`. `docs/agent-sdk-hook.md`
3647
4991
  is the third caller: a Python Agent SDK application has no settings file, so it
3648
4992
  spawns this same verb from a hook callback (APRV-242).
3649
4993
 
4994
+ **`hook grok` is the one exception to the exit codes above (APRV-243).** Grok
4995
+ Build reads exit 2 as the deny and exit 0 as the allow, whatever stdout said,
4996
+ so on that harness alone a deny is exit 2 with `{"decision":"deny","reason":…}`
4997
+ on stdout, and the post-execution event exits 0 in every case rather than
4998
+ using 2 for visibility. Its envelope is camelCase (`toolName`, `toolInput`,
4999
+ `sessionId`, `hookEventName`, plus a `workspaceRoot` this runtime ignores in
5000
+ favour of `cwd`); snake_case is still read and wins when both spellings are
5001
+ present. `approval hook grok --help` prints the `.grok/hooks/pre-tool-use.json`
5002
+ the human commits, and that entry's own `timeout` must exceed `--timeout`.
5003
+ Grok Build FAILS OPEN on hook timeout, crash and malformed output, with no
5004
+ setting to change it, which the fail-closed invariant does not survive;
5005
+ `docs/grok-hook.md` states which cases the adapter cannot cover and is worth
5006
+ reading before the file is committed.
5007
+
5008
+ **`hook muse` emits exactly ONE dialect and nothing else (APRV-350).** Muse Code
5009
+ treats output carrying any key it does not support as a failed hook, and a failed
5010
+ hook fails OPEN, so the belt-and-braces payload that satisfies several harnesses
5011
+ at once satisfies this one not at all. Its envelope is snake_case with its own
5012
+ tool names (`bash` carrying a per-call `workdir`, `write_file`, `read_file`, and
5013
+ `search` naming an ARRAY under `paths`), and a Contributor-tier model is refused
5014
+ for every tool call regardless of policy (`hook-muse-contributor-model`).
5015
+ `approval hook muse --help` prints the `.muse/hooks.json` the human commits;
5016
+ `docs/muse-hook.md` opens with the fail-open finding and the model warning.
5017
+
5018
+ **`hook hermes` is the second exception to the exit codes above, and the one
5019
+ harness whose configuration is not in the repository (APRV-398).** Its event
5020
+ names are its own — `pre_tool_call` and `post_tool_call`, not `PreToolUse` — and
5021
+ its envelope is snake_case (`tool_name`, `tool_input`, `session_id`, `cwd`,
5022
+ `profile`, `extra`). A deny is `{"action":"block","message":…}` **at exit 2**,
5023
+ which Hermes treats as an unconditional block whose message it takes from the
5024
+ stdout directive first; an **allow is `{}`** at exit 0, because Hermes has no
5025
+ allow directive and no directive is the allow, so an allow's reason goes to
5026
+ stderr. `execute_code` is refused outright
5027
+ (`hook-hermes-execute-code-unbound`): it carries a program and no path, no argv
5028
+ and no working directory, so no verdict could bind it. **A call whose effective
5029
+ directory the event does not carry is refused too** (`hook-unsupported-execution-context`,
5030
+ APRV-415): a live probe found that the envelope `cwd` is the Hermes PROCESS
5031
+ directory while `terminal` keeps a per-session recorded directory that a `cd` moves
5032
+ and no field reports, and that the file tools resolve relative paths against that
5033
+ one — so a `terminal` call with no absolute `workdir`, and a `write_file`, `patch`,
5034
+ `read_file` or `search_files` call whose path is relative or missing, are refused
5035
+ with a reason telling the session to retry with an absolute path. The file a human
5036
+ commits is `$HERMES_HOME/config.yaml` — name the home explicitly and spell its last
5037
+ segment `.hermes`, or the classifier does not recognise the organ — where the event
5038
+ is a mapping KEY rather than an `event:` field, `fail_closed: true` belongs on every
5039
+ entry because its default is false, and TWO timeouts bound the wait: the per-entry
5040
+ `timeout` caps at 300s and `plugins.hook_callback_timeout` defaults to 30s and
5041
+ fails closed by itself, so `--timeout` must come down under 300s. `--dir` is
5042
+ mandatory here, because a gateway session's `cwd` is the user's home. Without
5043
+ `hooks_auto_accept: true` (or `HERMES_ACCEPT_HOOKS=1`) and no TTY, Hermes
5044
+ **silently never registers the hook**. `fail_closed: true` was OBSERVED to block a
5045
+ hook crash, a hook timeout and unparseable output on `main` `118984d7`, which makes
5046
+ this the first adapter since Claude Code that is a gate rather than a backstop;
5047
+ `v0.21.3` (build 2026.9.14) does not know the key and fails open silently, so
5048
+ `approval doctor`'s `harness-version-unverified` row pins that floor.
5049
+ `approval hook hermes --help` prints the YAML; `docs/hermes-hook.md` opens with the
5050
+ fail-closed result, the floor, and what a post event does not prove.
5051
+
5052
+ **`--harness-cap <duration>` makes the question end before its asker does
5053
+ (APRV-423).** Every harness in the table kills the hook at some ceiling (Hermes
5054
+ caps a `pre_tool_call` entry at 300s, Claude Code kills it at the `timeout` in
5055
+ its settings file), and every one of them reads the killed process as a
5056
+ non-blocking error and runs the tool call. Before this flag the request that
5057
+ hook had opened stayed pending afterwards, so a tap arriving later was recorded
5058
+ as a plain `approval.granted` on a call nobody was holding, and the deny and the
5059
+ grant described one request and disagreed about it (APRV-410).
5060
+
5061
+ The flag states the ceiling. The hook records it on `approval.requested` as
5062
+ `payload.harness_cap_ms`, and the runtime judges the request against the SHORTER
5063
+ of the policy's `defaults.approval_ttl` and that ceiling minus a 60s margin
5064
+ (`HARNESS_CAP_MARGIN_MS` in `src/core/harness-wait.ts`, two of the daemon's 30s
5065
+ sweep intervals, so the `approval.expired` record lands while the harness is
5066
+ still listening in the common case; a skipped sweep or a slow intake can put it
5067
+ after the kill, and what keeps a late tap safe then is the gate's lazy `expired`
5068
+ refusal, which judges the lapse by arithmetic whether or not the record exists).
5069
+ A cap can only SHORTEN: the runtime takes a minimum, so a stated ceiling longer
5070
+ than the TTL changes nothing, which is SPEC.md §11.1 invariant 4 applied to a
5071
+ duration. Without the flag, `hook hermes` assumes **30s**, Hermes's default
5072
+ `plugins.hook_callback_timeout`, which does not clear the margin: every
5073
+ manual-class call is refused `hook-harness-cap-too-short` until the operator
5074
+ raises `plugins.hook_callback_timeout` above the per-entry `timeout` and passes
5075
+ `--harness-cap` with the smaller of the two (`--harness-cap 300s` for the
5076
+ documented `600` over `300`). A stated cap is still clamped to Hermes's observed
5077
+ 300s per-entry maximum. No other adapter assumes a ceiling, because no other
5078
+ harness documents one. A ceiling at or below the margin leaves no window for a
5079
+ human and is refused `hook-harness-cap-too-short` before anything is registered
5080
+ or requested. `approval serve` pins the same value on every hook call it makes
5081
+ with `--hook-harness-cap`, as it pins `--timeout` with `--hook-timeout`.
5082
+
3650
5083
  **Register the same command for the post-execution event too (APRV-145).** One
3651
5084
  binary answers two events, dispatched on `hook_event_name`. A `PostToolUse` or
3652
5085
  `PostToolUseFailure` run closes the delegated `execution.started` the
@@ -3679,8 +5112,42 @@ gate.self the "approval" CLI itself is pass-through
3679
5112
  Bash (Claude Code) and Shell (Cursor) commands are classified into SPEC.md §7
3680
5113
  action classes. A `git push` that names `refs/tags/*`, a bare `v`-prefixed
3681
5114
  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
5115
+ `release.publish`; force and mirror pushes remain `vcs.history.rewrite`; a push
5116
+ that DELETES a remote ref — `--delete`, `-d`, or a colon refspec such as
5117
+ `:refs/heads/x` — is `vcs.ref.delete` with the ref names bound (APRV-352), and a
5118
+ tag deletion keeps `release.publish`; ordinary branch pushes retain their branch
5119
+ or trunk class.
5120
+
5121
+ `git tag` splits on its flags since APRV-397: a LISTING (`-l`, `--list`, `-n`,
5122
+ `-n<num>`, `--contains`, `--points-at`, `--merged`, `--sort`, `--format`,
5123
+ `--color`, `--ignore-case`, `--omit-empty`, or no argument at all) is
5124
+ `read.shell`, rule `git-tag-read`, the same class `git log`, `git status` and
5125
+ `git branch` take for reading local repository metadata. Everything else stays
5126
+ `release.publish`: `-a`, `-d`, `-f`, `-s`, `-m`, a bare tag name, and any flag
5127
+ the listing allowlist does not name, so a future `git tag` option cannot arrive
5128
+ as a read. A positional under `-l` is a pattern rather than a name.
5129
+
5130
+ The packaging and archive tools are classified too, for the same reason and in
5131
+ the same release (APRV-397): they were `unclassified`, so a verification could
5132
+ not run them and reached for `curl` and a script instead. `npm pack` is a
5133
+ workspace write of the tarball, scoped to `--pack-destination`, and
5134
+ `network.call` when a positional names a registry package; `npm init` writes
5135
+ `package.json`; a bare `npm --version`, `-v`, `-V`, `--help` or `-h` is a read,
5136
+ while every npm subcommand the table does not name keeps its `unclassified`
5137
+ deny; `tar -t` lists (a read) and `tar -x`/`-c` writes into `-C` or the archive
5138
+ `-f` names; `gunzip` reads only with `-c`/`--stdout`, `-t`/`--test` or
5139
+ `-l`/`--list` and otherwise replaces the file it names; `base64` reads unless
5140
+ `-o` names an output;
5141
+ `openssl dgst` (and the `md5`/`sha*` spellings) reads unless `-out` names one,
5142
+ while every other `openssl` subcommand stays unclassified. Each write is scoped
5143
+ by the arithmetic `rm` uses: a destination under a resolved scratch root or a
5144
+ relative one is `files.write.workspace`, and an absolute destination elsewhere, a
5145
+ `..` segment, an unreadable value, or a destination flag with nothing readable
5146
+ after it is `files.delete.out_of_scope`, rule `packaging-write-out-of-scope`,
5147
+ with the destination bound. A `tar` whose mode is not in its words is
5148
+ `hook-opaque`. `docs/claude-code-hook.md` holds the table and the reasoning.
5149
+
5150
+ Claude file tools
3684
5151
  (Edit, Write, MultiEdit, NotebookEdit) and
3685
5152
  Cursor Write/Delete are gated only when the file is policy-protected
3686
5153
  (`APPROVAL.md`, `.approval/`, `CLAUDE.md`, `AGENTS.md`, `.claude/settings*`,
@@ -3698,13 +5165,27 @@ its own flags are not parsed as this verb's.
3698
5165
  no human is asked. This union's spelling of the gate's `class-human-only`,
3699
5166
  which the detail names in full. The opposite repair to `hook-unclassified`:
3700
5167
  that one says declare a class, this one says a person runs the command.
5168
+ - `hook-harness-launch-unruled` — some class of the command is in the
5169
+ `harness.launch.*` family and this policy names no rule for it (APRV-354).
5170
+ The family resolves only under an explicit rule, `harness.launch.*` or
5171
+ `harness.launch.NAME`, and never under `defaults.autonomy`, because a grant
5172
+ of the class covers the launch and nothing the launched session then does.
5173
+ Distinct from `hook-unclassified` (the classifier had nothing to say; here it
5174
+ was clear and the policy is silent) and from `hook-class-human-only` (the
5175
+ policy has spoken and reserved the class; the repair there is for a person to
5176
+ run the command, and here it is to write a line).
3701
5177
  - `hook-opaque` — a construct whose effect cannot be read from the text
3702
- (`bash -c`, `eval`, backticks, a non-read substitution).
5178
+ (`eval`, `xargs`, backticks, a non-read substitution). A login shell around
5179
+ ONE inline script is classified by that script since APRV-380, so
5180
+ `zsh -lc 'git push origin main'` is `vcs.push.main`; a script file, an extra
5181
+ word, a redirection on the wrapper, an assignment prefix and a nested shell
5182
+ all stay opaque.
3703
5183
  - `hook-unparseable` — the command line could not be tokenized.
3704
5184
  - `hook-rejected` — a human said no.
3705
5185
  - `hook-revoked` — a granted approval was withdrawn.
3706
5186
  - `hook-expired` — the TTL lapsed before a decision.
3707
- - `hook-timeout` — no decision inside `--timeout`; the request stays live.
5187
+ - `hook-timeout` — no decision inside the wait (`--timeout`, clamped to what
5188
+ `--harness-cap` leaves after the 60s margin; APRV-423); the request stays live.
3708
5189
  - `hook-gate-refused:<c>` — the gate refused intake; `<c>` is its own frozen code
3709
5190
  (`policy-not-attested`, `budget-exceeded`, …).
3710
5191
  - `hook-policy-unavailable` — `APPROVAL.md` could not be loaded.
@@ -3778,8 +5259,10 @@ importer collects the bullets under those headings into a second draft fence,
3778
5259
  ` ```yaml approval-values ` (SPEC.md §5.3), printed after the policy draft on
3779
5260
  stdout and written after it with `--out`; `--json` carries it as
3780
5261
  `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
5262
+ `like`, the middle grade, and none in `love` or `dislike`: how strongly a line
5263
+ is meant is the human's to say, and an importer that reached for the strongest
5264
+ grade would be putting words in their mouth. (`like` is where what an operator
5265
+ asks for lives since APRV-336 folded `wants` into it.) A
3783
5266
  bullet over the schema's 200 characters is truncated with a warning rather than
3784
5267
  dropped, and bullets past the twentieth are kept as comments inside the fence,
3785
5268
  which is the same stance the permissions half takes on unmapped bullets. The
@@ -3911,6 +5394,19 @@ in the file said what the operator wanted the work to be like. The optional
3911
5394
  ` ```yaml approval-values ` block (SPEC.md §5.3) is that, and this verb prints
3912
5395
  it.
3913
5396
 
5397
+ **The keys.** `version`, the quoted string `"0.2"` and the only required one;
5398
+ the standing grades `love`, `like` and `dislike`, printed as `loves:`, `likes:`
5399
+ and `dislikes:`; and `communication`, one sentence or two on how the operator
5400
+ reads and answers, printed under `communication:`. APRV-336 settled that shape:
5401
+ a `wants` list for what the operator asks of an agent folded into `like`, since
5402
+ a request about behaviour and a preference about the output are graded by the
5403
+ same person in the same way and one list is easier to keep true, and `responds`
5404
+ became `communication`, which no longer reads as a sibling of `approval
5405
+ feedback`. A block written to the earlier format (`version: 1`, a `wants` list)
5406
+ is refused with the code `version-unsupported` and a message naming both edits,
5407
+ rather than with a schema violation a reader has to decode. The same code
5408
+ answers `version: 0.2` written without quotes, which YAML reads as a float.
5409
+
3914
5410
  **It is guidance, and it is never policy.** Every output form opens with the
3915
5411
  banner saying so, `--json` carries the same sentence in `note`, and the reason
3916
5412
  is the same discipline `journal read` applies in the opposite direction: a
@@ -4055,6 +5551,57 @@ defined over the canonical VALUE, so non-JSON input has no defined
4055
5551
  could reproduce. Empty input is the same answer. A file that exists but cannot be
4056
5552
  read is exit 4.
4057
5553
 
5554
+ ## payload run
5555
+
5556
+ The payload `approval run` will recompute for a command, printed before any
5557
+ approval exists: its argv, the cwd it will run in, and the size and SHA-256 of
5558
+ the script that argv names.
5559
+
5560
+ ```
5561
+ approval payload run -- bash scripts/install.sh
5562
+ {"argv":["bash","scripts/install.sh"],"cwd":"/repo",
5563
+ "script":{"argv_index":1,"path":"/repo/scripts/install.sh","bytes":412,
5564
+ "sha256":"fef0…"}}
5565
+ ```
5566
+
5567
+ The `script` object is the whole point of the verb (APRV-401). A grant over
5568
+ `bash scripts/install.sh` used to bind that STRING, so the bytes behind the path
5569
+ were whatever the file held at execution time and the requester controlled the
5570
+ file between the request and the grant. The digest is inside the hashed value,
5571
+ so a script edited after the declaration is refused `payload-mismatch` before the
5572
+ child is spawned and before anything is appended, and the approver's card carries
5573
+ the path, the byte count and the digest because the payload does.
5574
+
5575
+ The argv names a script in two shapes: a known interpreter (the six shells,
5576
+ `node`, `python`/`python3`, `perl`, `ruby`, `deno` — each a name the command
5577
+ classifier already knows) followed by a path operand, or a path at `argv[0]` the
5578
+ kernel reads a shebang from. An inline program (`bash -c …`, `node -e …`) is
5579
+ already a word of the argv and binds no file.
5580
+
5581
+ An argv naming no readable script carries no `script` key and hashes exactly as
5582
+ it did before the rule existed, which is why every record already in a log and
5583
+ every declaration already written into a task file still verifies.
5584
+
5585
+ `--hash` prints the `payload_hash` instead of the bytes — the value a task file's
5586
+ action declaration carries. `--cwd` states the directory the run will happen in,
5587
+ because that directory is inside the hash and every relative argv word resolves
5588
+ against it. The two ends must agree, so pass the same `--cwd` the run will use.
5589
+
5590
+ What is NOT bound is stated in full in `docs/run-payload-binding.md`: nothing
5591
+ resolved through `PATH`, nothing a bound script itself reads or executes, and the
5592
+ instant between this hash and the spawn.
5593
+
5594
+ This verb reads the one file the argv names as its script and nothing else. No
5595
+ log, no policy, no network, no token, and it executes nothing.
5596
+
5597
+ It is **local only**: the MCP wrapper and `approval serve` withhold it, because
5598
+ the path it digests is one of the command's own words and no transport guard
5599
+ confines those the way the store confinement confines `payload hash`'s
5600
+ positional. A remote caller could otherwise ask for the digest of any file the
5601
+ server process can read (`-- bash /etc/shadow`) and learn that the path exists
5602
+ and what its bytes fingerprint to. Compute the binding where the command will
5603
+ run, which is the only place the value is true.
5604
+
4058
5605
  ## payload agentmail-draft
4059
5606
 
4060
5607
  An AgentMail draft is mutable server-side state, so an approval of a draft id
@@ -4191,7 +5738,22 @@ the clock starts when the daemon starts), and at a clean shutdown when records
4191
5738
  are still owed. Every attempt goes through the gate as `agent:daemon`: the cycle
4192
5739
  registers, requests, and proceeds only where the policy lets it, so a
4193
5740
  `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
5741
+ `manual`, stops it with nothing committed and the question in the queue.
5742
+
5743
+ **Which class it asks under, and which actor may advance autonomously
5744
+ (APRV-382).** Two classes, and the running process picks between them.
5745
+ `log.advance.daemon` is the daemon's own, asked only by the cadence advance
5746
+ inside `approval daemon run` and `approval up`; `log.advance` is what every
5747
+ other actor asks under, a session in a worktree and a human terminal alike. A
5748
+ policy may hold the daemon's class `autonomous` (this repository's does, since
5749
+ the advance publishes records the log already holds, appends nothing and decides
5750
+ nothing) while leaving the base class where it was. Nothing an agent can type
5751
+ reaches the looser line: `approval log advance` classifies `log.advance`
5752
+ whoever runs it. The choice is read from the process rather than from any
5753
+ argument, a policy that declares no rule for the daemon's class leaves the
5754
+ cadence gated exactly as it was, and a cycle that is NOT the daemon's whose
5755
+ class resolves `autonomous` is refused `advance-actor-not-daemon` before
5756
+ anything is appended. A gated
4195
5757
  or failed attempt is an `advance` line plus an `advance-refused` warning, and the
4196
5758
  next tick tries again — the cadence interval is the retry bound, so a refusal
4197
5759
  never loops. One records branch and ONE PULL REQUEST PER DAY: the first advance
@@ -4295,7 +5857,15 @@ says nothing about it.
4295
5857
  it, and neither is a log to append onto. A working log that is a strict prefix
4296
5858
  of the committed copy is an `anchor-behind` warning, not a stop. The `started`
4297
5859
  line names the rev and seq this run is held to (`"anchor"`), and the tick line
4298
- carries the comparison it made. Git is read, never fetched.
5860
+ carries the comparison it made. Git is read, never fetched. When the log was
5861
+ rewritten between the tick's opening read and the check's own read of the file
5862
+ (a `log sync` or a records pull replacing `events.jsonl` under the loop), the
5863
+ check reads the file again through the same verified read, reports one
5864
+ `anchor-reread` warning naming both heads, and the verdict beside it is the
5865
+ second read's (APRV-389). Every `anchor-diverged` stop is reached from the
5866
+ file's bytes and confirmed by that second read, and its message names the seq
5867
+ the chains parted at and both heads without also claiming either chain is a
5868
+ prefix of the other.
4299
5869
  - ENVELOPE DRIFT — a task file whose `state:` contradicts the log gets an
4300
5870
  `envelope.drift` event (actor `system:daemon`), once per claim.
4301
5871
  - TTL SWEEP — every live request whose TTL lapsed gets an `approval.expired`
@@ -4414,8 +5984,11 @@ Warnings go to stderr as `{"event":"warning","code":"...","message":"..."}`, wit
4414
5984
  `code` one of `task-unreadable`, `frontmatter-invalid`, `envelope-invalid`,
4415
5985
  `task-id-missing`, `tasks-dir-unreadable`, `append-refused`, `expire-refused`,
4416
5986
  `render-failed`, `watch-unavailable`, `prune-refused`, `write-back-refused`,
4417
- `advance-refused`, `draw-unavailable`. A warning never stops the
4418
- loop, and neither does `{"event":"git_evidence_failed","step":"commit",…}`.
5987
+ `advance-refused`, `dark-session-undetermined`, `anchor-behind`,
5988
+ `anchor-reread`, `checkpoint-due`, `draw-unavailable`, `sample-deferred`,
5989
+ `drift-deferred`. A
5990
+ warning never stops the loop, and neither does
5991
+ `{"event":"git_evidence_failed","step":"commit",…}`.
4419
5992
 
4420
5993
  Dangling advance cycles (APRV-264): at startup and before every trigger, the
4421
5994
  daemon lists every advance execution nobody closed and closes each one this
@@ -4440,6 +6013,73 @@ rather than inferred from the queue's backlog. `rendered` is emitted when the qu
4440
6013
  CHANGES; the file itself is rewritten every tick, because TTL countdowns move even
4441
6014
  when the log does not.
4442
6015
 
6016
+ **A sample that met another writer is deferred, not dropped (APRV-381).** The
6017
+ audit sweep's append is a compare-and-append against the head it read, so it can
6018
+ meet a lock another writer holds (`lock-timeout`) or a record another writer
6019
+ landed in between (`head-moved`). Both are reported as `sample-deferred` on
6020
+ stderr, naming the action key and saying that the sweep retries on the next tick:
6021
+
6022
+ ```
6023
+ warning sample-deferred audit sampling deferred hook:6f2a:vcs.push.main
6024
+ (lock-timeout): another writer holds .approval/log/events.jsonl.lock; gave up
6025
+ after 2000ms The sample is NOT lost: it is still pending in the log's own terms
6026
+ (eligible, drawn, no audit.sampled yet), and the sweep retries it on the next
6027
+ tick.
6028
+ audit.sampled: hook:6f2a:vcs.push.main drawn for review (execution.started at
6029
+ seq 51445) — recorded at seq 51447, the retry of the sample this run deferred
6030
+ earlier
6031
+ ```
6032
+
6033
+ The second line is the same `sampled` event with `"retry":true` added, and it
6034
+ appears only when this run is the run that deferred the sample: a restarted
6035
+ daemon still makes the append, and says nothing about a promise it did not make.
6036
+ Nothing is remembered across processes because nothing needs to be: the sweep
6037
+ re-derives the pending set (eligible, drawn by the sampler, no `audit.sampled`
6038
+ yet) from the verified log on every tick, so a deferral is lost work only if the
6039
+ daemon never runs again.
6040
+
6041
+ Every other append refusal on a sample keeps the `append-refused` form, because
6042
+ `validation`, `canonicalization`, `corrupt-tail` and `io` are facts about the
6043
+ record or the file that no amount of retrying repairs.
6044
+
6045
+ **A drift record that met another writer is deferred too (APRV-403).** The drift
6046
+ scan's append is the same compare-and-append against the head it read, and it
6047
+ met the same contention: the end-to-end test that holds the append lockfile for
6048
+ a whole tick showed the scan printing `append-refused … (lock-timeout)` for an
6049
+ `envelope.drift` the next tick went on to write. So the same split applies, from
6050
+ the same closed list of transient codes, on both drift reasons:
6051
+
6052
+ ```
6053
+ warning drift-deferred envelope.drift for task-042 was deferred (lock-timeout):
6054
+ another writer holds .approval/log/events.jsonl.lock; gave up after 2000ms The
6055
+ record is NOT lost: the disagreement is still there in the log's own terms, and
6056
+ the scan re-derives it and retries on the next tick.
6057
+ envelope.drift: task-042 (backlog/tasks/task-042.md) claims state approved, the
6058
+ log says proposed — recorded at seq 12, the retry of the record this run
6059
+ deferred earlier; the file is repaired to match the log later in this tick
6060
+ ```
6061
+
6062
+ The `retry` flag has the same standing as the sample's: it is on the `drift`
6063
+ event only when THIS run is the one that deferred the record, it is output
6064
+ bookkeeping that no append consults, and a restarted daemon makes the same
6065
+ append and claims nothing. Deferring costs nothing because the scan carries no
6066
+ state between ticks: it re-reads the folder, re-derives the state from the
6067
+ verified log, and `driftAlreadyLogged` keeps a repeat idempotent.
6068
+
6069
+ An `envelope.drift` refused for `validation`, `canonicalization`, `corrupt-tail`
6070
+ or `io` keeps the `append-refused` form it always had.
6071
+
6072
+ **The sweep waits `2000ms` for the lock and no longer, by decision.** The daemon
6073
+ is the one writer that could afford a longer wait and deliberately takes the
6074
+ default. A tick is serial, so a blocking wait here delays the expiry lines, the
6075
+ `state:` write-back and `QUEUE.md`; the writers it contends with hold the lock
6076
+ across spans no polite wait covers (`approval log advance` across a whole
6077
+ verify-and-commit, `approval log sync` across a baseline move); and the appends it
6078
+ would be muscling in front of are the ones somebody is waiting on, a hook's gate
6079
+ verdict or a lane's execution record. Deferring costs one line and at most one
6080
+ tick interval, and it is provably lossless. Raise `--interval` only if you want
6081
+ the retry later; there is no flag for the lock wait, on purpose.
6082
+
4443
6083
  ## up
4444
6084
 
4445
6085
  **Normal startup after setup and human attestation.** Run from the intended
@@ -4483,6 +6123,46 @@ Stop `up` before running Telegram setup or a standalone listener for its bot.
4483
6123
  One bot must have one polling runtime. After setup changes, reload the instance
4484
6124
  environment before starting `up` again.
4485
6125
 
6126
+ **Two refusals keep one bot to one instance (APRV-390).** Both are made before
6127
+ the daemon's first tick and before the first poll, and both exit 1 with a
6128
+ machine-readable code on stderr:
6129
+
6130
+ | code | what it means |
6131
+ | --- | --- |
6132
+ | `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. |
6133
+ | `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. |
6134
+
6135
+ `--allow-cross-instance` overrides both, starts, and prints on every run what it
6136
+ is overriding. Until APRV-390 the first of these was a warning printed on the
6137
+ way past a runtime that had already started, which is how a demo gate spent an
6138
+ evening polling the primary's bot.
6139
+
6140
+ The first refusal COMPARES VALUES rather than only reading names, because it is
6141
+ the one check that is about to use the value. That closes the case names cannot
6142
+ see: `approval env` never overrides a variable this shell has already exported,
6143
+ so a token re-stored in the keystore does not reach a terminal holding the old
6144
+ one, and the daemon answers 401 while the same line read by hand passes `getMe`.
6145
+ It also removes a false positive — an export whose value IS what the file
6146
+ resolves to is correct however it got there, so the documented `eval "$(approval
6147
+ env)"` is not a finding. `approval doctor` keeps the name-only rule; a
6148
+ diagnostic may not block on a keystore-unlock dialog. No value is printed on any
6149
+ path.
6150
+
6151
+ The `getMe` result is recorded against the instance in
6152
+ `.approval/channel-owner.json` (gitignored) and in a per-machine registry under
6153
+ the platform's user state directory, `approval/bots.json`. `APPROVAL_STATE_DIR`
6154
+ relocates that registry. A bot is identified by its id AND its Bot API base,
6155
+ because an id is unique within one deployment and nothing more: two gates
6156
+ pointed at two different `--api-base` values hold two different bots and neither
6157
+ refuses the other. Neither file is evidence, nothing reads them to widen a
6158
+ permission, and losing them costs one `getMe`. An unreachable Bot API is not a
6159
+ refusal: it says so and starts, because a captive portal is also a `getUpdates`
6160
+ that cannot conflict with anything.
6161
+
6162
+ A 409 that still happens at runtime — another machine, or a poller started
6163
+ outside this runtime — is reported once, with the instances the registry knows
6164
+ about, and its repeats are counted rather than reprinted.
6165
+
4486
6166
  **The ambient runtime: the daemon loop and every configured channel in one
4487
6167
  supervised foreground process.** `approval daemon run --with-channels` is the
4488
6168
  same verb spelled from the other side, and it reaches the same function before
@@ -4505,13 +6185,15 @@ because `git status` does not say what the upstream range changed. So the verb
4505
6185
  does all four, and `approval daemon run` runs the identical preflight from the
4506
6186
  identical module, printing the identical two lines.
4507
6187
 
4508
- It is allowed exactly three writes: a `--ff-only` merge, `npm run build`, and
6188
+ It is allowed exactly four writes: a `--ff-only` merge, `npm run build`,
4509
6189
  clearing an untracked `backlog/tasks/` file the incoming commit already contains
4510
- out of the merge's way (APRV-300, described below). It
6190
+ out of the merge's way (APRV-300, described below), and the reconcile `approval
6191
+ log sync` performs on its behalf (APRV-346, described below). It
4511
6192
  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.
6193
+ working log itself. That list is not caution for its own sake: a working
6194
+ `events.jsonl` rewound through git underneath a live appender is fork 2 of
6195
+ 2026-08-20, the incident `approval log sync` exists to prevent, which is why the
6196
+ one write that does move that file is made by that verb and by nothing here.
4515
6197
 
4516
6198
  **Safe** means both of: this checkout is not AHEAD of the remote, and no path the
4517
6199
  upstream range changes is locally modified. When it is safe, the preflight
@@ -4521,11 +6203,45 @@ running. When it is not, it refuses, and changes nothing:
4521
6203
  | code | fires when | next |
4522
6204
  |---|---|---|
4523
6205
  | `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` |
6206
+ | `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
6207
  | `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
6208
  | `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
6209
  | `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
6210
 
6211
+ **A working log that merely extends the committed one is reconciled, not
6212
+ refused (APRV-346).** Every records advance moves `origin/main`'s
6213
+ `.approval/log/events.jsonl` while the hook keeps appending locally, so
6214
+ "upstream changed the log and so did this working copy" is the normal state of
6215
+ the primary checkout after a merge. Refusing it sent the operator to `approval
6216
+ log sync` and then back to `approval up`, every time. So the collision is a
6217
+ question now: `core/log-reconcile.ts` — the same comparison `log sync` and
6218
+ doctor's `log-drift` row use — is asked how the working chain stands to the
6219
+ committed chain at the fetched tip, and:
6220
+
6221
+ - **`ahead`, `behind` or `equal`** — one chain contains the other whole, so
6222
+ adopting the longer one extends and rewinds nothing. The preflight calls
6223
+ `approval log sync` itself, which holds the append lock for its whole ceremony
6224
+ (snapshot, baseline, fast-forward, reconcile, rebuild the projections,
6225
+ post-verify), and prints one line: `synced: fast-forwarded to origin/main
6226
+ <sha>, kept K local records`. The `--json` stream carries it as a
6227
+ `preflight_sync` event and the `preflight` line's `log_synced` reads true.
6228
+ Nothing here reimplements any of that ceremony; it supplies a caller for it;
6229
+ - **`diverged`** — two appenders built different records on one predecessor.
6230
+ Hash chains do not merge, so this is the `up-preflight-log-diverged` refusal
6231
+ above, unchanged, and it is now the **only** case that needs a hand-run
6232
+ `approval log sync` (which will tell you the same thing, at more length).
6233
+
6234
+ Four conditions have to hold before the reconcile is even offered, and each of
6235
+ them answers "refuse" rather than "probably fine": the log is the repository's
6236
+ own `.approval/log/events.jsonl` (not some other file named with `--log`), no
6237
+ *other* path the upstream range touches is locally modified, both chains verify
6238
+ clean, and the relation is a prefix one. A `log sync` that refuses anyway — an
6239
+ appender that took the lock first (`log-sync-locked`), a fork that landed
6240
+ between the read and the ceremony, a git failure — comes back as the same
6241
+ `up-preflight-log-diverged` refusal with the sync's own code and sentence in
6242
+ YOUR STATE. Nothing starts, and the working log is exactly as `log sync` found
6243
+ it.
6244
+
4529
6245
  **An untracked task file no longer stops it (APRV-300).** A lane files
4530
6246
  `backlog/tasks/aprv-299` on its branch and its pull request merges, while the
4531
6247
  primary checkout holds the same path untracked from its own `backlog task
@@ -5342,6 +7058,51 @@ disables it whenever the policy names no variable, and this verb does not edit a
5342
7058
  attested policy file. It prints the block to add and the `approval policy amend`
5343
7059
  ceremony that attests it.
5344
7060
 
7061
+ ## setup sender-key
7062
+
7063
+ The operator-held key that turns a channel account id into the value an
7064
+ `approvers[id].senders` mapping carries (APRV-370). It exists for the deployment
7065
+ that PUBLISHES its policy and its log, where the raw account id is disclosed
7066
+ once in the file and then on every decision.
7067
+
7068
+ A plain unkeyed digest would not fix that. A Telegram account id is a ten-digit
7069
+ decimal number, the whole space is enumerable on a laptop, and a digest anybody
7070
+ can reverse states a privacy property it does not have. So the mapping value is
7071
+ `hmac-sha256:<hex>`, computed under this key, which is in the environment and
7072
+ never in the policy file.
7073
+
7074
+ Bare, the verb mints, stores and records the key, exactly as `setup sampling`
7075
+ does with its secret, and edits no policy file. The value is not printed and
7076
+ there is no verb that prints it.
7077
+
7078
+ With `--id <account-id>` it mints nothing and stores nothing. It reads the key
7079
+ from the environment, prints the `hmac-sha256:<hex>` for that account, and
7080
+ prints the `senders` line and a paste-ready proposal pair around it. That is the
7081
+ one thing an agent writing a policy proposal cannot do — the digest depends on a
7082
+ secret only the operator's machine holds — so the proposal page says to run it
7083
+ rather than carrying a placeholder:
7084
+
7085
+ ```sh
7086
+ approval setup sender-key # once, interactive, human-only
7087
+ eval "$(approval env)"
7088
+ approval setup sender-key --id 7345216485
7089
+ ```
7090
+
7091
+ Because it stores nothing and prints a value designed to be published, `--id` is
7092
+ the one `setup` path that runs without a terminal and accepts `--json`.
7093
+
7094
+ **The key is not an authenticator.** Nothing about the gate's safety rests on
7095
+ its secrecy: somebody who learns it learns which account ids a policy names,
7096
+ which is what the raw form told everybody. What it buys is that the published
7097
+ policy and the published log stop carrying the account.
7098
+
7099
+ **A listener that holds no key under a keyed mapping decides nothing** on that
7100
+ channel. Every tap is refused `sender-key-unavailable`, and there is no fallback
7101
+ to comparing the observed id against the raw entries: without the key the
7102
+ runtime cannot tell whether the account is also claimed by a keyed approver, so
7103
+ it cannot run the ambiguity check the mapping rests on. `approval doctor`'s
7104
+ `sender-mapping` row names the form in use and says whether the key resolves.
7105
+
5345
7106
  ## setup checkpoint
5346
7107
 
5347
7108
  Mints the Ed25519 keypair a human signs the log's head with (APRV-220's record,
@@ -5418,6 +7179,14 @@ STARTTLS, AUTH — and then QUIT. It proves the host answers, that the TLS mode
5418
7179
  the one the server offers, and that the credential is accepted. It does not prove
5419
7180
  delivery, and it puts no message on the wire.
5420
7181
 
7182
+ An `smtp.host` that is an IP address is probed without SNI (APRV-416). A server
7183
+ name is a name, so there is nothing to send for an address, and Node 26 refuses
7184
+ the session outright where earlier versions only warned. The certificate is
7185
+ still verified; what it is verified against is the address, which the server's
7186
+ certificate has to carry as an IP entry in its subject alternative names. A
7187
+ relay whose certificate names only a hostname therefore fails the probe when it
7188
+ is configured by address and passes when it is configured by that name.
7189
+
5421
7190
  A failed probe keeps the values. A laptop behind a captive portal is not a reason
5422
7191
  to make you type five things again. The refusal prints the SMTP code and the
5423
7192
  server's first line, with the credential redacted, and the undo.
@@ -5551,6 +7320,28 @@ a `human:<id>` and an `agent:` or `system:` actor is refused at exit 2. Exit 1
5551
7320
  means the far end refused: an invalid token, a 409 from a running listener, or no
5552
7321
  message reaching the bot before the deadline.
5553
7322
 
7323
+ **One bot per instance (APRV-390), and both names are derived.** The keystore
7324
+ item this verb creates is `approval-tg-token-<instance id>`, where the instance
7325
+ id is the eight hex digits `approval doctor` prints in its `keychain-scope` row,
7326
+ so two gates on one machine store two items. An operator who prefers a readable
7327
+ suffix may write their own name into `.approval/env` instead; nothing is
7328
+ migrated and `approval env --check` reports which item each variable resolves
7329
+ through. The variable is whatever the policy's
7330
+ `channels.telegram.token_env` declares; a policy that declares nothing gets
7331
+ `APPROVAL_TG_TOKEN`, which every other silent policy on the machine also gets,
7332
+ so a second gate on one machine declares a pair of its own (the packaged demo
7333
+ policy declares `APPROVAL_DEMO_TG_TOKEN` and `APPROVAL_DEMO_TG_CHAT`).
7334
+
7335
+ The `getMe` that proves the token also says which bot it is, and this verb
7336
+ records that against the instance in `.approval/channel-owner.json` (gitignored)
7337
+ and in a per-machine registry under the platform's user state directory,
7338
+ `approval/bots.json`. A bot another local instance has already claimed is
7339
+ refused before anything is written, naming that instance's directory; the values
7340
+ are open names and never a token. `--allow-cross-instance` records this instance
7341
+ as an owner anyway and says what it is doing. An instance set up before this
7342
+ existed keeps working: nothing is migrated, and the first `approval up` or
7343
+ listener start writes the record from its own `getMe`.
7344
+
5554
7345
  ## setup service
5555
7346
 
5556
7347
  **It writes one file: the launchd user agent or the systemd user unit that runs
@@ -5696,6 +7487,442 @@ The guest instructions string says so, tells the caller to poll `status`, and
5696
7487
  states plainly that a granted request executes nowhere: the demo is the approval
5697
7488
  flow itself.
5698
7489
 
7490
+ ## serve
7491
+
7492
+ `approval serve` is the same agent-facing surface as `mcp serve`, over HTTP,
7493
+ for a harness that has no local log and no local policy: a sandboxed tenant
7494
+ whose daemon runs elsewhere, one process per tenant. It is a transport and not
7495
+ a second gate. Every verdict, every verb and every batch of records comes from
7496
+ the function the CLI dispatches to; nothing under `src/serve/` classifies a
7497
+ command, resolves a policy, mints a token or verifies a chain.
7498
+
7499
+ STDOUT IS EMPTY. The bound address, the identity, the daemon id every record
7500
+ it appends carries (see [Which daemon wrote it](#which-daemon-wrote-it-aprv-448))
7501
+ and the store root go to stderr on one started line. SIGINT and SIGTERM close
7502
+ the listener and exit 0.
7503
+
7504
+ ### The two credentials
7505
+
7506
+ Two bearer values, both read from the launch environment (SPEC.md §11.1
7507
+ invariant 7: nothing is loaded out of the working tree), both required before
7508
+ the listener exists, and refused when they are equal or shorter than 24
7509
+ characters:
7510
+
7511
+ | variable | opens |
7512
+ |---|---|
7513
+ | `APPROVAL_SERVE_AGENT_TOKEN` | `GET /verbs`, `POST /hook/<harness>`, and the allowlisted verbs below |
7514
+ | `APPROVAL_SERVE_TENANT_TOKEN` | `GET /verbs`, `GET /log/follow`, `GET /export`, `GET /status`, and every other published verb |
7515
+
7516
+ **The agent credential never reads the log it is judged by.** That is the
7517
+ sentence the split exists for. An agent that could stream the verified log
7518
+ could read every decision made about it and the shape of the oversight it is
7519
+ under; an agent that could export the store could carry all of it away.
7520
+
7521
+ Authorization is therefore per verb, and it is an ALLOWLIST. The party at the
7522
+ other end of this transport is a sandboxed harness on another machine, not the
7523
+ operator at their own laptop, so the agent credential opens only what a harness
7524
+ under oversight needs in order to ASK and to ACT ON a grant:
7525
+
7526
+ > `instructions`, `hook_classify`, `request`, `wait`, `withdraw`
7527
+
7528
+ Five verbs, and the shortness is the point: a harness under oversight asks and
7529
+ is answered, and everything else about the gate belongs to the party the gate
7530
+ is for.
7531
+
7532
+ Neither payload builder is on it, under the rule that a builder is the agent's
7533
+ only if it builds from the request's own arguments and never reaches a host
7534
+ resource. `payload_hash` takes a FILE, and on this transport that file is on
7535
+ the daemon's machine while the caller is not: every path it could name is one
7536
+ the host put there, so answering would be a hash oracle that also files the
7537
+ bytes into the payload store. `payload_agentmail-draft` takes two ids rather
7538
+ than a path, but reads the draft over HTTPS with `AGENTMAIL_API_KEY` from this
7539
+ server's own environment, so a remote caller would be spending the host's
7540
+ credential on ids it chose: the same fault wearing a network instead of a
7541
+ filesystem.
7542
+
7543
+ Everything else the catalog publishes is the tenant's. The ones easiest to wave
7544
+ through and most worth naming: `log_tail` and `log_export` return records,
7545
+ `log_verify` and `gate_status` return the log's length, its head hash and the
7546
+ live state of the gate, `queue` returns the tenant's pending decisions,
7547
+ `policy_check` and `policy_test` answer what the policy does with a class and
7548
+ name the absolute store path while doing it, and `status` is the tenant's view
7549
+ of the oversight the agent is under. So are the verbs that touch the host
7550
+ rather than the log: `run` and `sandbox` spawn argv on the daemon's machine,
7551
+ every `adapter_<name>` spends vault credentials, `log_advance` opens pull
7552
+ requests against the tenant's records. And so are `register` (its positional is
7553
+ a host path; the hook endpoint is a harness's way to register, and it
7554
+ synthesises the envelope itself), the two payload builders, `token`,
7555
+ `coverage`, `doctor`, `audit_list`, `audit_obligations`,
7556
+ `channel_telegram_health`, `journal_write`, `journal_read`, `feedback`,
7557
+ `values`, `import_agents-md`, `reindex` and `render`.
7558
+
7559
+ **No caller names a path, and no positional wears a dash.** Four rules, and
7560
+ each closes a route around the others.
7561
+
7562
+ 1. The server appends `--dir`, `--log` and `--policy` to every verb call in
7563
+ every scope from its own launch configuration, and a call supplying one of
7564
+ them is refused `serve-path-pinned` even when the value is correct.
7565
+ 2. A positional beginning with `-` is refused `serve-positional-flag`, in every
7566
+ scope. Positionals are emitted first and verbatim, and the verb reads any
7567
+ token starting with `-` as a flag — including the `--payload=/path`
7568
+ spelling — so without this rule a refused flag could be sent as a value and
7569
+ reach the parser through the front door. `trailing` needs no such rule: it
7570
+ always follows a `--` separator, and the flag parser stops there, which is
7571
+ what lets `hook classify -- git push -f` classify a command line that has
7572
+ flags in it.
7573
+ 3. The AGENT credential may not use a path-typed flag at all
7574
+ (`serve-flag-not-permitted`), and may use only the flags on a short
7575
+ per-verb list. A harness reached through this transport has no files on the
7576
+ daemon's machine, so a path it names is either useless or somebody else's;
7577
+ payload bytes arrive through the hook route, which builds the envelope from
7578
+ the tool call.
7579
+ 4. The TENANT credential's path-typed flags are confined to the store
7580
+ (`serve-path-outside-store`), resolved through `realpath` on the deepest
7581
+ existing ancestor so traversal and a symlinked directory both refuse.
7582
+
7583
+ Which flags are path-typed comes from the registry rather than from a list
7584
+ kept beside the server: a flag declared `"path"` publishes as an ordinary
7585
+ `{"type":"string"}`, so the wire contract is unchanged, and it is confined on
7586
+ the day it appears. A test walks every published verb and fails on any flag
7587
+ that is neither declared `"path"` nor reviewed as something else.
7588
+
7589
+ A verb added to the registry tomorrow is published on both surfaces the same
7590
+ day and is TENANT-scoped until somebody decides otherwise. The allowlist is
7591
+ what widens, and widening it is a diff in the server and a diff in a test.
7592
+
7593
+ An agent-credential call to anything off that list is refused
7594
+ `serve-agent-forbidden`, and a tenant-credential call to an allowlisted verb
7595
+ (`request`, `wait`, `withdraw`, `hook_classify`, or `consume`, which this
7596
+ surface withholds entirely) is refused `serve-tenant-forbidden`. Both are scope refusals rather
7597
+ than not-founds: a caller that guessed a path learns that the door exists and
7598
+ is not theirs.
7599
+
7600
+ Every path requires one of the two. There is no unauthenticated surface at all,
7601
+ not a health check and not a 404, so a caller with no credential learns nothing
7602
+ about what exists.
7603
+
7604
+ ### The endpoints
7605
+
7606
+ `GET /verbs` is the published catalog: `approval mcp serve`'s tool list, name
7607
+ for name and schema for schema, because it is derived from the same registry
7608
+ filter by the same function. `--as` is absent from every published schema and
7609
+ the launch identity is appended last to every argv, so a caller can neither
7610
+ name an identity nor change one. `grant` is absent, as it is there and for the
7611
+ same reason: SPEC.md §11 makes the agent the untrusted policy, and a tool list
7612
+ is a statement about what a surface is for. Each entry carries the scope that
7613
+ opens it, so the catalog is honest about `status` being the tenant's.
7614
+
7615
+ ### The response contract
7616
+
7617
+ `POST /verb/<tool_name>` and `POST /hook/<harness>` answer with ONE body:
7618
+
7619
+ ```json
7620
+ {"exit_code": 0, "stdout": "…", "stderr": "…",
7621
+ "stdout_truncated": false, "stderr_truncated": false}
7622
+ ```
7623
+
7624
+ `stdout` and `stderr` are exactly what the CLI wrote to each stream, and
7625
+ `exit_code` is exactly what it exited. A client reproduces the invocation by
7626
+ writing `stdout`, writing `stderr`, and exiting `exit_code`. A client that
7627
+ wants the verb's machine-readable refusal parses `stderr`, which is the stream
7628
+ the CLI prints refusals on. Both streams are bounded at 256 KiB and clipped at
7629
+ a codepoint boundary, with a flag saying whether that happened.
7630
+
7631
+ **Nothing about a verdict travels in a header.** It used to: the exit code rode
7632
+ on `x-approval-exit-code`, and server-authored refusals did not set it. A
7633
+ client following the documented rule then read a missing header as zero, and
7634
+ zero is ALLOW on Claude Code, Cursor, Codex and Muse — so a 1.1 MB hook
7635
+ envelope came back as a 413 that meant *proceed*. The exit code is now data in
7636
+ the body, it is present on every response including every refusal, and on a
7637
+ refusal it is always non-zero.
7638
+
7639
+ **A client treats a missing, unparseable or truncated body as a BLOCK.** That
7640
+ rule is not a nicety: a transport failure this server never saw (a proxy, a
7641
+ dropped connection, a process killed mid-response) has no exit code to carry,
7642
+ and the only safe reading of "no answer" from a gate is no.
7643
+
7644
+ `POST /hook/<harness>` takes the harness envelope the stdin form would read, for
7645
+ every harness this runtime speaks a hook protocol for. Its `stdout` is
7646
+ BYTE-FOR-BYTE what `approval hook <harness>` prints for that envelope, because
7647
+ it IS that stdout: the request body is handed to `commandHook` as its stdin and
7648
+ nothing reshapes the answer. The exit code must travel with it, because the
7649
+ dialects disagree about where a block lives — Claude Code, Cursor, Codex and
7650
+ Muse put it in the body at exit 0; Grok Build and Hermes use exit 2. Hermes's
7651
+ ALLOW is `{}` and carries its reason on stderr and nowhere else, which is why
7652
+ `stderr` is part of the contract rather than a diagnostic.
7653
+
7654
+ A refusal the SERVER raises on the hook route — an oversized body, a credential
7655
+ at the wrong door, a method it does not answer — carries `{"error":{…}}` and
7656
+ `exit_code: 2` **and** a `stdout` holding the harness's own block directive,
7657
+ rendered by the same function that prints one. A client that writes `stdout`
7658
+ and exits `exit_code` therefore blocks on both halves of every dialect at once.
7659
+ That holds for the refusals that fire BEFORE the URL is parsed, too — a
7660
+ rotated credential, a malformed `Host` — because the harness is read off the
7661
+ raw request target for the purpose of the refusal body and nothing else. A
7662
+ credential rotation must not read as a permission, and on four of the six
7663
+ dialects a body with no block directive at exit 0 is exactly that.
7664
+
7665
+ Where the harness is not known (an unroutable path, an unrecognised name)
7666
+ `stdout` is absent, and the client rule in the paragraph above is what covers
7667
+ it.
7668
+
7669
+ A deny is a VERDICT, so its HTTP status is 200; only a refusal by the server
7670
+ is not.
7671
+
7672
+ **A Hermes tenant needs `--hook-harness-cap`, or every manual class is refused
7673
+ (APRV-423).** `approval serve` pins `--harness-cap` on every hook call with
7674
+ `--hook-harness-cap`, as it pins `--timeout` with `--hook-timeout`. Without it a
7675
+ `POST /hook/hermes` runs as `approval hook hermes` with no flag, which assumes
7676
+ Hermes's 30s default `plugins.hook_callback_timeout`; 30s does not clear the 60s
7677
+ margin, so every manual-class call answers `hook-harness-cap-too-short` (a
7678
+ block, nothing registered or requested, no prompt sent) and a tenant is gated
7679
+ shut rather than gated. The repair for a serve operator, who does not own the
7680
+ tenant's `$HERMES_HOME`: have the tenant raise `plugins.hook_callback_timeout` in
7681
+ the harness's config above the per-entry `timeout`, then start `serve` with
7682
+ `--hook-harness-cap <the smaller of the two>` (`--hook-harness-cap 300s` for the
7683
+ documented `600` over `300`). The value is per server rather than per tenant, so
7684
+ a server fronting several Hermes tenants states the smallest ceiling any of them
7685
+ runs under; a stated cap can only shorten a window, never lengthen one. The
7686
+ same flag bounds the hook's WAIT on every route: `--hook-timeout` is clamped to
7687
+ what the ceiling leaves after the margin, so a hook that adopts an already-open
7688
+ question under a ceiling with no room in it answers `hook-timeout` at once
7689
+ (question left open for the retry grace) instead of being killed mid-wait.
7690
+
7691
+ `GET /log/follow?from=<seq>&cursor_hash=<64hex>&limit=<n>` answers one page of
7692
+ verified records and the cursor to ask with next time. The cursor is EXCLUSIVE
7693
+ and the subscription is `core/log-subscribe.ts`'s, so SPEC.md §8 holds
7694
+ unchanged: the chain is verified from genesis before anything is emitted, and a
7695
+ `cursor_hash` that does not match the retained prefix is refused with the
7696
+ `integrity` code, `cursor-mismatch` as its reason, and NO RECORDS, not even the
7697
+ ones a partial drain had already produced. `caught_up` says whether the page
7698
+ exhausted the log or the limit. The server keeps no subscription state: a page
7699
+ is a function of the cursor in the request and the bytes on disk, so a host that
7700
+ sleeps and wakes serves the same next page it would have served before.
7701
+
7702
+ **`from` greater than zero REQUIRES `cursor_hash`.** The subscription calls the
7703
+ sequence-only form a weaker bootstrap, weak in exactly one way: it cannot
7704
+ detect a fully recomputed replacement prefix on its first read. A streaming
7705
+ consumer pays that once and then retains the digest it verified. A paged one
7706
+ never does — every request is a first read — so the weakness would be
7707
+ permanent, and a caller who dropped the hash would be served a replaced prefix
7708
+ silently while an honest caller who kept it got the refusal. `from=0` is the
7709
+ only hashless form, because replaying from genesis binds nothing.
7710
+
7711
+ `from` must be a sequence number this runtime can represent and `limit` must be
7712
+ between 1 and 1000. Both are refused `serve-invalid-cursor` when they are not,
7713
+ rather than clamped: a clamp answers a different question than the one asked
7714
+ and the caller cannot tell it happened.
7715
+
7716
+ `GET /export` answers the store as a gzipped POSIX tar. Its contents are a
7717
+ positive allowlist (`APPROVAL.md`, `.approval/log/`, `.approval/payloads/`,
7718
+ `.approval/QUEUE.md`, `.approval/index.sqlite`), so `.approval/keys`,
7719
+ `.approval/env`, `.approval/daemon` and any `vault.enc` are out by not being
7720
+ named rather than by a denylist remembering them. `.approval/payloads/` is in,
7721
+ and it is the entry the reasoning turns on: the log records a `payload_hash`
7722
+ for every action a human was shown, and those bytes live there. An archive
7723
+ carrying the hashes without the bytes would hand a tenant a chain of references
7724
+ to evidence they no longer hold, which is a receipt for an exit rather than an
7725
+ exit.
7726
+
7727
+ **No link is followed, of either kind, and a link refuses the whole export.**
7728
+ A name-based allowlist is no defence against one: `ln -s
7729
+ .approval/keys/sender.key .approval/log/note.jsonl` passes every check that
7730
+ reads the name and hands over the key, and a link out of the store hands over
7731
+ any file on the host.
7732
+
7733
+ So every entry is `lstat`ed and a symbolic link refuses the export with
7734
+ `serve-export-symlink`; every file is then opened `O_NOFOLLOW` and `fstat`ed,
7735
+ so the checks are made against the descriptor that is actually read rather
7736
+ than against a name that could have been swapped in between; and a regular
7737
+ file whose link count is greater than one refuses with
7738
+ `serve-export-hardlink`. That last is the case with nothing to notice — a hard
7739
+ link is a second name for one inode, with no link to refuse to follow and no
7740
+ target to inspect — so the link count is the only thing that distinguishes it.
7741
+ Each refusal names the path and never its target.
7742
+
7743
+ The whole export is refused rather than the offending file skipped, because an
7744
+ archive silently missing a file is one nobody can tell from a complete one.
7745
+
7746
+ The only file the export drops on its own account is the append lockfile
7747
+ derived from the log path, which exists for exactly the span of the copy
7748
+ because the export holds that lock. It is matched as an exact path, not as a
7749
+ `*.lock` class: a tenant's own `.approval/payloads/x.lock` is the tenant's, and
7750
+ a name this runtime happens to use for bookkeeping is no reason to drop
7751
+ somebody else's file.
7752
+
7753
+ `GET /status` is the `status` verb, answered to the tenant credential.
7754
+
7755
+ ### The bind, and where TLS is
7756
+
7757
+ `--port <n>` picks the port (default 4682) and always binds `127.0.0.1`.
7758
+ `--listen <[host:]port>` is the only way to bind anything else, passing both is
7759
+ a usage error, and a non-loopback host ALSO requires `--allow-non-loopback`: two
7760
+ flags, because the credentials here are bearer values and a cleartext hop hands
7761
+ them to whoever is on it. A widened bind prints a banner on stderr saying
7762
+ exactly that. This process terminates no TLS and holds no certificate; the
7763
+ supported deployment is a loopback bind behind a proxy the operator owns.
7764
+
7765
+ `--listen unix:<path>` (or `APPROVAL_SERVE_LISTEN=unix:<path>`, which the flag
7766
+ overrides) binds a unix-domain socket instead (APRV-445), for a sandbox on the
7767
+ same machine running as a different uid. The path must be absolute, inside an
7768
+ existing directory OWNED BY THE SERVING UID: that directory is the access
7769
+ control, so a directory somebody else owns is refused at startup (exit 2). The
7770
+ socket itself is opened `0666` once bound, and who may reach it is decided by
7771
+ the directory's mode, group or ACL. An other-writable directory (`/tmp`) is
7772
+ refused, since anyone could swap the socket. The widening to `0666` happens only
7773
+ in a directory nobody but the serving uid can write; in a group-writable one the
7774
+ socket keeps the umask's mode (a notice says so). The residual: between the bind
7775
+ and the chmod the socket carries the umask's narrower mode, which fails closed.
7776
+ A socket file is stale, and replaced, only when a connect to it is refused
7777
+ (`ECONNREFUSED`) or it vanished (`ENOENT`); a live one (another server answers)
7778
+ is refused rather than taken over, one whose probe fails any other way
7779
+ (`EACCES`, a full backlog) is left in place and refused, and anything at the
7780
+ path that is not a socket is refused rather than destroyed. Clients speak plain HTTP over the socket, with the same two
7781
+ bearer credentials. `--allow-non-loopback` has nothing to say about it.
7782
+
7783
+ ### Which daemon wrote it (APRV-448)
7784
+
7785
+ Every record this server appends, from a verb call and from a hook call alike,
7786
+ carries the `daemon` field, and the id is the one on the started line
7787
+ (`records stamped daemon <id> (<source>)`). It is resolved once, at startup, by
7788
+ the rule the daemon loop uses: `APPROVAL_DAEMON_ID` from the launch environment
7789
+ when it is set, otherwise `daemon-` plus the instance id derived from the
7790
+ store's `.approval` directory. That is the id `approval status` reports for the
7791
+ same store, so a tenant who reads `daemon` on a record can look it up. No
7792
+ request names any part of it. A declared id that is not a usable id (lowercase
7793
+ letters, digits, `.`, `-`, `_`, at most 64 characters) refuses the server
7794
+ before it binds, exit 2.
7795
+
7796
+ The attested policy's `daemons` list governs this server at the write boundary,
7797
+ exactly as it governs the daemon. It is re-read from the verified log and the
7798
+ attested policy inside the store lock before every verb call and every hook
7799
+ call, so a list a human narrows and re-attests while the server runs refuses
7800
+ the very next call. A refused append writes nothing, and the verb answers
7801
+ `append-failed` with the write boundary's code under `error.append`:
7802
+
7803
+ ```json
7804
+ {"ok":false,"error":{"code":"append-failed","append":"daemon-not-allowed",
7805
+ "message":"task.registered could not be appended: this daemon's id is village-goa-1 and the attested policy's `daemons` list admits village-goa-2, …"}}
7806
+ ```
7807
+
7808
+ A hook call whose append is refused blocks, in the harness's own dialect.
7809
+
7810
+ The processes that stamp the field are three: the daemon loop (`approval daemon
7811
+ run`, and `approval up`, whose Telegram listener runs in the daemon's process),
7812
+ this server, and [`channel telegram webhook`](#channel-telegram-webhook). A
7813
+ session's own CLI calls, `approval hook <harness>` run as a harness's own
7814
+ process, and a standalone `channel telegram listen` stamp nothing. This server
7815
+ declares the id without marking itself the daemon: the mark is what routes an
7816
+ advance under the daemon's own autonomous class, and a process that dispatches
7817
+ the verbs an agent asks for does not hold it.
7818
+
7819
+ ### What it never does
7820
+
7821
+ It appends no record on its own account. Every event in the log under it was
7822
+ written by a verb a caller asked for, under the identity the operator fixed at
7823
+ launch, and stamped with the daemon id on the started line. It reads no
7824
+ `.approval/env`.
7825
+
7826
+ ### What it serialises, and what it does not (APRV-427)
7827
+
7828
+ One lock per store, held inside this process. It is the lock for every stretch
7829
+ of work that may append to the store or must read it as one snapshot:
7830
+
7831
+ | work | holds the store lock |
7832
+ |---|---|
7833
+ | `POST /verb/<name>`, including `GET /status` | for the whole call |
7834
+ | `GET /log/follow` | for the page, so no append this process makes lands under a verified read |
7835
+ | `GET /export` | for the whole snapshot, and the log's append lockfile as well, so no other process appends during it either |
7836
+ | `POST /hook/<harness>` | for its MUTATION sections only: everything up to its poll loop (intake, the abandoned-question sweep, register, request) and everything after it (the spend, a withdrawal) |
7837
+ | a hook call's WAIT | never |
7838
+ | `GET /verbs` | never |
7839
+
7840
+ So one hook call waiting on a human holds nothing. While it polls, the tenant's
7841
+ `status`, `queue` and follow answer, and a second hook call opens its own
7842
+ question and waits beside the first. A hook call runs on a worker thread of
7843
+ this process, because a hook's wait is a synchronous sleep and on the
7844
+ listener's own thread it would stop the listener; the verdict, its bytes and
7845
+ its exit code are `approval hook <harness>`'s, exactly as before. A new thread
7846
+ reads the log once BEFORE it asks for the store lock, so the cold walk of a
7847
+ mature log (hundreds of milliseconds) happens in parallel and never inside the
7848
+ lock; its reads under the lock are then warm.
7849
+
7850
+ **The pool is bounded, because each thread holds its own copy of the log**
7851
+ (about 26 MB idle, plus roughly 84 MB once it has read a 73k-record log). At
7852
+ most `--hook-threads` hook calls run at once, 16 by default, and no more
7853
+ threads than that ever exist. Up to `--hook-queue` further calls, 64 by
7854
+ default, wait for a slot in arrival order; a queued call has registered and
7855
+ requested nothing yet. A call that finds every slot and every place in line
7856
+ taken is refused at once with `serve-hook-saturated` (HTTP 503), carrying the
7857
+ harness's own block directive in `stdout` and `exit_code: 2`, and nothing is
7858
+ appended for it. Four finished threads are kept warm for the next call.
7859
+
7860
+ **Time in line is charged to the harness's ceiling.** The ceiling a harness
7861
+ puts on its hook (`--hook-harness-cap`, or the adapter's documented default)
7862
+ runs from the moment the call ARRIVED at this server. A call that waits for a
7863
+ slot or for the store lock spends that budget, and the hook judges, waits by
7864
+ and records on its request the ceiling less that wait, so any question it
7865
+ opens lapses at arrival plus ceiling minus the 60s margin, while the harness
7866
+ is still listening. A call that arrived with room and has less than the
7867
+ margin left when its slot or the lock arrives is refused
7868
+ `serve-hook-saturated` instead, appending nothing: a question opened then
7869
+ would still be on the approver's phone when the harness kills its asker. A
7870
+ ceiling that never had room is the hook's own `hook-harness-cap-too-short`,
7871
+ as before.
7872
+
7873
+ A torn read in a hook's poll (another writer's line caught half-landed, which a
7874
+ filesystem that grows a file a page at a time can show a reader) is read again
7875
+ on the next tick, up to five ticks in a row, before the hook treats the log as
7876
+ unreadable. This holds for the stdin form too, beside the daemon. Verb calls still run one at a time, in the
7877
+ order they arrive, because some of them are synchronous and blocking too (`run`
7878
+ spawns, and `wait` sleeps on the listener's thread, so an agent's `POST
7879
+ /verb/wait` still holds the listener for its own timeout; the hook route is the
7880
+ one a harness waits on).
7881
+
7882
+ Two appends never interleave. Inside this process the store lock is the
7883
+ reason: every append is inside one of the sections above, and the sections run
7884
+ one at a time. Across processes (a daemon, a CLI run beside this server) the
7885
+ reason is the one every `approval` process relies on: each append takes the
7886
+ log's lockfile and compares-and-appends against the head it read.
7887
+
7888
+ **A client that goes away is not answered, and does not spend.** When the HTTP
7889
+ connection of a waiting hook call closes before its answer, the call stops at
7890
+ its next poll tick without spending a grant and without withdrawing its
7891
+ question, and nothing is sent. A grant that lands afterwards is left for the
7892
+ harness's retry of the same command, which carries it and spends it once; an
7893
+ undecided question is adopted by that retry. A call still in line for a
7894
+ thread simply leaves the line. A grant already spent before the connection
7895
+ closed stays spent.
7896
+
7897
+ Closing the listener runs in this order: stop accepting connections; cancel
7898
+ every hook call the same way (waiting calls stop at their next tick, queued
7899
+ ones leave the line); terminate the hook threads from inside the store lock,
7900
+ which also waits out any verb or mutation section still running, so no thread
7901
+ is killed while holding the log's append lockfile; and only then destroy the
7902
+ remaining sockets. A question a cancelled call opened stays open for the retry
7903
+ grace, exactly as for an `approval hook` process that was killed mid-wait.
7904
+ Shutdown therefore takes as long as the longest work already holding the store
7905
+ lock (a `POST /verb/wait` runs to its own timeout), so a platform that kills
7906
+ the process a fixed time after SIGTERM (a container's stop timeout, a
7907
+ supervisor's kill timeout) should allow more than that, or the kill lands on
7908
+ work the lock was protecting. A hook call whose thread fails answers a
7909
+ refusal, `serve-hook-failed`, with the harness's own block directive in
7910
+ `stdout`.
7911
+
7912
+ ## muse
7913
+
7914
+ `approval muse` starts a local, single-tenant synthetic consumer facade. The
7915
+ operator fixes the tenant and distinct read/propose credentials in the launch
7916
+ environment and the store root with `--dir` before the listener binds to
7917
+ loopback. The proposal
7918
+ scope can register an in-memory envelope and request its declared action. The
7919
+ read scope sees status, pending requests and the canonical rendering of a live
7920
+ request. Both scopes are narrow: the listener has no grant, reject, execution,
7921
+ token, generic verb or export route. Human decisions use the separately
7922
+ configured Telegram listener. A pending request does not prove delivery to
7923
+ Telegram. See `docs/muse-connector.md` for the local HTTP contract, native
7924
+ evidence limits and prerequisites.
7925
+
5699
7926
  ## Constrained Codex preparation
5700
7927
 
5701
7928
  approval codex prepare is an artifact generator. It writes one fresh review
@@ -5710,7 +7937,270 @@ the broker and runner present? POSIX ownership does not establish ACL custody,
5710
7937
  so this slice executes no manifest-selected binary and reports runtime versions
5711
7938
  unchecked. Unknown evidence is a refusal.
5712
7939
 
5713
- The first slice deliberately makes start and serve return codex-not-ready. An
7940
+ The first slice deliberately made start and serve return codex-not-ready. An
5714
7941
  npm install, generated config, or passing bundle check does not create a
5715
7942
  mandatory boundary. The whole approval codex family is operator-only and absent
5716
7943
  from the ordinary broad MCP catalog.
7944
+
7945
+ ### The workspace broker (APRV-325.2)
7946
+
7947
+ approval codex apply is the executable half. It takes an instance manifest and a
7948
+ proposal file, and the split between them is the design: the manifest supplies
7949
+ the acting identity (agent:codex-<instance_id>), the workspace root, the policy
7950
+ file and the log, and the proposal supplies operations and the SHA-256 of the
7951
+ policy it was built against. A proposal naming anything else, an actor, a root,
7952
+ a class, a token, a sandbox posture, is refused input-invalid rather than having
7953
+ the extra key ignored, because a caller that wrote one meant something by it.
7954
+
7955
+ The order of one apply, and why each step is where it is:
7956
+
7957
+ 1. Read the log under chain verification, read the policy once, hash those exact
7958
+ bytes, and check them against the latest attestation. An unattested or
7959
+ drifted policy refuses here, before a plan exists.
7960
+ 2. Compare the caller's expected digest. A mismatch is attestation-drift: the
7961
+ proposal was built against a policy nobody is enforcing.
7962
+ 3. Plan through src/codex/workspace-plan.ts, which does the path work: traversal,
7963
+ symlinks, hardlinks, case aliases, missing sources, existing destinations,
7964
+ preimage digests, and human-only classes refused before any preimage is read.
7965
+ 4. Register one action per distinct path class under a task id derived from the
7966
+ payload hash. Classes are never collapsed: the class is what the operator's
7967
+ roster, budget and autonomy are keyed to.
7968
+ 5. Authorize every leg. A leg the policy sends to a human refuses
7969
+ approval-required and names the action keys to grant; nothing is written, and
7970
+ the same proposal applies once a person has decided.
7971
+ 6. Start every leg before any byte moves. A later leg refusing to start closes
7972
+ the earlier ones execution.failed and leaves the workspace untouched by
7973
+ construction rather than by cleanup.
7974
+ 7. Take the workspace lock, then revalidate the plan under it. A revalidation
7975
+ that precedes the lock proves only what was true before another writer could
7976
+ act.
7977
+ 8. Stage the new bytes and the preimages on the same filesystem, fsync them,
7978
+ write a journal naming the before-state and the after-state, fsync that, and
7979
+ only then apply.
7980
+ 9. Read the workspace back. All-after completes every leg, all-before fails every
7981
+ leg, and anything else, a partial apply, a failed rollback, an endpoint that
7982
+ cannot be read, records execution.indeterminate with the reason
7983
+ workspace-commit-unknown on every leg and retains the journal and the lock.
7984
+
7985
+ approval codex recover reads that retained journal and reports before, after or
7986
+ mixed. It repairs nothing, and the restraint is the point: rolling a mixed
7987
+ workspace forward would guess which half the human approved, and rolling it back
7988
+ would delete the half that committed. Exit 1 means mixed. The resolution is a
7989
+ person's, through approval execution reconcile.
7990
+
7991
+ Custody is claimed only as far as the platform proves it. The broker takes an
7992
+ O_CREAT|O_EXCL lock, which excludes other cooperating brokers and nothing else,
7993
+ and then asks POSIX ownership and mode whether any other principal can write the
7994
+ directories it is about to touch. It reports os-exclusive only when both hold and
7995
+ advisory otherwise, and it always reports acl-unproven, because ownership and
7996
+ mode say nothing about ACLs. --require-exclusive-custody turns the weak answer
7997
+ into a refusal rather than a footnote.
7998
+
7999
+ approval codex serve publishes that broker over stdio as exactly one MCP tool,
8000
+ codex_workspace_apply, checked at call time as well as at list time. It is a
8001
+ different server from approval mcp serve, whose catalog is the whole verb
8002
+ registry and therefore grows: a constrained session has to reach one door, and
8003
+ the same door next month.
8004
+
8005
+ ### The confined session (APRV-325.3)
8006
+
8007
+ approval codex start prepares the room a session's shell runs in. With no
8008
+ -- <command> it reports the room and runs nothing, which is what an operator
8009
+ checking a host should not have to start Codex to learn. With one, it runs that
8010
+ command inside the room and exits with the child's own code.
8011
+
8012
+ The shell gets a disposable workspace under the system temporary directory, and
8013
+ that workspace is the only path it may write. The canonical workspace is
8014
+ readable and never writable. Reads are jailed to exactly those two roots
8015
+ (APRV-347), so the gate home, other repositories and everything else the
8016
+ operator's home holds are unreadable whether or not anyone thought to name them,
8017
+ and the credential denials are emitted after the jail's allows so they remain
8018
+ the last word on the vault, the environment map and the sealing keys. The
8019
+ environment is an allow-list rather than a filtered copy of the operator's, so a
8020
+ provider key nobody taught this runtime about is absent rather than forgotten.
8021
+ Outbound network is denied, loopback included. Descendants inherit all of it,
8022
+ which is the property that matters: a session does not write files by calling
8023
+ into this runtime, it spawns shells that do.
8024
+
8025
+ There is no opt-out flag and no unwrapped fallback. approval run has
8026
+ --no-sandbox because a human's grant over exact bytes is authority to reach the
8027
+ world; a confined session has no such authority to present, so a host with no
8028
+ sandbox mechanism refuses (sandbox-unsupported) where approval run would record
8029
+ unsupported and proceed. A command that cannot be resolved on the session PATH is
8030
+ refused rather than spawned outside the room.
8031
+
8032
+ The disposable workspace is removed when the session ends, so a replay of the
8033
+ same shell work starts from an empty room. Only a brokered change survives it,
8034
+ which is the whole arrangement: the shell cannot reach the canonical workspace,
8035
+ and approval codex apply is how a change that a policy admitted does.
8036
+
8037
+ ### The app-server bridge (APRV-361)
8038
+
8039
+ ```
8040
+ approval codex bridge --prompt <text> [--workspace <dir>] [-- <server command>]
8041
+ ```
8042
+
8043
+ approval codex bridge starts `codex app-server` and answers every approval
8044
+ request it raises through the policy and the log. Each
8045
+ `item/commandExecution/requestApproval` carries `command` and `cwd` on the same
8046
+ frame, minted by the harness runtime, and those two fields are exactly the pair
8047
+ the native hook lacks: a Codex `Bash` pre-event names only the command, so
8048
+ `approval hook codex` refuses every shell call as
8049
+ `hook-unsupported-execution-context` rather than bind bytes whose directory it
8050
+ does not know. Here the directory arrives with the question.
8051
+
8052
+ It reuses the hook's decision path rather than a copy of it. The request becomes
8053
+ the hook's own input (tool `Bash`, `tool_input.command` the string the server
8054
+ sent, `cwd` the directory it named) and goes through the same classifier, the
8055
+ same human-only refusal, the same sandbox requirement, the same loop floor and
8056
+ unattended guard, and the same register, request and wait against the verified
8057
+ view. What differs is where the answer goes: `{id, result: {decision}}` on the
8058
+ connection instead of a decision object on stdout.
8059
+
8060
+ The deadline is the policy's `approval_ttl`, not a harness ceiling. Every hook
8061
+ adapter answers inside a timeout its harness sets, and the retry grace exists so
8062
+ a denial-by-deadline is recoverable; this transport has no timeout at all, so a
8063
+ human who answers in eleven minutes is answering rather than arriving too late.
8064
+ `--wait` overrides it. The bridge therefore states no `--harness-cap` and the
8065
+ window shortening of APRV-423 never applies to it: there is no process here that
8066
+ something else will kill, so there is nothing for a cap to keep ahead of.
8067
+
8068
+ It answers accept or decline only, in the vocabulary the request advertised
8069
+ through `availableDecisions`, matched exactly and never by prefix, so
8070
+ `acceptWithExecpolicyAmendment` is not read as an accept. It never sends
8071
+ `acceptForSession` (standing authority for a whole session is a grant shape this
8072
+ project does not have), `cancel` or `abort` (those mean "stop the turn", and
8073
+ sending one would record an interruption as a denial). A request advertising
8074
+ nothing gets `accept` or `decline` and the report says the word was this
8075
+ runtime's own.
8076
+
8077
+ The vocabulary is eight spellings of those two words (`accept`, `approved`,
8078
+ `approve`, `allow`; `decline`, `denied`, `deny`, `reject`), and since APRV-367
8079
+ it is a type rather than a convention: the reply value cannot be constructed
8080
+ outside the list, one function turns a decision into bytes and re-checks
8081
+ membership there, and a word that somehow failed that check would be sent as a
8082
+ decline, since the only safe substitute for a word you cannot name is no. The
8083
+ match is case-insensitive, and what goes on the wire is this runtime's own
8084
+ spelling of the matched word. The `bridge-decisions` conformance suite pins the
8085
+ behaviour for a second implementation.
8086
+
8087
+ Its own refusals, beside the gate's:
8088
+
8089
+ ```
8090
+ bridge-request-unbound no command, no cwd, or no call identity on the request
8091
+ bridge-command-unbound a command string that names no argv this client can bind (APRV-362)
8092
+ bridge-file-change-unbound an item-based file change whose content this client cannot
8093
+ produce from the item/started frame its item id names: no such
8094
+ frame, an item that is not a fileChange, an empty change set, or
8095
+ a frame belonging to another thread or turn (APRV-379)
8096
+ bridge-file-change-already-completed
8097
+ item/completed for that item arrived before the question, so the
8098
+ change had finished before this client was asked (APRV-379)
8099
+ bridge-unknown-request a server request this client has no reading for
8100
+ ```
8101
+
8102
+ The **item-based file change is correlated, not guessed** (APRV-379). That API
8103
+ puts the content on an earlier `item/started` notification and the approval
8104
+ request refers to it by `itemId`, so the bridge keeps every item the thread
8105
+ announces and decides the request against the frame that id names: the paths
8106
+ take their classes, the payload binds the change set verbatim with
8107
+ `content_sha256` over it as received, and nothing parses the `diff`. The request
8108
+ carries no directory, so the paths resolve against the workspace the bridge
8109
+ named on `thread/start`, and one landing outside it is refused `hook-io`.
8110
+
8111
+ The **exec request binds words, not a rendering** (APRV-362). The item-based API
8112
+ delivers the command as one string, joined from the argv Codex will run, so the
8113
+ bridge un-joins it and the registered payload carries the string that arrived
8114
+ and the argv beside it. A string that is not readable as a join (an unterminated
8115
+ quote, a double quote outside a quoted run, a trailing backslash, or separation
8116
+ no join produces) is `bridge-command-unbound`, because approving it would
8117
+ approve this client's own re-parse. Byte equality with a re-rendering is
8118
+ deliberately not required: a join written for shell safety quotes more than this
8119
+ one does, and demanding equality would refuse ordinary traffic over a quoting
8120
+ rule nothing here records. A legacy argv array is rendered word by word rather
8121
+ than concatenated, so `["bash", "-lc", "rm -rf build"]` reaches the classifier
8122
+ as `bash -lc 'rm -rf build'` and not as five separate words.
8123
+
8124
+ A **legacy `applyPatchApproval` is decided rather than declined** (APRV-363).
8125
+ Its `fileChanges` map rides on the request, so there is nothing to correlate and
8126
+ nothing is re-rendered: the change is classified by the paths it names, through
8127
+ the same protected-path rules every other file tool uses, and the registered
8128
+ payload carries those paths, the change verbatim and `content_sha256` over the
8129
+ map as it arrived. A path that is absolute, or that resolves outside the
8130
+ directory the server named, is refused `hook-io`; a request with a map and no
8131
+ directory (`cwd` or `grantRoot`) is `bridge-request-unbound`. The item-based
8132
+ `item/fileChange/requestApproval`, which carries an identifier and no content,
8133
+ stays declined.
8134
+
8135
+ The thread is started with `approvalPolicy: untrusted` and `sandbox: read-only`.
8136
+ `untrusted` is the wire spelling of the source's `UnlessTrusted`, the only
8137
+ variant under which every command asks, and the server refuses the source name.
8138
+ There is no flag for it: a session gating an unknown fraction of itself is what
8139
+ the pin exists to prevent (APRV-366).
8140
+
8141
+ The pin is checked as well as sent, and a failure ends the run rather than
8142
+ declining one request:
8143
+
8144
+ ```
8145
+ bridge-thread-start-refused the server refused thread/start, so no thread
8146
+ exists and no policy was established; its own
8147
+ error is carried verbatim
8148
+ bridge-approval-policy-mismatch the server reported an effective approval
8149
+ policy that is not untrusted
8150
+ ```
8151
+
8152
+ A server that reports no policy at all is run against, because the observed
8153
+ 0.155.0 server echoes none and a client demanding an echo could not start. The
8154
+ report says which case it was: `thread.requested` is what went on the wire,
8155
+ `thread.effective` is what the server said, and `thread.confirmed` names where
8156
+ the claim comes from: `unconfirmed`, `reported` (a frame echoed the pin back)
8157
+ or `observed` (a probe command produced an approval request that reached this
8158
+ client). Both stops exit 4, as every other protocol stop in this verb does; the
8159
+ code in the report is the part to branch on.
8160
+
8161
+ **A preflight probe runs before every turn** (APRV-364). Codex's auto-reviewer
8162
+ can resolve an approval with a model call before this client is asked, and
8163
+ nothing in the protocol reports whether it is running, so the bridge watches one
8164
+ command instead of reading a setting. Each start asks a preflight turn for
8165
+ `true` and nothing else, before the operator's prompt, with no flag to skip it;
8166
+ it costs one turn per start. The probe's own request is declined immediately as
8167
+ an observation and never reaches the gate, so nothing is registered for it and
8168
+ no approver is asked about it.
8169
+
8170
+ ```
8171
+ bridge-preflight-void the preflight turn ran no command at all, so
8172
+ nothing was established; the report carries
8173
+ the turn's frames verbatim and nothing is
8174
+ retried. Run the verb again
8175
+ bridge-auto-reviewer-active an item/autoApprovalReview notification
8176
+ arrived in either turn: something other than
8177
+ this client answered a question
8178
+ ```
8179
+
8180
+ A probe that RAN without asking is `bridge-approval-policy-mismatch`, because a
8181
+ policy under which one command did not ask is not `untrusted` whatever the
8182
+ server reports about itself. A probe that was asked about lets the real turn
8183
+ run, and `thread.confirmed` becomes `observed`. That word is narrow on purpose:
8184
+ it says one question reached this client unanswered by anything else, and it is
8185
+ not a claim that the auto-reviewer is off. The report's `preflight` block
8186
+ carries the turn id, the command, the outcome (`asked`, `executed`, `void` or
8187
+ `pending`), the word sent, and, on a void, the frames.
8188
+
8189
+ **It is an advisory checkpoint and not a boundary**, for reasons
8190
+ docs/codex-app-server-bridge.md states in full: Codex's auto-reviewer can
8191
+ resolve a question before this client sees it, and the approval policy and
8192
+ sandbox posture decide how many questions exist. An open gate window is not
8193
+ honoured here either, which is the strict direction. The claim it supports is
8194
+ "this client decided every question this app-server child asked in this
8195
+ session", and nothing wider.
8196
+
8197
+ **Custody is the operating system's** (APRV-365). The server is started by this
8198
+ verb as its own child over stdio pipes: there is no socket, nothing binds a
8199
+ path, and no other process holds a descriptor to speak on, so the replay of a
8200
+ pending request to whatever connects next cannot arise inside one run. The
8201
+ scope of the claim is that child and that session; a Codex started outside this
8202
+ arrangement is a different process and nothing here observes it. The verb does
8203
+ not inspect the command after `--` for a shape that would attach to something
8204
+ already running instead: that would be a guess at another program's command
8205
+ line, and a check written against a guessed shape finds nothing while reporting
8206
+ that it looked.