approval-md 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (416) hide show
  1. package/README.md +629 -559
  2. package/SPEC.md +99 -24
  3. package/dist/src/adapters/agentmail.d.ts +426 -0
  4. package/dist/src/adapters/agentmail.js +2 -2
  5. package/dist/src/adapters/conformance.d.ts +149 -0
  6. package/dist/src/adapters/contract.d.ts +628 -0
  7. package/dist/src/adapters/contract.js +110 -16
  8. package/dist/src/adapters/contract.js.map +1 -1
  9. package/dist/src/adapters/email.d.ts +324 -0
  10. package/dist/src/adapters/env-passphrase.d.ts +93 -0
  11. package/dist/src/adapters/public.d.ts +11 -0
  12. package/dist/src/adapters/public.js +11 -0
  13. package/dist/src/adapters/public.js.map +1 -0
  14. package/dist/src/adapters/registry.d.ts +59 -0
  15. package/dist/src/adapters/registry.js +2 -1
  16. package/dist/src/adapters/registry.js.map +1 -1
  17. package/dist/src/adapters/smtp.d.ts +213 -0
  18. package/dist/src/adapters/vault-provider.d.ts +114 -0
  19. package/dist/src/adapters/vault-provider.js +3 -3
  20. package/dist/src/adapters/zzz.d.ts +66 -0
  21. package/dist/src/adapters/zzz.js +299 -0
  22. package/dist/src/adapters/zzz.js.map +1 -0
  23. package/dist/src/channels/batch.d.ts +109 -0
  24. package/dist/src/channels/cli.d.ts +193 -0
  25. package/dist/src/channels/conformance.d.ts +92 -0
  26. package/dist/src/channels/contract.d.ts +656 -0
  27. package/dist/src/channels/contract.js +200 -7
  28. package/dist/src/channels/contract.js.map +1 -1
  29. package/dist/src/channels/payload-view.d.ts +35 -0
  30. package/dist/src/channels/render-queue.d.ts +149 -0
  31. package/dist/src/channels/tagging.d.ts +196 -0
  32. package/dist/src/channels/telegram.d.ts +1944 -0
  33. package/dist/src/channels/telegram.js +218 -23
  34. package/dist/src/channels/telegram.js.map +1 -1
  35. package/dist/src/channels/web.d.ts +350 -0
  36. package/dist/src/channels/web.js +17 -0
  37. package/dist/src/channels/web.js.map +1 -1
  38. package/dist/src/cli/adapter.d.ts +90 -0
  39. package/dist/src/cli/adapter.js +25 -15
  40. package/dist/src/cli/adapter.js.map +1 -1
  41. package/dist/src/cli/amend.d.ts +59 -0
  42. package/dist/src/cli/amend.js +214 -30
  43. package/dist/src/cli/amend.js.map +1 -1
  44. package/dist/src/cli/args.d.ts +43 -0
  45. package/dist/src/cli/attest.d.ts +50 -0
  46. package/dist/src/cli/attest.js +134 -7
  47. package/dist/src/cli/attest.js.map +1 -1
  48. package/dist/src/cli/audit-card.d.ts +62 -0
  49. package/dist/src/cli/audit.d.ts +59 -0
  50. package/dist/src/cli/channel-telegram.d.ts +879 -0
  51. package/dist/src/cli/channel-telegram.js +311 -13
  52. package/dist/src/cli/channel-telegram.js.map +1 -1
  53. package/dist/src/cli/channel-web.d.ts +131 -0
  54. package/dist/src/cli/channel.d.ts +80 -0
  55. package/dist/src/cli/channel.js +9 -0
  56. package/dist/src/cli/channel.js.map +1 -1
  57. package/dist/src/cli/checkpoint-tap.d.ts +169 -0
  58. package/dist/src/cli/codex-bridge.d.ts +819 -0
  59. package/dist/src/cli/codex-bridge.js +1607 -0
  60. package/dist/src/cli/codex-bridge.js.map +1 -0
  61. package/dist/src/cli/codex.d.ts +2 -0
  62. package/dist/src/cli/codex.js +469 -0
  63. package/dist/src/cli/codex.js.map +1 -0
  64. package/dist/src/cli/coverage.d.ts +61 -0
  65. package/dist/src/cli/daemon.d.ts +120 -0
  66. package/dist/src/cli/daemon.js +4 -1
  67. package/dist/src/cli/daemon.js.map +1 -1
  68. package/dist/src/cli/doctor.d.ts +129 -0
  69. package/dist/src/cli/doctor.js +586 -17
  70. package/dist/src/cli/doctor.js.map +1 -1
  71. package/dist/src/cli/env.d.ts +65 -0
  72. package/dist/src/cli/execute.d.ts +202 -0
  73. package/dist/src/cli/execute.js +25 -2
  74. package/dist/src/cli/execute.js.map +1 -1
  75. package/dist/src/cli/exit-codes.d.ts +73 -0
  76. package/dist/src/cli/feedback.d.ts +60 -0
  77. package/dist/src/cli/gate-window.d.ts +40 -0
  78. package/dist/src/cli/gate.d.ts +68 -0
  79. package/dist/src/cli/git-scope.d.ts +190 -0
  80. package/dist/src/cli/gloss-attach.d.ts +85 -0
  81. package/dist/src/cli/gloss-codex-child.d.ts +9 -0
  82. package/dist/src/cli/gloss-codex.d.ts +24 -0
  83. package/dist/src/cli/gloss-options.d.ts +42 -0
  84. package/dist/src/cli/gloss.d.ts +265 -0
  85. package/dist/src/cli/help.d.ts +107 -0
  86. package/dist/src/cli/help.js +320 -93
  87. package/dist/src/cli/help.js.map +1 -1
  88. package/dist/src/cli/hook-codex.d.ts +126 -0
  89. package/dist/src/cli/hook-codex.js +226 -0
  90. package/dist/src/cli/hook-codex.js.map +1 -0
  91. package/dist/src/cli/hook.d.ts +787 -0
  92. package/dist/src/cli/hook.js +1235 -181
  93. package/dist/src/cli/hook.js.map +1 -1
  94. package/dist/src/cli/import.d.ts +35 -0
  95. package/dist/src/cli/import.js +1 -1
  96. package/dist/src/cli/import.js.map +1 -1
  97. package/dist/src/cli/init.d.ts +84 -0
  98. package/dist/src/cli/init.js +2 -2
  99. package/dist/src/cli/init.js.map +1 -1
  100. package/dist/src/cli/instructions.d.ts +23 -0
  101. package/dist/src/cli/journal.d.ts +41 -0
  102. package/dist/src/cli/log-advance.d.ts +287 -0
  103. package/dist/src/cli/log-advance.js +102 -11
  104. package/dist/src/cli/log-advance.js.map +1 -1
  105. package/dist/src/cli/log-anchor.d.ts +176 -0
  106. package/dist/src/cli/log-checkpoint.d.ts +22 -0
  107. package/dist/src/cli/log-sync.d.ts +243 -0
  108. package/dist/src/cli/log-verbs.d.ts +16 -0
  109. package/dist/src/cli/log-verbs.js +7 -1
  110. package/dist/src/cli/log-verbs.js.map +1 -1
  111. package/dist/src/cli/long-help.d.ts +70 -0
  112. package/dist/src/cli/main.d.ts +77 -0
  113. package/dist/src/cli/main.js +159 -7
  114. package/dist/src/cli/main.js.map +1 -1
  115. package/dist/src/cli/mcp.d.ts +52 -0
  116. package/dist/src/cli/paths.d.ts +56 -0
  117. package/dist/src/cli/payload.d.ts +58 -0
  118. package/dist/src/cli/policy-apply.d.ts +195 -0
  119. package/dist/src/cli/policy-apply.js +573 -0
  120. package/dist/src/cli/policy-apply.js.map +1 -0
  121. package/dist/src/cli/policy.d.ts +43 -0
  122. package/dist/src/cli/policy.js +14 -1
  123. package/dist/src/cli/policy.js.map +1 -1
  124. package/dist/src/cli/preflight.d.ts +501 -0
  125. package/dist/src/cli/preflight.js +689 -45
  126. package/dist/src/cli/preflight.js.map +1 -1
  127. package/dist/src/cli/progress.d.ts +78 -0
  128. package/dist/src/cli/prompt.d.ts +209 -0
  129. package/dist/src/cli/quickstart.d.ts +46 -0
  130. package/dist/src/cli/quickstart.js +297 -0
  131. package/dist/src/cli/quickstart.js.map +1 -0
  132. package/dist/src/cli/records.d.ts +34 -0
  133. package/dist/src/cli/render.d.ts +22 -0
  134. package/dist/src/cli/sandbox.d.ts +51 -0
  135. package/dist/src/cli/sandbox.js +17 -1
  136. package/dist/src/cli/sandbox.js.map +1 -1
  137. package/dist/src/cli/scaffold.d.ts +79 -0
  138. package/dist/src/cli/scaffold.js +1 -1
  139. package/dist/src/cli/setup-adapter.d.ts +137 -0
  140. package/dist/src/cli/setup-adapter.js +38 -4
  141. package/dist/src/cli/setup-adapter.js.map +1 -1
  142. package/dist/src/cli/setup-channel.d.ts +126 -0
  143. package/dist/src/cli/setup-channel.js +28 -1
  144. package/dist/src/cli/setup-channel.js.map +1 -1
  145. package/dist/src/cli/setup-checkpoint.d.ts +57 -0
  146. package/dist/src/cli/setup-common.d.ts +277 -0
  147. package/dist/src/cli/setup-common.js +3 -2
  148. package/dist/src/cli/setup-common.js.map +1 -1
  149. package/dist/src/cli/setup-flow.d.ts +287 -0
  150. package/dist/src/cli/setup-service.d.ts +96 -0
  151. package/dist/src/cli/setup.d.ts +204 -0
  152. package/dist/src/cli/setup.js +94 -2
  153. package/dist/src/cli/setup.js.map +1 -1
  154. package/dist/src/cli/style.d.ts +320 -0
  155. package/dist/src/cli/token.d.ts +39 -0
  156. package/dist/src/cli/up.d.ts +155 -0
  157. package/dist/src/cli/up.js +119 -53
  158. package/dist/src/cli/up.js.map +1 -1
  159. package/dist/src/cli/usage.d.ts +37 -0
  160. package/dist/src/cli/values.d.ts +40 -0
  161. package/dist/src/cli/values.js +3 -4
  162. package/dist/src/cli/values.js.map +1 -1
  163. package/dist/src/cli/vault.d.ts +59 -0
  164. package/dist/src/cli/vault.js +2 -2
  165. package/dist/src/cli/vault.js.map +1 -1
  166. package/dist/src/cli/verb-registry.d.ts +76 -0
  167. package/dist/src/cli/verb-registry.js +344 -11
  168. package/dist/src/cli/verb-registry.js.map +1 -1
  169. package/dist/src/cli/wordmark.d.ts +31 -0
  170. package/dist/src/cli/wordmark.js +2 -2
  171. package/dist/src/codex/broker.d.ts +229 -0
  172. package/dist/src/codex/broker.js +548 -0
  173. package/dist/src/codex/broker.js.map +1 -0
  174. package/dist/src/codex/doctor.d.ts +13 -0
  175. package/dist/src/codex/doctor.js +41 -0
  176. package/dist/src/codex/doctor.js.map +1 -0
  177. package/dist/src/codex/manifest.d.ts +49 -0
  178. package/dist/src/codex/manifest.js +103 -0
  179. package/dist/src/codex/manifest.js.map +1 -0
  180. package/dist/src/codex/runner.d.ts +178 -0
  181. package/dist/src/codex/runner.js +231 -0
  182. package/dist/src/codex/runner.js.map +1 -0
  183. package/dist/src/codex/serve.d.ts +56 -0
  184. package/dist/src/codex/serve.js +98 -0
  185. package/dist/src/codex/serve.js.map +1 -0
  186. package/dist/src/codex/templates.d.ts +41 -0
  187. package/dist/src/codex/templates.js +319 -0
  188. package/dist/src/codex/templates.js.map +1 -0
  189. package/dist/src/codex/trust.d.ts +19 -0
  190. package/dist/src/codex/trust.js +183 -0
  191. package/dist/src/codex/trust.js.map +1 -0
  192. package/dist/src/codex/workspace-commit.d.ts +219 -0
  193. package/dist/src/codex/workspace-commit.js +549 -0
  194. package/dist/src/codex/workspace-commit.js.map +1 -0
  195. package/dist/src/codex/workspace-plan.d.ts +131 -0
  196. package/dist/src/codex/workspace-plan.js +561 -0
  197. package/dist/src/codex/workspace-plan.js.map +1 -0
  198. package/dist/src/core/actor.d.ts +2 -0
  199. package/dist/src/core/actor.js +5 -0
  200. package/dist/src/core/actor.js.map +1 -0
  201. package/dist/src/core/advance-cycle.d.ts +221 -0
  202. package/dist/src/core/advance-cycle.js +66 -2
  203. package/dist/src/core/advance-cycle.js.map +1 -1
  204. package/dist/src/core/agents-md.d.ts +278 -0
  205. package/dist/src/core/agents-md.js +33 -31
  206. package/dist/src/core/agents-md.js.map +1 -1
  207. package/dist/src/core/apply-patch.d.ts +49 -0
  208. package/dist/src/core/apply-patch.js +266 -0
  209. package/dist/src/core/apply-patch.js.map +1 -0
  210. package/dist/src/core/attest.d.ts +635 -0
  211. package/dist/src/core/attest.js +326 -4
  212. package/dist/src/core/attest.js.map +1 -1
  213. package/dist/src/core/audit.d.ts +510 -0
  214. package/dist/src/core/audit.js +13 -0
  215. package/dist/src/core/audit.js.map +1 -1
  216. package/dist/src/core/budgets.d.ts +238 -0
  217. package/dist/src/core/channel-owner.d.ts +213 -0
  218. package/dist/src/core/channel-owner.js +358 -0
  219. package/dist/src/core/channel-owner.js.map +1 -0
  220. package/dist/src/core/checkpoint.d.ts +500 -0
  221. package/dist/src/core/child-env.d.ts +88 -0
  222. package/dist/src/core/clock.d.ts +52 -0
  223. package/dist/src/core/command-class.d.ts +697 -0
  224. package/dist/src/core/command-class.js +713 -25
  225. package/dist/src/core/command-class.js.map +1 -1
  226. package/dist/src/core/commit-guard.d.ts +272 -0
  227. package/dist/src/core/commit-guard.js +424 -0
  228. package/dist/src/core/commit-guard.js.map +1 -0
  229. package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
  230. package/dist/src/core/coverage-sources/gh.d.ts +48 -0
  231. package/dist/src/core/coverage-sources/git.d.ts +101 -0
  232. package/dist/src/core/coverage.d.ts +217 -0
  233. package/dist/src/core/credential-spec.d.ts +72 -0
  234. package/dist/src/core/daemon-actor.d.ts +45 -0
  235. package/dist/src/core/daemon-actor.js +54 -0
  236. package/dist/src/core/daemon-actor.js.map +1 -0
  237. package/dist/src/core/dark-session.d.ts +432 -0
  238. package/dist/src/core/dark-session.js +266 -82
  239. package/dist/src/core/dark-session.js.map +1 -1
  240. package/dist/src/core/decision-refusal.d.ts +206 -0
  241. package/dist/src/core/decision-refusal.js +24 -2
  242. package/dist/src/core/decision-refusal.js.map +1 -1
  243. package/dist/src/core/env-file.d.ts +455 -0
  244. package/dist/src/core/env-file.js +60 -1
  245. package/dist/src/core/env-file.js.map +1 -1
  246. package/dist/src/core/execute.d.ts +871 -0
  247. package/dist/src/core/execute.js +59 -8
  248. package/dist/src/core/execute.js.map +1 -1
  249. package/dist/src/core/frontmatter.d.ts +78 -0
  250. package/dist/src/core/gate-window.d.ts +312 -0
  251. package/dist/src/core/gate.d.ts +1449 -0
  252. package/dist/src/core/gate.js +149 -14
  253. package/dist/src/core/gate.js.map +1 -1
  254. package/dist/src/core/gesture-refusal.d.ts +166 -0
  255. package/dist/src/core/gesture-refusal.js +188 -0
  256. package/dist/src/core/gesture-refusal.js.map +1 -0
  257. package/dist/src/core/git-run.d.ts +73 -0
  258. package/dist/src/core/harness-version.d.ts +157 -0
  259. package/dist/src/core/harness-version.js +4 -1
  260. package/dist/src/core/harness-version.js.map +1 -1
  261. package/dist/src/core/harness-wait.d.ts +55 -0
  262. package/dist/src/core/head-retry.d.ts +107 -0
  263. package/dist/src/core/instance.d.ts +310 -0
  264. package/dist/src/core/instance.js +113 -0
  265. package/dist/src/core/instance.js.map +1 -1
  266. package/dist/src/core/intake-limits.d.ts +247 -0
  267. package/dist/src/core/jcs.d.ts +52 -0
  268. package/dist/src/core/journal.d.ts +144 -0
  269. package/dist/src/core/live-draw.d.ts +436 -0
  270. package/dist/src/core/log-reconcile.d.ts +89 -0
  271. package/dist/src/core/log-subscribe.d.ts +36 -0
  272. package/dist/src/core/log-subscribe.js +162 -0
  273. package/dist/src/core/log-subscribe.js.map +1 -0
  274. package/dist/src/core/log.d.ts +316 -0
  275. package/dist/src/core/log.js.map +1 -1
  276. package/dist/src/core/loop.d.ts +274 -0
  277. package/dist/src/core/loop.js +11 -0
  278. package/dist/src/core/loop.js.map +1 -1
  279. package/dist/src/core/md-fence.d.ts +41 -0
  280. package/dist/src/core/money.d.ts +147 -0
  281. package/dist/src/core/payload-census.d.ts +74 -0
  282. package/dist/src/core/payload-store.d.ts +175 -0
  283. package/dist/src/core/payload.d.ts +71 -0
  284. package/dist/src/core/policy-diff.d.ts +292 -0
  285. package/dist/src/core/policy-diff.js +27 -4
  286. package/dist/src/core/policy-diff.js.map +1 -1
  287. package/dist/src/core/policy-expectations.d.ts +199 -0
  288. package/dist/src/core/policy-explain.d.ts +160 -0
  289. package/dist/src/core/policy-explain.js +63 -3
  290. package/dist/src/core/policy-explain.js.map +1 -1
  291. package/dist/src/core/policy-load.d.ts +567 -0
  292. package/dist/src/core/policy-load.js +36 -6
  293. package/dist/src/core/policy-load.js.map +1 -1
  294. package/dist/src/core/policy-match.d.ts +324 -0
  295. package/dist/src/core/policy-match.js +72 -9
  296. package/dist/src/core/policy-match.js.map +1 -1
  297. package/dist/src/core/policy-proposal.d.ts +317 -0
  298. package/dist/src/core/policy-proposal.js +102 -2
  299. package/dist/src/core/policy-proposal.js.map +1 -1
  300. package/dist/src/core/prompt-layout.d.ts +221 -0
  301. package/dist/src/core/protected-path-guard.d.ts +566 -0
  302. package/dist/src/core/protected-path-guard.js +848 -55
  303. package/dist/src/core/protected-path-guard.js.map +1 -1
  304. package/dist/src/core/question-preempted.d.ts +141 -0
  305. package/dist/src/core/question-preempted.js +152 -0
  306. package/dist/src/core/question-preempted.js.map +1 -0
  307. package/dist/src/core/read-scope.d.ts +172 -0
  308. package/dist/src/core/read-scope.js +252 -0
  309. package/dist/src/core/read-scope.js.map +1 -0
  310. package/dist/src/core/registration.d.ts +25 -0
  311. package/dist/src/core/reindex.d.ts +99 -0
  312. package/dist/src/core/sampler.d.ts +313 -0
  313. package/dist/src/core/sandbox.d.ts +371 -0
  314. package/dist/src/core/sandbox.js +190 -1
  315. package/dist/src/core/sandbox.js.map +1 -1
  316. package/dist/src/core/seal.d.ts +165 -0
  317. package/dist/src/core/sender-identity.d.ts +476 -0
  318. package/dist/src/core/sender-identity.js +572 -0
  319. package/dist/src/core/sender-identity.js.map +1 -0
  320. package/dist/src/core/shlex.d.ts +102 -0
  321. package/dist/src/core/shlex.js +159 -0
  322. package/dist/src/core/shlex.js.map +1 -0
  323. package/dist/src/core/state.d.ts +505 -0
  324. package/dist/src/core/task-file.d.ts +185 -0
  325. package/dist/src/core/telegram-config.d.ts +93 -0
  326. package/dist/src/core/token.d.ts +409 -0
  327. package/dist/src/core/token.js +21 -38
  328. package/dist/src/core/token.js.map +1 -1
  329. package/dist/src/core/validate.d.ts +138 -0
  330. package/dist/src/core/values.d.ts +147 -0
  331. package/dist/src/core/values.js +36 -1
  332. package/dist/src/core/values.js.map +1 -1
  333. package/dist/src/core/vault.d.ts +291 -0
  334. package/dist/src/core/verified-snapshot.d.ts +204 -0
  335. package/dist/src/core/verify.d.ts +336 -0
  336. package/dist/src/core/version.d.ts +8 -0
  337. package/dist/src/core/wysiwys.d.ts +370 -0
  338. package/dist/src/daemon/advance-child.d.ts +39 -0
  339. package/dist/src/daemon/advance.d.ts +476 -0
  340. package/dist/src/daemon/advance.js +25 -4
  341. package/dist/src/daemon/advance.js.map +1 -1
  342. package/dist/src/daemon/audit.d.ts +87 -0
  343. package/dist/src/daemon/daemon.d.ts +1180 -0
  344. package/dist/src/daemon/daemon.js +9 -0
  345. package/dist/src/daemon/daemon.js.map +1 -1
  346. package/dist/src/daemon/dark-session.d.ts +64 -0
  347. package/dist/src/daemon/draw-child.d.ts +36 -0
  348. package/dist/src/daemon/draw.d.ts +154 -0
  349. package/dist/src/daemon/git-evidence.d.ts +173 -0
  350. package/dist/src/daemon/git-evidence.js +1 -1
  351. package/dist/src/daemon/projection.d.ts +180 -0
  352. package/dist/src/daemon/prune.d.ts +207 -0
  353. package/dist/src/mcp/http.d.ts +113 -0
  354. package/dist/src/mcp/server.d.ts +265 -0
  355. package/dist/src/mcp/server.js +17 -1
  356. package/dist/src/mcp/server.js.map +1 -1
  357. package/docs/adapter-api.md +106 -0
  358. package/docs/cli-reference.md +1316 -63
  359. package/docs/codex-enforced-session.md +103 -0
  360. package/docs/codex-workspace-broker.md +118 -0
  361. package/package.json +14 -2
  362. package/schema/codex-instance.schema.json +82 -0
  363. package/schema/event.schema.json +539 -9
  364. package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
  365. package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
  366. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  367. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  368. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  369. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  370. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  371. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  372. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  373. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  374. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  375. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  376. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  377. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  378. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  379. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  380. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  381. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  382. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  383. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  384. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  385. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  386. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  387. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  388. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  389. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  390. package/schema/fixtures/policy/valid/canonical.json +1 -1
  391. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  392. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  393. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  394. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  395. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  396. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  397. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  398. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  399. package/schema/fixtures/values/invalid/version-float.json +1 -0
  400. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  401. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  402. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  403. package/schema/fixtures/values/valid/full.json +5 -7
  404. package/schema/fixtures/values/valid/minimal.json +1 -1
  405. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  406. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  407. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  408. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  409. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  410. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  411. package/schema/fixtures/values-md/valid/absent.md +1 -1
  412. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  413. package/schema/policy.schema.json +75 -3
  414. package/schema/values.schema.json +7 -11
  415. package/templates/codex/README.md +9 -0
  416. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -17,20 +17,32 @@
17
17
  * party under oversight to report its own oversight, which SPEC.md §11 rules
18
18
  * out on principle. So this one never talks to a session. It takes two commits,
19
19
  * asks git which protected paths changed between them, and requires — for each
20
- * one — evidence in the committed hash-chained log that a human saw that edit.
21
- * A change with no evidence fails the pull request. Session wiring is not an
22
- * input.
20
+ * one — exact evidence in the committed hash-chained log that either a human
21
+ * granted that edit or the runtime recorded that policy authorized its
22
+ * execution. A change with no evidence fails the pull request. Session wiring
23
+ * is not an input.
24
+ *
25
+ * ## The unit of judgment: one commit (APRV-375)
26
+ *
27
+ * This file answers about ONE PAIR OF BLOBS, whichever pair the caller hands
28
+ * it, so the caller's choice of pair is where the question is really settled.
29
+ * Both callers choose the same one: for every commit of the range, base is that
30
+ * commit's first parent and head is the commit, because a grant binds one edit
31
+ * and the combined diff of a branch is a change nobody made. `core/commit-guard.ts`
32
+ * enumerates the commits, builds the per-commit inputs, states the argument
33
+ * that nothing goes unjudged, and is shared by the CI script and by
34
+ * `approval doctor`'s dark-session arm A.
23
35
  *
24
36
  * ## What counts as evidence
25
37
  *
26
38
  * Evidence is about the CHANGE, not about the path (APRV-202). The guard reads
27
39
  * the blob at both commits, reduces the difference to the lines this pull
28
- * request adds and removes, and requires every one of them to trace back to the
29
- * bound material of some grant. A path that was granted last Tuesday and edited
30
- * again today has a grant naming it and no grant covering today's lines, and
31
- * that fails. Naming remains necessary; it stopped being sufficient.
40
+ * request adds and removes, and requires every one of them to trace back to
41
+ * bound material in an authorization record. A path authorized last Tuesday
42
+ * and edited again today has evidence naming it and none covering today's
43
+ * lines, and that fails. Naming remains necessary; it stopped being sufficient.
32
44
  *
33
- * Three verdicts pass, and they are ordered by how much they prove:
45
+ * Four verdicts pass:
34
46
  *
35
47
  * 1. `attested` — the policy file, and the gate's ORGANS. `approval policy
36
48
  * amend --commit` appends `policy.updated` carrying `{policy_path, sha256}`,
@@ -52,7 +64,41 @@
52
64
  * match: a digest attested for one organ is not evidence for another, which
53
65
  * is why the organ record carries a whole relative path where the policy
54
66
  * record carries a basename.
55
- * 2. `granted-file` — a file-tool edit. The hook binds the CHANGE rather than
67
+ *
68
+ * Since APRV-338 the verdict ALSO covers an ordinary `policy.edit` path on a
69
+ * `gate.path.signed_off` record carrying that path and the digest at head —
70
+ * and it is reached LAST, after every grant search below has failed. That
71
+ * ordering is the whole design. A sign-off is whole-file evidence: it says a
72
+ * human read this file at these bytes, not that they saw a particular line
73
+ * change. A grant binds the hunk. So a change a grant covers passes on the
74
+ * grant and prints the grant as its reason, and the sign-off answers only
75
+ * the case the pending-sign-off suffix was invented for — text a human has
76
+ * read and agrees with, for which no grant was ever taken. The organ's
77
+ * verdict stays first because an organ can have no grant at all, so for one
78
+ * there is nothing weaker to prefer.
79
+ * 2. `policy-authorized-file` — an exact Edit or Write whose verified
80
+ * `execution.started` is preceded by its unique matching registration and no
81
+ * approval request. The registration, start and recomputed stored payload
82
+ * must agree on task, action, class and hash. The start must precede the
83
+ * change and the recorded class must be the class this path is routed to.
84
+ * This records authorization to execute, not successful completion; the
85
+ * exact hunk checks below establish whether those bytes landed.
86
+ *
87
+ * The payload's `file` is the path the hook bound, and the hook binds an
88
+ * ABSOLUTE one: `fileToolGate` resolves the declared target against the
89
+ * session's `cwd`, so an Edit of SPEC.md in a worktree is recorded as
90
+ * `/Users/carter/dev/approval-md/.claude/worktrees/<name>/SPEC.md`. This
91
+ * tier accepts that shape and the bare repository-relative path, matching
92
+ * an absolute one by its trailing segments (see
93
+ * {@link namesProtectedFile}). Until APRV-337 it accepted only the relative
94
+ * shape, which no hook has ever written, so every unsampled
95
+ * supervised-live edit to SPEC.md failed CI (PR #393). Tail matching is
96
+ * sound HERE because the bytes carry the proof: `before` has to occur in
97
+ * the blob at base, `after` in the blob at head, and the replay has to
98
+ * reach HEAD byte-identical, so a scratch copy holding other bytes covers
99
+ * nothing whatever its path says. Verdict 4 below keeps the stricter
100
+ * cwd-join rule, because a command payload describes no bytes.
101
+ * 3. `granted-file` — a file-tool edit. The hook binds the CHANGE rather than
56
102
  * the touch (APRV-124), so the bound material carries `file` plus the exact
57
103
  * edit: `{before, after}` for an Edit, `{content}` for a Write. That is
58
104
  * HUNK-level evidence, and it is used as such. The granted `after` bytes
@@ -62,7 +108,7 @@
62
108
  * in head is a grant for something that did not land, and covers nothing.
63
109
  * Some payloads carry the `{input}` fallback shape instead, which describes
64
110
  * no bytes; those name the path and cover nothing.
65
- * 3. `granted-command` — a shell edit. The bound material is `{command, cwd}`
111
+ * 4. `granted-command` — a shell edit. The bound material is `{command, cwd}`
66
112
  * or `{argv, cwd}`, and the guard re-runs the runtime's own
67
113
  * {@link classifyCommand} over it, requiring a segment that classifies as a
68
114
  * granting class BECAUSE of a word naming this path. A mention is not a
@@ -126,6 +172,22 @@
126
172
  * against the head commit would have been theatre. The finding says which of
127
173
  * the two it got, every time.
128
174
  *
175
+ * ## Two anchors, because git keeps two dates (APRV-339)
176
+ *
177
+ * A commit carries an author date and a committer date, and they answer
178
+ * different questions. ORDERING — could this record have written these bytes,
179
+ * or does it come after them — is measured against the COMMITTER date, the
180
+ * moment those bytes were committed: an amend or a rebase only ever moves it
181
+ * later, so it cannot turn a genuine earlier start into a post-hoc one.
182
+ * STALENESS — is this evidence about this change or about some edit months ago
183
+ * — is measured against the AUTHOR date, which a rebase does not move, so
184
+ * replaying history does not expire evidence. Measuring both against the author
185
+ * date is what refused PR #393: `git commit --amend` kept the first author date
186
+ * while the bytes were committed 98 seconds later, and the unattended start
187
+ * that wrote them sat between the two and was read as having happened after the
188
+ * change. A caller that has only one date supplies it alone, and it answers
189
+ * both questions, which is where every caller stood before this.
190
+ *
129
191
  * ## How a hunk is decided to be covered
130
192
  *
131
193
  * The unit is a line of text. `added` is the multiset of lines the head blob
@@ -137,6 +199,18 @@
137
199
  * several approved edits to one file; the finding names every contributing
138
200
  * grant and puts the strongest and nearest at the head.
139
201
  *
202
+ * A bound Edit may describe LESS than a line, because the hook binds exactly
203
+ * what the tool replaced: rewriting part of a long paragraph binds a fragment
204
+ * and covers neither the line it removed nor the line it added. So before the
205
+ * replay below there is a line-local step (APRV-340). A fragment that occurs
206
+ * exactly once in exactly one line of the blob at base has one possible
207
+ * effect — that line, rewritten — and when the line it rewrites is one this
208
+ * change removes and the line it produces is one this change adds, both lines
209
+ * are credited to it. It is a one-step replay that needs no search, and it
210
+ * carries every eligibility condition the replay carries; what it does not
211
+ * carry is the replay's budget, which a 200 KB file with forty naming
212
+ * candidates exhausts before reaching a proof (PR #393).
213
+ *
140
214
  * Three properties of that choice are worth stating, because each is a limit:
141
215
  *
142
216
  * - A blob that differs while its line multiset does not is a REORDERING, and
@@ -190,8 +264,10 @@
190
264
  * This module appends nothing, reads no clock, and performs no IO of its own —
191
265
  * git plumbing and file reads live in the caller.
192
266
  */
193
- import { organAttestationOf } from "./attest.js";
194
- import { classifyCommand, isGateOrganPath, isProtectedPath, normalizePathSpelling, POLICY_EDIT_SUBCLASS, } from "./command-class.js";
267
+ import { organAttestationOf, pathSignOffOf } from "./attest.js";
268
+ import { parseApplyPatch } from "./apply-patch.js";
269
+ import { classifyCommand, isGateOrganPath, isProtectedPath, normalizePathSpelling, POLICY_EDIT_SUBCLASS, protectedPathClass, } from "./command-class.js";
270
+ import { payloadHash } from "./payload.js";
195
271
  /**
196
272
  * Classes whose grant authorizes a protected-path write.
197
273
  *
@@ -288,6 +364,10 @@ export const DEFAULT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
288
364
  * fresh approval, which costs one tap.
289
365
  */
290
366
  export const DEFAULT_COMMAND_ATTRIBUTION_MS = 6 * 60 * 60 * 1000;
367
+ /** Fail-closed resource bounds for exact protected-edit reconstruction. */
368
+ export const EXACT_REPLAY_MAX_CANDIDATES = 128;
369
+ export const EXACT_REPLAY_MAX_STATES = 2_048;
370
+ export const EXACT_REPLAY_MAX_EXAMINED_BYTES = 64 * 1024 * 1024;
291
371
  /** Split on either separator, dropping empties and `.` noise. */
292
372
  function segmentsOf(candidate) {
293
373
  return candidate.split(/[/\\]+/u).filter((part) => part.length > 0 && part !== ".");
@@ -301,6 +381,50 @@ function endsWithSegments(candidate, want) {
301
381
  const offset = have.length - tail.length;
302
382
  return tail.every((segment, index) => segment === have[offset + index]);
303
383
  }
384
+ /**
385
+ * Is this changed path the POLICY FILE, whichever end carries the longer
386
+ * spelling?
387
+ *
388
+ * The guard is handed a repository-relative path from git and a policy location
389
+ * from its caller, and either may be the more qualified of the two, so the test
390
+ * is run both ways. One named predicate rather than the comparison written out
391
+ * at each of the two sites that ask it.
392
+ */
393
+ function namesPolicyFile(path, policyPath) {
394
+ return endsWithSegments(policyPath, path) || endsWithSegments(path, policyPath);
395
+ }
396
+ /**
397
+ * Does a bound payload's `file` name this checkout's copy of `path`?
398
+ *
399
+ * The hook writes the ABSOLUTE path: `fileToolGate` resolves the declared
400
+ * target against the session's `cwd`, so a real Edit of SPEC.md in a worktree
401
+ * binds `/Users/carter/dev/approval-md/.claude/worktrees/<name>/SPEC.md`. Two
402
+ * shapes pass: the repository-relative path itself, and an absolute path whose
403
+ * TRAILING segments are that path. A relative path carrying extra leading
404
+ * directories (`dry/SPEC.md`) is refused, as is any `..` segment or backslash,
405
+ * either of which would make the tail test claim something it cannot know.
406
+ *
407
+ * Matching an absolute path by its tail is safe for Edit and Write material,
408
+ * and only for that material, because there the bytes are the proof: `before`
409
+ * has to occur in the blob at base, `after` in the blob at head, and the
410
+ * replay has to reach HEAD byte-identical. A payload naming another checkout's
411
+ * copy of SPEC.md carries that copy's bytes, which are not these, so it covers
412
+ * nothing. Command material describes no bytes at all, which is why
413
+ * {@link commandTargetsPath} keeps the stricter cwd-join rule for it.
414
+ */
415
+ function namesProtectedFile(file, path) {
416
+ if (file.includes("\\"))
417
+ return false;
418
+ const named = segmentsOf(file);
419
+ if (named.includes(".."))
420
+ return false;
421
+ const wanted = segmentsOf(path);
422
+ if (wanted.length === 0)
423
+ return false;
424
+ if (file.startsWith("/"))
425
+ return endsWithSegments(file, path);
426
+ return named.length === wanted.length && named.every((part, index) => part === wanted[index]);
427
+ }
304
428
  /**
305
429
  * Does this command line WRITE `path`, as the runtime's own classifier reads it?
306
430
  *
@@ -413,6 +537,31 @@ function evidenceFor(material, path, policyProtectedPaths) {
413
537
  if (typeof material !== "object" || material === null || Array.isArray(material))
414
538
  return null;
415
539
  const map = material;
540
+ // Codex apply_patch is tagged explicitly. Parse it before the generic
541
+ // command fallback so patch text is never mistaken for a shell command and
542
+ // never receives time-based command attribution. Until a patch-to-blob
543
+ // proof exists, this is deliberately naming-only evidence and covers no
544
+ // bytes; it remains useful diagnosis without weakening the guard.
545
+ if (map["tool"] === "apply_patch" && typeof map["command"] === "string") {
546
+ const parsed = parseApplyPatch(map["command"]);
547
+ if (parsed.ok) {
548
+ const operation = parsed.operations.find((candidate) => endsWithSegments(candidate.path, path) ||
549
+ (candidate.kind === "update" &&
550
+ candidate.moveTo !== undefined &&
551
+ endsWithSegments(candidate.moveTo, path)));
552
+ if (operation !== undefined) {
553
+ return {
554
+ kind: "granted-file",
555
+ detail: `the granted material is a Codex apply_patch operation naming ${path}; patch bytes are not yet guard coverage`,
556
+ after: null,
557
+ before: null,
558
+ whole: false,
559
+ command: false,
560
+ };
561
+ }
562
+ }
563
+ return null;
564
+ }
416
565
  const file = map["file"];
417
566
  if (typeof file === "string" && endsWithSegments(file, path)) {
418
567
  const tool = typeof map["tool"] === "string" ? map["tool"] : "a file tool";
@@ -476,6 +625,412 @@ function evidenceFor(material, path, policyProtectedPaths) {
476
625
  }
477
626
  return null;
478
627
  }
628
+ /** Every declaration of this action key that existed before the start. */
629
+ function precedingBindings(records, start) {
630
+ const actionKey = start.action_key;
631
+ if (actionKey === undefined)
632
+ return [];
633
+ const found = [];
634
+ for (const record of records) {
635
+ if (record.seq >= start.seq || record.event !== "task.registered")
636
+ continue;
637
+ const actions = payloadOf(record)["actions"];
638
+ if (!Array.isArray(actions))
639
+ continue;
640
+ for (const raw of actions) {
641
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
642
+ continue;
643
+ const action = raw;
644
+ if (action["idempotency_key"] !== actionKey)
645
+ continue;
646
+ const cls = action["class"];
647
+ const hash = action["payload_hash"];
648
+ // Count malformed declarations too. A malformed entry sharing the key is
649
+ // ambiguity, never something a later well-formed entry can hide.
650
+ found.push({
651
+ record,
652
+ task: record.task ?? null,
653
+ actionKey,
654
+ cls: typeof cls === "string" ? cls : null,
655
+ payloadHash: typeof hash === "string" ? hash : null,
656
+ });
657
+ }
658
+ }
659
+ return found;
660
+ }
661
+ /** Exact file bytes, excluding fallback, command and apply_patch material. */
662
+ function policyFileEvidence(material, path, policyProtectedPaths) {
663
+ if (typeof material !== "object" || material === null || Array.isArray(material))
664
+ return null;
665
+ const map = material;
666
+ const tool = map["tool"];
667
+ const file = map["file"];
668
+ if (typeof file !== "string" || !namesProtectedFile(file, path))
669
+ return null;
670
+ const keys = Object.keys(map);
671
+ const hasOnly = (allowed) => keys.every((key) => allowed.includes(key));
672
+ if (tool === "Edit") {
673
+ if (!hasOnly(["tool", "rule", "file", "before", "after", "replace_all"]) ||
674
+ (map["rule"] !== undefined && typeof map["rule"] !== "string") ||
675
+ typeof map["before"] !== "string" ||
676
+ typeof map["after"] !== "string" ||
677
+ (map["replace_all"] !== undefined && typeof map["replace_all"] !== "boolean"))
678
+ return null;
679
+ }
680
+ else if (tool === "Write") {
681
+ if (!hasOnly(["tool", "rule", "file", "content"]) ||
682
+ (map["rule"] !== undefined && typeof map["rule"] !== "string") ||
683
+ typeof map["content"] !== "string")
684
+ return null;
685
+ }
686
+ else {
687
+ return null;
688
+ }
689
+ const match = evidenceFor(material, path, policyProtectedPaths);
690
+ if (match === null || match.command || (match.after === null && match.before === null))
691
+ return null;
692
+ return {
693
+ ...match,
694
+ kind: "policy-authorized-file",
695
+ detail: match.detail.replace(/^the granted material is/u, "the policy-authorized material is"),
696
+ };
697
+ }
698
+ /** Differing applicable routes in an unlabeled policy union carry no single class. */
699
+ function hasAmbiguousRoute(path, policyProtectedPaths) {
700
+ const candidate = segmentsOf(path);
701
+ const classes = new Set();
702
+ for (const entry of policyProtectedPaths) {
703
+ const raw = typeof entry === "string" ? entry : entry.path;
704
+ const trimmed = raw.trim();
705
+ if (trimmed.length === 0 || trimmed.startsWith("/"))
706
+ continue;
707
+ const wanted = segmentsOf(trimmed);
708
+ if (wanted.includes("..") || wanted.length === 0 || wanted.length > candidate.length)
709
+ continue;
710
+ const directory = /[/\\]$/u.test(trimmed);
711
+ const matches = directory
712
+ ? candidate.some((_, start) => start + wanted.length <= candidate.length &&
713
+ wanted.every((segment, offset) => candidate[start + offset] === segment))
714
+ : wanted.every((segment, offset) => candidate[candidate.length - wanted.length + offset] === segment);
715
+ if (matches)
716
+ classes.add(typeof entry === "string" ? "policy.edit" : entry.class);
717
+ }
718
+ return classes.size > 1;
719
+ }
720
+ /** Normalize whatever the caller supplied into one {@link ChangeAnchor}. */
721
+ function changeAnchorOf(supplied) {
722
+ const pair = supplied === null || typeof supplied === "string"
723
+ ? { author: supplied, committer: supplied }
724
+ : supplied;
725
+ const authorTs = pair.author ?? pair.committer;
726
+ const committerTs = pair.committer ?? pair.author;
727
+ const parse = (ts) => {
728
+ if (ts === null)
729
+ return null;
730
+ const ms = Date.parse(ts);
731
+ return Number.isNaN(ms) ? null : ms;
732
+ };
733
+ const authorMs = parse(authorTs);
734
+ const committerMs = parse(committerTs);
735
+ return {
736
+ authorTs,
737
+ committerTs,
738
+ authorMs: authorMs ?? committerMs,
739
+ committerMs: committerMs ?? authorMs,
740
+ };
741
+ }
742
+ /**
743
+ * Exact-file evidence from an unattended execution authorized by policy.
744
+ *
745
+ * The start must reproduce the one declaration that preceded it, and the
746
+ * committed payload must hash back to that declaration. A prior request means
747
+ * the action entered the human-decision path, so this tier cannot carry it.
748
+ */
749
+ function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, payloadFor, anchor, lookbackMs) {
750
+ if (start.event !== "execution.started")
751
+ return null;
752
+ const task = start.task;
753
+ const actionKey = start.action_key;
754
+ if (task === undefined || actionKey === undefined)
755
+ return null;
756
+ const started = payloadOf(start);
757
+ const harnessStart = started["execution"] === "harness";
758
+ const coreStart = !Object.hasOwn(started, "execution");
759
+ if ((!harnessStart && !coreStart) ||
760
+ Object.hasOwn(started, "grant_seq") ||
761
+ Object.hasOwn(started, "token_sha256"))
762
+ return null;
763
+ const cls = started["class"];
764
+ const hash = started["payload_hash"];
765
+ if (typeof cls !== "string" || typeof hash !== "string")
766
+ return null;
767
+ // The exact routed class is authority. Merely living in policy.edit.* is not.
768
+ if (hasAmbiguousRoute(path, policyProtectedPaths))
769
+ return null;
770
+ const routed = protectedPathClass(path, policyProtectedPaths);
771
+ if (routed === null || routed === "policy.core" || routed === "log.mutate" || cls !== routed) {
772
+ return null;
773
+ }
774
+ const bindings = precedingBindings(records, start);
775
+ if (bindings.length !== 1)
776
+ return null;
777
+ const binding = bindings[0];
778
+ if (binding.task !== task ||
779
+ binding.actionKey !== actionKey ||
780
+ binding.cls !== cls ||
781
+ binding.payloadHash !== hash)
782
+ return null;
783
+ if (records.some((record) => record.seq < start.seq &&
784
+ record.action_key === actionKey &&
785
+ record.event === "approval.requested"))
786
+ return null;
787
+ // A start cannot authorize a change that already happened, and a start from
788
+ // months ago is about some earlier edit. Ordering is asked of the COMMITTER
789
+ // date and staleness of the AUTHOR date (APRV-339). This tier also requires a
790
+ // usable commit timestamp; missing temporal evidence fails closed.
791
+ const { authorMs, committerMs } = anchor;
792
+ if (authorMs === null || committerMs === null)
793
+ return null;
794
+ const at = Date.parse(start.ts);
795
+ if (Number.isNaN(at) || at > committerMs || authorMs - at > lookbackMs)
796
+ return null;
797
+ const material = payloadFor(hash);
798
+ if (material === null)
799
+ return null;
800
+ try {
801
+ if (payloadHash(material) !== hash)
802
+ return null;
803
+ }
804
+ catch {
805
+ return null;
806
+ }
807
+ return policyFileEvidence(material, path, policyProtectedPaths);
808
+ }
809
+ /** The closed Edit shape, naming this path, eligible for byte replay. */
810
+ function exactReplayEdit(material, path) {
811
+ if (typeof material !== "object" || material === null || Array.isArray(material))
812
+ return null;
813
+ const map = material;
814
+ const keys = Object.keys(map);
815
+ const allowed = ["tool", "rule", "file", "before", "after", "replace_all"];
816
+ if (keys.some((key) => !allowed.includes(key)))
817
+ return null;
818
+ if (map["tool"] !== "Edit" ||
819
+ typeof map["file"] !== "string" ||
820
+ !namesProtectedFile(map["file"], path) ||
821
+ (map["rule"] !== undefined && typeof map["rule"] !== "string") ||
822
+ typeof map["before"] !== "string" ||
823
+ map["before"].length === 0 ||
824
+ typeof map["after"] !== "string" ||
825
+ map["before"] === map["after"] ||
826
+ (map["replace_all"] !== undefined && map["replace_all"] !== false))
827
+ return null;
828
+ return { before: map["before"], after: map["after"] };
829
+ }
830
+ /** The one token or harness spend that proves this manual grant actually ran. */
831
+ function startForReplayGrant(candidate, records, anchor, lookbackMs, path, policyProtectedPaths) {
832
+ const { authorMs, committerMs } = anchor;
833
+ if (authorMs === null || committerMs === null)
834
+ return null;
835
+ const grant = candidate.record;
836
+ const task = grant.task;
837
+ const actionKey = grant.action_key;
838
+ if (task === undefined || actionKey === undefined)
839
+ return null;
840
+ const granted = payloadOf(grant);
841
+ const cls = granted["class"];
842
+ const hash = granted["payload_hash"];
843
+ if (typeof cls !== "string" || typeof hash !== "string" || hash !== candidate.payloadHash)
844
+ return null;
845
+ if (hasAmbiguousRoute(path, policyProtectedPaths))
846
+ return null;
847
+ const routed = protectedPathClass(path, policyProtectedPaths);
848
+ if (routed === null || routed === "policy.core" || routed === "log.mutate" || cls !== routed) {
849
+ return null;
850
+ }
851
+ try {
852
+ if (payloadHash(candidate.material) !== hash)
853
+ return null;
854
+ }
855
+ catch {
856
+ return null;
857
+ }
858
+ const grantToken = granted["token_sha256"];
859
+ const starts = records.filter((record) => {
860
+ if (record.event !== "execution.started" ||
861
+ record.seq <= grant.seq ||
862
+ record.task !== task ||
863
+ record.action_key !== actionKey)
864
+ return false;
865
+ const started = payloadOf(record);
866
+ if (started["class"] !== cls || started["payload_hash"] !== hash)
867
+ return false;
868
+ if (Object.hasOwn(started, "execution") && started["execution"] !== "harness")
869
+ return false;
870
+ const hasGrantLink = Object.hasOwn(started, "grant_seq");
871
+ const hasTokenLink = Object.hasOwn(started, "token_sha256");
872
+ if (!hasGrantLink && !hasTokenLink)
873
+ return false;
874
+ if (hasGrantLink && started["grant_seq"] !== grant.seq)
875
+ return false;
876
+ if (hasTokenLink &&
877
+ (typeof grantToken !== "string" || started["token_sha256"] !== grantToken))
878
+ return false;
879
+ // Ordering against the committer date, staleness against the author date
880
+ // (APRV-339), exactly as the policy-authorized tier asks them.
881
+ const at = Date.parse(record.ts);
882
+ return !Number.isNaN(at) && at <= committerMs && authorMs - at <= lookbackMs;
883
+ });
884
+ if (starts.length !== 1)
885
+ return null;
886
+ const start = starts[0];
887
+ const bindings = precedingBindings(records, start);
888
+ if (bindings.length !== 1)
889
+ return null;
890
+ const binding = bindings[0];
891
+ if (binding.record.seq >= grant.seq ||
892
+ binding.task !== task ||
893
+ binding.actionKey !== actionKey ||
894
+ binding.cls !== cls ||
895
+ binding.payloadHash !== hash)
896
+ return null;
897
+ const requested = records.some((record) => {
898
+ if (record.event !== "approval.requested" ||
899
+ record.seq <= binding.record.seq ||
900
+ record.seq >= grant.seq ||
901
+ record.task !== task ||
902
+ record.action_key !== actionKey)
903
+ return false;
904
+ const payload = payloadOf(record);
905
+ return payload["class"] === cls && payload["payload_hash"] === hash;
906
+ });
907
+ return requested ? start : null;
908
+ }
909
+ /**
910
+ * Replay authorized exact edits in execution order and demand byte equality.
911
+ *
912
+ * The log may contain older edits whose anchors survive in BASE but which did
913
+ * not produce this change. Search deterministic ordered subsequences: skip a
914
+ * candidate first, and apply it only when its anchor occurs exactly once in the
915
+ * current state. An ambiguous candidate cannot be applied, but it does not make
916
+ * an independent later proof ambiguous. Only a non-empty sequence that turns
917
+ * BASE into HEAD byte for byte is evidence; there is no reordering, substring
918
+ * credit, or partial result.
919
+ *
920
+ * Search is bounded by candidate count, distinct states, and cumulative bytes
921
+ * examined. Crossing any bound refuses the fallback rather than pruning a
922
+ * potentially valid branch and claiming the remaining search was complete.
923
+ */
924
+ function exactEditReplay(candidates, records, base, head, anchor, lookbackMs, path, policyProtectedPaths) {
925
+ if (base === null ||
926
+ head === null ||
927
+ base.includes("\uFFFD") ||
928
+ head.includes("\uFFFD"))
929
+ return { steps: null, bound: null };
930
+ const gathered = [];
931
+ for (const candidate of candidates) {
932
+ try {
933
+ if (payloadHash(candidate.material) !== candidate.payloadHash)
934
+ continue;
935
+ }
936
+ catch {
937
+ continue;
938
+ }
939
+ const edit = exactReplayEdit(candidate.material, path);
940
+ if (edit === null)
941
+ continue;
942
+ const start = candidate.source === "policy"
943
+ ? candidate.record
944
+ : startForReplayGrant(candidate, records, anchor, lookbackMs, path, policyProtectedPaths);
945
+ if (start !== null)
946
+ gathered.push({ candidate, start, ...edit });
947
+ }
948
+ gathered.sort((left, right) => left.start.seq - right.start.seq);
949
+ // A verified execution record can authorize at most one application. Collapse
950
+ // duplicate evidence views of the same binding. If two views give one start
951
+ // conflicting edit bytes, neither representation of that start is usable.
952
+ const byStart = new Map();
953
+ const conflictingStarts = new Set();
954
+ for (const step of gathered) {
955
+ const prior = byStart.get(step.start.seq);
956
+ if (prior === undefined) {
957
+ byStart.set(step.start.seq, step);
958
+ continue;
959
+ }
960
+ if (prior.before !== step.before || prior.after !== step.after) {
961
+ conflictingStarts.add(step.start.seq);
962
+ }
963
+ }
964
+ const steps = [...byStart.values()].filter((step) => !conflictingStarts.has(step.start.seq));
965
+ if (steps.length > EXACT_REPLAY_MAX_CANDIDATES) {
966
+ return { steps: null, bound: "candidate-limit" };
967
+ }
968
+ const headBytes = Buffer.byteLength(head, "utf8");
969
+ let examinedBytes = Buffer.byteLength(base, "utf8") + headBytes;
970
+ if (examinedBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
971
+ return { steps: null, bound: "byte-limit" };
972
+ }
973
+ let visitedStates = 0;
974
+ let exceeded = null;
975
+ // State equality is exact string equality, not a digest or lossy cache key.
976
+ // Empty/non-empty histories stay distinct because an empty replay is never
977
+ // evidence even when BASE already equals HEAD.
978
+ const visited = Array.from({ length: steps.length + 1 }, () => [new Set(), new Set()]);
979
+ const search = (index, replayed, applied) => {
980
+ if (exceeded !== null)
981
+ return null;
982
+ const replayedBytes = Buffer.byteLength(replayed, "utf8");
983
+ // Charge exact-state hashing/equality and the HEAD comparison before either
984
+ // can retain or accept this state.
985
+ const stateBytes = replayedBytes + headBytes;
986
+ if (examinedBytes + stateBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
987
+ exceeded = "byte-limit";
988
+ return null;
989
+ }
990
+ examinedBytes += stateBytes;
991
+ const seen = visited[index]?.[applied.length === 0 ? 0 : 1];
992
+ if (seen === undefined || seen.has(replayed))
993
+ return null;
994
+ seen.add(replayed);
995
+ visitedStates += 1;
996
+ if (visitedStates > EXACT_REPLAY_MAX_STATES) {
997
+ exceeded = "state-limit";
998
+ return null;
999
+ }
1000
+ if (applied.length > 0 && replayed === head)
1001
+ return [...applied];
1002
+ if (index === steps.length)
1003
+ return null;
1004
+ // Deterministic skip-first DFS finds a late witness quickly and
1005
+ // prevents irrelevant historical edits from polluting a valid proof.
1006
+ const skipped = search(index + 1, replayed, applied);
1007
+ if (skipped !== null || exceeded !== null)
1008
+ return skipped;
1009
+ const step = steps[index];
1010
+ const scanBytes = replayedBytes * 2 +
1011
+ Buffer.byteLength(step.before, "utf8");
1012
+ if (examinedBytes + scanBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
1013
+ exceeded = "byte-limit";
1014
+ return null;
1015
+ }
1016
+ examinedBytes += scanBytes;
1017
+ const first = replayed.indexOf(step.before);
1018
+ if (first === -1 || replayed.indexOf(step.before, first + 1) !== -1)
1019
+ return null;
1020
+ // Bound the next allocation before constructing it. `replayed + after` is
1021
+ // a conservative upper bound because the replacement removes `before`.
1022
+ const prospectiveBytes = replayedBytes + Buffer.byteLength(step.after, "utf8");
1023
+ if (examinedBytes + prospectiveBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
1024
+ exceeded = "byte-limit";
1025
+ return null;
1026
+ }
1027
+ examinedBytes += prospectiveBytes;
1028
+ const next = `${replayed.slice(0, first)}${step.after}${replayed.slice(first + step.before.length)}`;
1029
+ return search(index + 1, next, [...applied, step]);
1030
+ };
1031
+ const proof = search(0, base, []);
1032
+ return { steps: proof, bound: exceeded };
1033
+ }
479
1034
  /** A blob's lines, with the empty tail a trailing newline leaves dropped. */
480
1035
  function linesOf(text) {
481
1036
  const parts = text.split("\n");
@@ -483,6 +1038,38 @@ function linesOf(text) {
483
1038
  parts.pop();
484
1039
  return parts;
485
1040
  }
1041
+ /**
1042
+ * Where a fragment anchors in the blob at base, and what replacing it yields.
1043
+ *
1044
+ * `null` unless the fragment sits inside ONE line and occurs exactly once in
1045
+ * the whole file: a fragment with two homes does not say which occurrence the
1046
+ * human approved, and a guess is not evidence. A fragment carrying a newline is
1047
+ * not line-local at all and belongs to the global replay.
1048
+ *
1049
+ * Applying a uniquely-anchored edit to base can only produce base with that one
1050
+ * line rewritten, so this is a one-step replay stated as the line it changes —
1051
+ * the arithmetic the caller then checks against the hunks (APRV-340).
1052
+ */
1053
+ function lineLocalReplacement(baseLines, edit) {
1054
+ if (edit.before.includes("\n"))
1055
+ return null;
1056
+ let found = null;
1057
+ for (const line of baseLines) {
1058
+ const first = line.indexOf(edit.before);
1059
+ if (first === -1)
1060
+ continue;
1061
+ if (found !== null || line.indexOf(edit.before, first + 1) !== -1)
1062
+ return null;
1063
+ found = { line, at: first };
1064
+ }
1065
+ if (found === null)
1066
+ return null;
1067
+ const { line, at } = found;
1068
+ return {
1069
+ removed: line,
1070
+ added: `${line.slice(0, at)}${edit.after}${line.slice(at + edit.before.length)}`,
1071
+ };
1072
+ }
486
1073
  /** A line that carries content. Blank lines neither need coverage nor give it. */
487
1074
  function substantive(line) {
488
1075
  return line.trim().length > 0;
@@ -555,8 +1142,8 @@ function windowText(window) {
555
1142
  }
556
1143
  /**
557
1144
  * How far past the change commit a run may still start and be its cause: five
558
- * minutes, for clock disagreement between the log and git's author date. It is
559
- * a skew allowance, not an ordering allowance.
1145
+ * minutes, for clock disagreement between the log and git's committer date. It
1146
+ * is a skew allowance, not an ordering allowance.
560
1147
  */
561
1148
  const SKEW_GRACE_MS = 5 * 60 * 1000;
562
1149
  /** Milliseconds as something a failure message can say out loud. */
@@ -580,7 +1167,12 @@ function spanText(ms) {
580
1167
  * interval it actually occupied: a batch that started before the commit and
581
1168
  * finished after it brackets the commit, which is the strongest form of this.
582
1169
  */
583
- function attributeRun(grant, runs, anchorMs, attributionMs) {
1170
+ function attributeRun(grant, runs, anchor, attributionMs) {
1171
+ // The same split the other two tiers make (APRV-339): "did this run start
1172
+ // after the bytes were committed" is asked of the COMMITTER date, which an
1173
+ // amend may only move later, and "how far from the change is it" of the
1174
+ // AUTHOR date, which a rebase does not move.
1175
+ const { authorMs, committerMs } = anchor;
584
1176
  const key = grant.action_key;
585
1177
  if (key === undefined) {
586
1178
  return {
@@ -598,7 +1190,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
598
1190
  }
599
1191
  const completed = tied.filter((record) => record.event === "execution.completed");
600
1192
  const endOf = (record) => completed.find((done) => done.seq > record.seq);
601
- if (anchorMs === null) {
1193
+ if (authorMs === null || committerMs === null) {
602
1194
  const first = started[0];
603
1195
  return {
604
1196
  ok: true,
@@ -611,7 +1203,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
611
1203
  // the record is appended before the process is spawned. The real log shows
612
1204
  // what the symmetric window costs — a SPEC.md batch run four hours AFTER
613
1205
  // PR #187's commit would otherwise have carried that commit's changes.
614
- // `SKEW_GRACE_MS` is for the two clocks (the log's and git's author date)
1206
+ // `SKEW_GRACE_MS` is for the two clocks (the log's and git's committer date)
615
1207
  // disagreeing, not for ordering.
616
1208
  let best = null;
617
1209
  let laterOnly = null;
@@ -619,7 +1211,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
619
1211
  const from = Date.parse(record.ts);
620
1212
  if (Number.isNaN(from))
621
1213
  continue;
622
- if (from > anchorMs + SKEW_GRACE_MS) {
1214
+ if (from > committerMs + SKEW_GRACE_MS) {
623
1215
  if (laterOnly === null)
624
1216
  laterOnly = record;
625
1217
  continue;
@@ -627,9 +1219,9 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
627
1219
  const end = endOf(record);
628
1220
  const to = end === undefined ? from : Date.parse(end.ts);
629
1221
  const upper = Number.isNaN(to) ? from : to;
630
- const distance = anchorMs >= Math.min(from, upper) && anchorMs <= Math.max(from, upper)
1222
+ const distance = authorMs >= Math.min(from, upper) && authorMs <= Math.max(from, upper)
631
1223
  ? 0
632
- : Math.min(Math.abs(from - anchorMs), Math.abs(upper - anchorMs));
1224
+ : Math.min(Math.abs(from - authorMs), Math.abs(upper - authorMs));
633
1225
  if (best === null || distance < best.distance)
634
1226
  best = { record, end, distance };
635
1227
  }
@@ -667,7 +1259,41 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
667
1259
  * spelled: two different (path, digest) pairs cannot collide into one key.
668
1260
  */
669
1261
  function organKey(organPath, sha256) {
670
- return `${normalizePathSpelling(organPath)}${sha256}`;
1262
+ return `${normalizePathSpelling(organPath)}\0${sha256}`;
1263
+ }
1264
+ /**
1265
+ * May this path be evidenced by a `gate.path.signed_off` record? (APRV-338.)
1266
+ *
1267
+ * `policy.edit` and its sub-classes, and nothing else. The organs and the
1268
+ * approval home are `policy.core` and answered by their own record; the log
1269
+ * directory is `log.mutate` and answered by nobody. Asked with the policy's own
1270
+ * entries, so a path a project routed to `policy.edit.design` is signable while
1271
+ * a path no list protects is not.
1272
+ */
1273
+ function signOffEligible(path, extra) {
1274
+ const routed = protectedPathClass(path, extra);
1275
+ return routed === "policy.edit" || (routed !== null && POLICY_EDIT_SUBCLASS.test(routed));
1276
+ }
1277
+ /**
1278
+ * The sentence a failing `policy.edit` path gets about the sign-off route, or
1279
+ * nothing at all.
1280
+ *
1281
+ * Deliberately last in every message it appears in, and deliberately hedged.
1282
+ * The first repair for an uncovered change is to take it to the gate, which
1283
+ * binds the hunk; a sign-off stands for the whole file and is what the
1284
+ * pending-sign-off suffix was invented for — text a human has read at this
1285
+ * commit and agrees with. Printing it first would read as an invitation to
1286
+ * route around the gate, which is the one thing this guard exists to notice.
1287
+ */
1288
+ function signOffRepair(path, digest) {
1289
+ if (digest === null)
1290
+ return "";
1291
+ // The two flags are named because the digest is the thing that has to match,
1292
+ // and the bytes under review are on the BRANCH while the log lives in the
1293
+ // primary checkout. `--dir` says which bytes to hash and `--log` says where
1294
+ // the record goes, so one command can span both without the log ever being
1295
+ // written from a worktree.
1296
+ return ` If a human has READ this change at this commit and stands behind the file as it now is, they may ratify it with \`approval policy attest --path ${path} --dir <a checkout at this commit> --log <the primary checkout's log> --as human:<id>\`, which must hash to ${digest}, followed by a log advance carrying that record; that is whole-file evidence and weaker than a grant, so prefer the gate wherever the edit can still go through it.`;
671
1297
  }
672
1298
  /** The ordering rule, stated identically on every failure that could be lag. */
673
1299
  const ORDERING_RULE = "the committed log trails the primary checkout's live log, so if this edit WAS granted, " +
@@ -733,6 +1359,18 @@ export function evaluateProtectedPaths(input) {
733
1359
  continue;
734
1360
  organAttestations.set(organKey(fields.organPath, fields.sha256), record);
735
1361
  }
1362
+ // The SIGN-OFF index (APRV-338), keyed the same way and fed by its own event
1363
+ // type, so a sign-off can never answer an organ's question or the policy
1364
+ // file's and neither can answer a sign-off's. Built here and consulted at the
1365
+ // END of each path's evidence search: see the module note on why hunk
1366
+ // evidence has to be preferred over whole-file evidence.
1367
+ const signOffs = new Map();
1368
+ for (const record of records) {
1369
+ const fields = pathSignOffOf(record);
1370
+ if (fields === null)
1371
+ continue;
1372
+ signOffs.set(organKey(fields.path, fields.sha256), record);
1373
+ }
736
1374
  // The grants of a class that can authorize a protected write.
737
1375
  const grants = records.filter((record) => record.event === "approval.granted" &&
738
1376
  isGrantingClass(String(payloadOf(record)["class"] ?? "")));
@@ -757,8 +1395,7 @@ export function evaluateProtectedPaths(input) {
757
1395
  const findings = [];
758
1396
  for (const path of guarded) {
759
1397
  // 1. The policy file, by attestation.
760
- if (endsWithSegments(input.policyPath, path) ||
761
- endsWithSegments(path, input.policyPath)) {
1398
+ if (namesPolicyFile(path, input.policyPath)) {
762
1399
  if (input.policySha256AtHead !== null) {
763
1400
  const attested = attestations.get(input.policySha256AtHead);
764
1401
  if (attested !== undefined) {
@@ -806,18 +1443,24 @@ export function evaluateProtectedPaths(input) {
806
1443
  const lookbackMs = input.lookbackMs ?? DEFAULT_LOOKBACK_MS;
807
1444
  // ONE derivation of the anchor, so the bound that is enforced and the bound
808
1445
  // that is reported cannot disagree. An unparseable timestamp is no anchor,
809
- // exactly as a missing one is not: both land in `anchorMs === null`.
810
- const parsed = changeTs === null ? Number.NaN : Date.parse(changeTs);
811
- const anchorMs = Number.isNaN(parsed) ? null : parsed;
1446
+ // exactly as a missing one is not: both land in `anchorMs === null`. Two
1447
+ // instants since APRV-339, and the one asked here is the AUTHOR date: this
1448
+ // is the staleness pre-filter, and ordering belongs to the tiers that can
1449
+ // say which record wrote which bytes.
1450
+ const anchor = changeAnchorOf(changeTs);
1451
+ const anchorMs = anchor.authorMs;
812
1452
  const inWindow = (ts) => {
813
1453
  if (anchorMs === null)
814
1454
  return true;
815
1455
  const at = Date.parse(ts);
816
1456
  return !Number.isNaN(at) && Math.abs(at - anchorMs) <= lookbackMs;
817
1457
  };
1458
+ const datedText = anchor.committerTs === null || anchor.committerTs === anchor.authorTs
1459
+ ? `${anchor.authorTs}`
1460
+ : `authored ${anchor.authorTs}, committed ${anchor.committerTs}`;
818
1461
  const boundText = anchorMs === null
819
- ? `no usable commit timestamp for this path (${changeTs === null ? "git named none" : `git named ${JSON.stringify(changeTs)}, which does not parse`}), so NO recency bound was applied and this evidence rests on the path match alone`
820
- : `within ${Math.round(lookbackMs / 86_400_000)}d of the commit that changed it (${changeTs})`;
1462
+ ? `no usable commit timestamp for this path (${anchor.authorTs === null ? "git named none" : `git named ${JSON.stringify(datedText)}, which does not parse`}), so NO recency bound was applied and this evidence rests on the path match alone`
1463
+ : `within ${Math.round(lookbackMs / 86_400_000)}d of the commit that changed it (${datedText})`;
821
1464
  // EVERY qualifying grant is collected and the best one is reported, rather
822
1465
  // than the first one found. The first-match version passed commit 41d2c9f
823
1466
  // on a `cp SPEC.md <dir>/` granted four days earlier while the grant that
@@ -844,8 +1487,21 @@ export function evaluateProtectedPaths(input) {
844
1487
  stale.push(grant);
845
1488
  continue;
846
1489
  }
847
- candidates.push({ grant, match: found });
1490
+ candidates.push({ record: grant, match: found, source: "grant", material, payloadHash: hash });
848
1491
  }
1492
+ for (const start of records) {
1493
+ const found = policyAuthorizedEvidence(records, start, path, input.policyProtectedPaths, input.payloadFor, anchor, lookbackMs);
1494
+ const hash = payloadOf(start)["payload_hash"];
1495
+ if (found !== null && typeof hash === "string") {
1496
+ const material = input.payloadFor(hash);
1497
+ if (material !== null) {
1498
+ candidates.push({ record: start, match: found, source: "policy", material, payloadHash: hash });
1499
+ }
1500
+ }
1501
+ }
1502
+ const evidenceNoun = candidates.every((candidate) => candidate.source === "grant")
1503
+ ? `grant${candidates.length === 1 ? "" : "s"}`
1504
+ : `evidence record${candidates.length === 1 ? "" : "s"}`;
849
1505
  /** Distance from the change commit, or 0 when there is no anchor to measure from. */
850
1506
  const distance = (grant) => {
851
1507
  if (anchorMs === null)
@@ -853,9 +1509,9 @@ export function evaluateProtectedPaths(input) {
853
1509
  const at = Date.parse(grant.ts);
854
1510
  return Number.isNaN(at) ? Number.POSITIVE_INFINITY : Math.abs(at - anchorMs);
855
1511
  };
856
- const rank = (kind) => (kind === "granted-file" ? 0 : 1);
1512
+ const rank = (kind) => kind === "granted-file" ? 0 : kind === "policy-authorized-file" ? 1 : 2;
857
1513
  candidates.sort((left, right) => rank(left.match.kind) - rank(right.match.kind) ||
858
- distance(left.grant) - distance(right.grant));
1514
+ distance(left.record) - distance(right.record));
859
1515
  // The change this pull request makes to the path. Read before any coverage
860
1516
  // is attempted, and a failure to read it is a failure: a change the guard
861
1517
  // cannot see is not a change it has checked (APRV-202).
@@ -865,7 +1521,7 @@ export function evaluateProtectedPaths(input) {
865
1521
  path,
866
1522
  ok: false,
867
1523
  code: "change-unreadable",
868
- detail: `${path} is a protected path and changed between ${input.window.base} and ${input.window.head}, and its bytes could not be read at both commits (git could not show the blob, or it is binary), so no grant could be checked against the change. ${candidates.length} grant${candidates.length === 1 ? "" : "s"} name this path in the window, and naming is not coverage. ${ORDERING_RULE}.`,
1524
+ detail: `${path} is a protected path and changed between ${input.window.base} and ${input.window.head}, and its bytes could not be read at both commits (git could not show the blob, or it is binary), so no evidence could be checked against the change. ${candidates.length} ${evidenceNoun} name this path in the window, and naming is not coverage. ${ORDERING_RULE}.`,
869
1525
  });
870
1526
  continue;
871
1527
  }
@@ -881,14 +1537,21 @@ export function evaluateProtectedPaths(input) {
881
1537
  const removedCover = new Set();
882
1538
  let whole = false;
883
1539
  for (const candidate of candidates) {
884
- const { grant, match } = candidate;
885
- const at = `the grant at seq ${grant.seq}`;
1540
+ const { record, match, source } = candidate;
1541
+ // An execution start says an exact file action was authorized, not that a
1542
+ // mode-only or whitespace-only change happened. Legacy human grants retain
1543
+ // their narrow no-substantive-hunk behavior; policy starts do not gain it.
1544
+ if (hunks.identical && source === "policy")
1545
+ continue;
1546
+ const at = source === "grant"
1547
+ ? `the grant at seq ${record.seq}`
1548
+ : `the policy-authorized execution at seq ${record.seq}`;
886
1549
  if (match.command) {
887
1550
  if (!commandTargetsPath(match.target ?? "", match.cwd, path)) {
888
1551
  rejected.push(`${at} writes ${JSON.stringify(match.target ?? "")} from ${JSON.stringify(match.cwd ?? "(no cwd recorded)")}, which is not this checkout's ${path}: a granted write to a copy of the file elsewhere (a dry run into a scratch directory, another worktree) authorizes nothing here`);
889
1552
  continue;
890
1553
  }
891
- const run = attributeRun(grant, runs, anchorMs, attributionMs);
1554
+ const run = attributeRun(record, runs, anchor, attributionMs);
892
1555
  if (!run.ok) {
893
1556
  rejected.push(run.why);
894
1557
  continue;
@@ -896,7 +1559,7 @@ export function evaluateProtectedPaths(input) {
896
1559
  // A command payload describes no bytes, so attribution is all or
897
1560
  // nothing: the run that wrote this file wrote whatever is in it.
898
1561
  whole = true;
899
- contributors.push({ grant, kind: match.kind, why: `${match.detail}, and ${run.detail}`, whole: true });
1562
+ contributors.push({ record, kind: match.kind, why: `${match.detail}, and ${run.detail}`, whole: true, source });
900
1563
  continue;
901
1564
  }
902
1565
  if (match.after === null && match.before === null) {
@@ -947,8 +1610,99 @@ export function evaluateProtectedPaths(input) {
947
1610
  if (parts.length > 0)
948
1611
  contributed = `${match.detail}, and ${parts.join(" and ")}`;
949
1612
  }
950
- if (contributed !== null)
951
- contributors.push({ grant, kind: match.kind, why: contributed, whole: wholeHere });
1613
+ if (contributed !== null) {
1614
+ contributors.push({ record, kind: match.kind, why: contributed, whole: wholeHere, source });
1615
+ }
1616
+ }
1617
+ // One fragment inside one line, credited by bytes alone (APRV-340).
1618
+ //
1619
+ // The hook binds exactly what the Edit tool replaced, so an edit that
1620
+ // rewrites part of a long paragraph binds a fragment and whole-line set
1621
+ // membership cannot credit either line. That used to leave only the global
1622
+ // replay, which on a 200 KB file with forty naming candidates refuses on
1623
+ // its byte limit before it can reach a proof (PR #393, the fragment inside
1624
+ // SPEC.md line 139). A uniquely-anchored fragment needs no search: applying
1625
+ // it to base can only produce base with that ONE line rewritten, so if the
1626
+ // line it rewrites is a line this change removes and the line it produces
1627
+ // is a line this change adds, the bytes have proved both lines at the cost
1628
+ // of one scan. Every eligibility condition is the global replay's own — the
1629
+ // material rehashed, the Edit shape naming this path, and the same start
1630
+ // resolution, which for a grant is the registration, the request, the
1631
+ // class, the spend and the timing. A line-local step substitutes for none
1632
+ // of them; it only spends less to ask the same question.
1633
+ //
1634
+ // Bytes that did not decode are not bytes anyone can prove anything about,
1635
+ // so a blob carrying U+FFFD is refused here exactly as the replay refuses
1636
+ // it: byte equality against a lossy decoding says nothing.
1637
+ if (!whole &&
1638
+ !hunks.identical &&
1639
+ !baseText.includes("\uFFFD") &&
1640
+ !headText.includes("\uFFFD")) {
1641
+ const baseLines = linesOf(baseText);
1642
+ const addsLine = new Set(hunks.added);
1643
+ const removesLine = new Set(hunks.removed);
1644
+ for (const candidate of candidates) {
1645
+ const edit = exactReplayEdit(candidate.material, path);
1646
+ if (edit === null)
1647
+ continue;
1648
+ try {
1649
+ if (payloadHash(candidate.material) !== candidate.payloadHash)
1650
+ continue;
1651
+ }
1652
+ catch {
1653
+ continue;
1654
+ }
1655
+ const start = candidate.source === "policy"
1656
+ ? candidate.record
1657
+ : startForReplayGrant(candidate, records, anchor, lookbackMs, path, input.policyProtectedPaths);
1658
+ if (start === null)
1659
+ continue;
1660
+ const local = lineLocalReplacement(baseLines, edit);
1661
+ if (local === null)
1662
+ continue;
1663
+ if (!removesLine.has(local.removed) || !addsLine.has(local.added))
1664
+ continue;
1665
+ addedCover.add(local.added);
1666
+ removedCover.add(local.removed);
1667
+ const why = `replacing that fragment in the one line of the blob at base that carries it yields a line this change adds, and that base line is one this change removes (line-local replay at execution.started seq ${start.seq})`;
1668
+ const already = contributors.find((one) => one.record.seq === candidate.record.seq);
1669
+ if (already === undefined) {
1670
+ contributors.push({
1671
+ record: candidate.record,
1672
+ kind: candidate.match.kind,
1673
+ why: `${candidate.match.detail}, and ${why}`,
1674
+ whole: false,
1675
+ source: candidate.source,
1676
+ });
1677
+ continue;
1678
+ }
1679
+ already.why = `${already.why}, and ${why}`;
1680
+ }
1681
+ }
1682
+ // Exact Edit payloads may bind fragments within a line, including several
1683
+ // independent fragments of the same long line. Whole-line set membership
1684
+ // cannot express that safely. Replay is the bounded fallback: genuine
1685
+ // starts, execution order, unique anchors, and final byte equality.
1686
+ const needsReplay = !whole &&
1687
+ !hunks.identical &&
1688
+ (hunks.reordered ||
1689
+ hunks.added.some((line) => !addedCover.has(line)) ||
1690
+ hunks.removed.some((line) => !removedCover.has(line)));
1691
+ const replay = needsReplay
1692
+ ? exactEditReplay(candidates, records, blobs.base, blobs.head, anchor, lookbackMs, path, input.policyProtectedPaths)
1693
+ : { steps: null, bound: null };
1694
+ if (replay.bound !== null) {
1695
+ rejected.unshift(`exact BASE-to-HEAD replay refused after reaching its ${replay.bound.replace("-", " ")}`);
1696
+ }
1697
+ if (replay.steps !== null) {
1698
+ whole = true;
1699
+ contributors.splice(0, contributors.length, ...replay.steps.map((step) => ({
1700
+ record: step.candidate.record,
1701
+ kind: step.candidate.match.kind,
1702
+ why: `${step.candidate.match.detail}, applied at execution.started seq ${step.start.seq} in an exact BASE-to-HEAD replay`,
1703
+ whole: true,
1704
+ source: step.candidate.source,
1705
+ })));
952
1706
  }
953
1707
  // What the bytes alone cover, before any whole-file attribution. If this is
954
1708
  // non-empty the change RESTS on the attribution, and the finding has to
@@ -976,12 +1730,14 @@ export function evaluateProtectedPaths(input) {
976
1730
  const lead = restsOnAttribution
977
1731
  ? (contributors.find((one) => one.whole) ?? contributors[0])
978
1732
  : contributors[0];
979
- const quiet = hunks.identical ? candidates[0] : undefined;
1733
+ const quiet = hunks.identical
1734
+ ? candidates.find((candidate) => candidate.source === "grant")
1735
+ : undefined;
980
1736
  const passing = lead !== undefined
981
- ? { record: lead.grant, kind: lead.kind, why: lead.why }
1737
+ ? { record: lead.record, kind: lead.kind, why: lead.why }
982
1738
  : quiet !== undefined
983
1739
  ? {
984
- record: quiet.grant,
1740
+ record: quiet.record,
985
1741
  kind: quiet.match.kind,
986
1742
  why: `${quiet.match.detail}, and this change alters no substantive line (whitespace, mode or metadata only), so there is no hunk to cover`,
987
1743
  }
@@ -997,17 +1753,48 @@ export function evaluateProtectedPaths(input) {
997
1753
  seq: record.seq,
998
1754
  ts: record.ts,
999
1755
  actor: record.actor,
1000
- coveredBy: contributors.map((one) => one.grant.seq),
1001
- detail: `${path} was granted by ${record.actor} at seq ${record.seq} (${record.ts}), ${boundText}: ${why}. ${hunks.identical
1002
- ? "there are no substantive hunks to cover"
1003
- : whole
1004
- ? "the whole change is attributed to that grant"
1005
- : `${hunks.added.length} added and ${hunks.removed.length} removed line(s) all trace to granted material`}${contributors.length > 1
1006
- ? ` (assembled from ${contributors.length} grants: seq ${contributors.map((one) => one.grant.seq).join(", ")}; the strongest and nearest leads)`
1007
- : ""}`,
1756
+ coveredBy: contributors.map((one) => one.record.seq),
1757
+ detail: replay.steps !== null
1758
+ ? `${path}'s full change was reconstructed byte-for-byte from ${contributors.length} replayed authorization record${contributors.length === 1 ? "" : "s"} in execution order, ${boundText}. Evidence records in that order: seq ${contributors.map((one) => one.record.seq).join(", ")}. The first record, ${record.actor} at seq ${record.seq} (${record.ts}), leads the finding: ${why}.`
1759
+ : `${path} was ${kind === "policy-authorized-file" ? "authorized by policy for execution" : "granted"} by ${record.actor} at seq ${record.seq} (${record.ts}), ${boundText}: ${why}. ${hunks.identical
1760
+ ? "there are no substantive hunks to cover"
1761
+ : whole
1762
+ ? "the whole change is attributed to that authorization record"
1763
+ : `${hunks.added.length} added and ${hunks.removed.length} removed line(s) all trace to authorized material`}${contributors.length > 1
1764
+ ? ` (assembled from ${contributors.length} ${contributors.every((one) => one.source === "grant") ? "grants" : "evidence records"}: seq ${contributors.map((one) => one.record.seq).join(", ")}; the strongest and nearest leads)`
1765
+ : ""}`,
1008
1766
  });
1009
1767
  continue;
1010
1768
  }
1769
+ // The LAST thing tried for this path, and deliberately last: a human's
1770
+ // whole-file sign-off (APRV-338).
1771
+ //
1772
+ // Everything above is hunk evidence, and everything above has now failed
1773
+ // to cover this change. Only here does the guard ask the weaker question —
1774
+ // did a human read this file at exactly these bytes and say so — and the
1775
+ // finding says in words that this is what it rests on, so a reader can
1776
+ // never mistake it for a grant. Running it here rather than beside the
1777
+ // organ verdict is what keeps that true: consulted first, a sign-off would
1778
+ // have silently answered for every change a grant already covered, and the
1779
+ // reasons this guard prints are half its value.
1780
+ const signOffDigest = signOffEligible(path, input.policyProtectedPaths)
1781
+ ? (input.pathSha256AtHead?.(path) ?? null)
1782
+ : null;
1783
+ if (signOffDigest !== null) {
1784
+ const signed = signOffs.get(organKey(path, signOffDigest));
1785
+ if (signed !== undefined) {
1786
+ findings.push({
1787
+ path,
1788
+ ok: true,
1789
+ evidence: "attested",
1790
+ seq: signed.seq,
1791
+ ts: signed.ts,
1792
+ actor: signed.actor,
1793
+ detail: `${path} at ${input.window.head} hashes to ${signOffDigest}, which ${signed.actor} signed off FOR THAT PATH at seq ${signed.seq}. This is WHOLE-FILE evidence and weaker than a grant: it says a human read this file at these exact bytes, not that they saw this hunk. It was read only because no grant covers this change — ${candidates.length} ${evidenceNoun} name this path ${boundText}${contributors.length > 0 ? `, and ${contributors.length} of them covered part of it (seq ${contributors.map((one) => one.record.seq).join(", ")})` : ""}`,
1794
+ });
1795
+ continue;
1796
+ }
1797
+ }
1011
1798
  // Naming grants exist and the change is not made of them: the repeat-edit
1012
1799
  // shape. Its own code, because the reader's next move differs from
1013
1800
  // `no-evidence` — take THIS change to the gate, rather than hunt for a
@@ -1019,11 +1806,11 @@ export function evaluateProtectedPaths(input) {
1019
1806
  ok: false,
1020
1807
  code: "uncovered-hunk",
1021
1808
  uncovered: sample,
1022
- detail: `${path} is a protected path and ${uncovered.length} line(s) of this change trace to no granted material. ${candidates.length} grant${candidates.length === 1 ? "" : "s"} name this path ${boundText}, and naming is not coverage: a grant authorizes the bytes it bound, and a later edit to the same file is a different decision. ${contributors.length > 0
1023
- ? `${contributors.length} grant${contributors.length === 1 ? "" : "s"} covered part of it (seq ${contributors.map((one) => one.grant.seq).join(", ")}); `
1809
+ detail: `${path} is a protected path and ${uncovered.length} line(s) of this change trace to no authorized material. ${candidates.length} ${evidenceNoun} name this path ${boundText}, and naming is not coverage: evidence authorizes only the bytes it binds, and a later edit to the same file is a different decision. ${contributors.length > 0
1810
+ ? `${contributors.length} evidence record${contributors.length === 1 ? "" : "s"} covered part of it (seq ${contributors.map((one) => one.record.seq).join(", ")}); `
1024
1811
  : ""}${rejected.length > 0
1025
1812
  ? `${rejected.slice(0, 6).join("; ")}${rejected.length > 6 ? `; … and ${rejected.length - 6} other naming grants set aside for the same kinds of reason` : ""}. `
1026
- : ""}uncovered: ${sample.map((line) => JSON.stringify(line)).join(", ")}${uncovered.length > sample.length ? `, … ${uncovered.length - sample.length} more` : ""}. ${windowText(input.window)}. ${ORDERING_RULE}.`,
1813
+ : ""}uncovered: ${sample.map((line) => JSON.stringify(line)).join(", ")}${uncovered.length > sample.length ? `, … ${uncovered.length - sample.length} more` : ""}. ${windowText(input.window)}. ${ORDERING_RULE}.${signOffRepair(path, signOffDigest)}`,
1027
1814
  });
1028
1815
  continue;
1029
1816
  }
@@ -1037,10 +1824,16 @@ export function evaluateProtectedPaths(input) {
1037
1824
  if (unresolved.length > 0) {
1038
1825
  diagnosis.push(`${unresolved.length} grant payload${unresolved.length === 1 ? "" : "s"} could not be resolved from the committed payload store (${unresolved.slice(0, 3).join(", ")}${unresolved.length > 3 ? ", …" : ""}), and a grant whose bytes cannot be read is not evidence for any path`);
1039
1826
  }
1040
- if ((endsWithSegments(input.policyPath, path) || endsWithSegments(path, input.policyPath)) &&
1041
- input.policySha256AtHead !== null) {
1827
+ if (namesPolicyFile(path, input.policyPath) && input.policySha256AtHead !== null) {
1042
1828
  diagnosis.push(`no policy.updated record attests the bytes this pull request would install (${input.policySha256AtHead}); an amendment lands through \`approval policy amend --commit\`, whose attestation record is the evidence`);
1043
1829
  }
1830
+ if (signOffDigest !== null) {
1831
+ // A protected path that COULD have been signed off and was not. Named
1832
+ // second to the grant advice above, not first: taking the edit to the
1833
+ // gate binds the hunk, and a sign-off stands for the whole file
1834
+ // (APRV-338).
1835
+ diagnosis.push(`no gate.path.signed_off record signs off ${path} at ${signOffDigest}, and a digest signed for some OTHER path is not evidence for this one`);
1836
+ }
1044
1837
  if (isGateOrganPath(path)) {
1045
1838
  // The one failure in this guard whose repair is NOT "take the change to
1046
1839
  // the gate": there is no gate for it. `policy.core` is human-only, the
@@ -1056,7 +1849,7 @@ export function evaluateProtectedPaths(input) {
1056
1849
  path,
1057
1850
  ok: false,
1058
1851
  code: "no-evidence",
1059
- detail: `${path} is a protected path (edits classify policy.edit) and changed between ${input.window.base} and ${input.window.head}, and the committed log carries no evidence that a human decided it. ${windowText(input.window)}. ${diagnosis.join("; ")}. ${ORDERING_RULE}.`,
1852
+ detail: `${path} is a protected path (edits classify policy.edit) and changed between ${input.window.base} and ${input.window.head}, and the committed log carries no evidence that a human decided it. ${windowText(input.window)}. ${diagnosis.join("; ")}. ${ORDERING_RULE}.${signOffRepair(path, signOffDigest)}`,
1060
1853
  });
1061
1854
  }
1062
1855
  return {