approval-md 0.1.0 → 0.2.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 (266) hide show
  1. package/README.md +584 -553
  2. package/SPEC.md +42 -13
  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 +623 -0
  27. package/dist/src/channels/payload-view.d.ts +35 -0
  28. package/dist/src/channels/render-queue.d.ts +149 -0
  29. package/dist/src/channels/tagging.d.ts +196 -0
  30. package/dist/src/channels/telegram.d.ts +1832 -0
  31. package/dist/src/channels/web.d.ts +341 -0
  32. package/dist/src/cli/adapter.d.ts +90 -0
  33. package/dist/src/cli/adapter.js +25 -15
  34. package/dist/src/cli/adapter.js.map +1 -1
  35. package/dist/src/cli/amend.d.ts +59 -0
  36. package/dist/src/cli/args.d.ts +43 -0
  37. package/dist/src/cli/attest.d.ts +41 -0
  38. package/dist/src/cli/audit-card.d.ts +62 -0
  39. package/dist/src/cli/audit.d.ts +59 -0
  40. package/dist/src/cli/channel-telegram.d.ts +806 -0
  41. package/dist/src/cli/channel-web.d.ts +131 -0
  42. package/dist/src/cli/channel.d.ts +71 -0
  43. package/dist/src/cli/checkpoint-tap.d.ts +169 -0
  44. package/dist/src/cli/codex.d.ts +2 -0
  45. package/dist/src/cli/codex.js +172 -0
  46. package/dist/src/cli/codex.js.map +1 -0
  47. package/dist/src/cli/coverage.d.ts +61 -0
  48. package/dist/src/cli/daemon.d.ts +120 -0
  49. package/dist/src/cli/doctor.d.ts +129 -0
  50. package/dist/src/cli/doctor.js +119 -5
  51. package/dist/src/cli/doctor.js.map +1 -1
  52. package/dist/src/cli/env.d.ts +65 -0
  53. package/dist/src/cli/execute.d.ts +202 -0
  54. package/dist/src/cli/exit-codes.d.ts +73 -0
  55. package/dist/src/cli/feedback.d.ts +60 -0
  56. package/dist/src/cli/gate-window.d.ts +40 -0
  57. package/dist/src/cli/gate.d.ts +68 -0
  58. package/dist/src/cli/git-scope.d.ts +190 -0
  59. package/dist/src/cli/gloss-attach.d.ts +85 -0
  60. package/dist/src/cli/gloss-codex-child.d.ts +9 -0
  61. package/dist/src/cli/gloss-codex.d.ts +24 -0
  62. package/dist/src/cli/gloss-options.d.ts +42 -0
  63. package/dist/src/cli/gloss.d.ts +265 -0
  64. package/dist/src/cli/help.d.ts +103 -0
  65. package/dist/src/cli/help.js +173 -51
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook-codex.d.ts +78 -0
  68. package/dist/src/cli/hook-codex.js +167 -0
  69. package/dist/src/cli/hook-codex.js.map +1 -0
  70. package/dist/src/cli/hook.d.ts +331 -0
  71. package/dist/src/cli/hook.js +186 -80
  72. package/dist/src/cli/hook.js.map +1 -1
  73. package/dist/src/cli/import.d.ts +35 -0
  74. package/dist/src/cli/init.d.ts +84 -0
  75. package/dist/src/cli/init.js +2 -2
  76. package/dist/src/cli/init.js.map +1 -1
  77. package/dist/src/cli/instructions.d.ts +23 -0
  78. package/dist/src/cli/journal.d.ts +41 -0
  79. package/dist/src/cli/log-advance.d.ts +287 -0
  80. package/dist/src/cli/log-advance.js +102 -11
  81. package/dist/src/cli/log-advance.js.map +1 -1
  82. package/dist/src/cli/log-anchor.d.ts +176 -0
  83. package/dist/src/cli/log-checkpoint.d.ts +22 -0
  84. package/dist/src/cli/log-sync.d.ts +243 -0
  85. package/dist/src/cli/log-verbs.d.ts +16 -0
  86. package/dist/src/cli/log-verbs.js +7 -1
  87. package/dist/src/cli/log-verbs.js.map +1 -1
  88. package/dist/src/cli/long-help.d.ts +70 -0
  89. package/dist/src/cli/main.d.ts +77 -0
  90. package/dist/src/cli/main.js +155 -5
  91. package/dist/src/cli/main.js.map +1 -1
  92. package/dist/src/cli/mcp.d.ts +52 -0
  93. package/dist/src/cli/paths.d.ts +56 -0
  94. package/dist/src/cli/payload.d.ts +58 -0
  95. package/dist/src/cli/policy.d.ts +43 -0
  96. package/dist/src/cli/preflight.d.ts +363 -0
  97. package/dist/src/cli/preflight.js +294 -7
  98. package/dist/src/cli/preflight.js.map +1 -1
  99. package/dist/src/cli/progress.d.ts +78 -0
  100. package/dist/src/cli/prompt.d.ts +209 -0
  101. package/dist/src/cli/quickstart.d.ts +46 -0
  102. package/dist/src/cli/quickstart.js +297 -0
  103. package/dist/src/cli/quickstart.js.map +1 -0
  104. package/dist/src/cli/records.d.ts +34 -0
  105. package/dist/src/cli/render.d.ts +22 -0
  106. package/dist/src/cli/sandbox.d.ts +51 -0
  107. package/dist/src/cli/scaffold.d.ts +79 -0
  108. package/dist/src/cli/setup-adapter.d.ts +137 -0
  109. package/dist/src/cli/setup-adapter.js +38 -4
  110. package/dist/src/cli/setup-adapter.js.map +1 -1
  111. package/dist/src/cli/setup-channel.d.ts +117 -0
  112. package/dist/src/cli/setup-checkpoint.d.ts +57 -0
  113. package/dist/src/cli/setup-common.d.ts +275 -0
  114. package/dist/src/cli/setup-flow.d.ts +287 -0
  115. package/dist/src/cli/setup-service.d.ts +96 -0
  116. package/dist/src/cli/setup.d.ts +202 -0
  117. package/dist/src/cli/style.d.ts +320 -0
  118. package/dist/src/cli/token.d.ts +39 -0
  119. package/dist/src/cli/up.d.ts +155 -0
  120. package/dist/src/cli/up.js +4 -2
  121. package/dist/src/cli/up.js.map +1 -1
  122. package/dist/src/cli/usage.d.ts +37 -0
  123. package/dist/src/cli/values.d.ts +40 -0
  124. package/dist/src/cli/vault.d.ts +59 -0
  125. package/dist/src/cli/vault.js +2 -2
  126. package/dist/src/cli/vault.js.map +1 -1
  127. package/dist/src/cli/verb-registry.d.ts +76 -0
  128. package/dist/src/cli/verb-registry.js +176 -8
  129. package/dist/src/cli/verb-registry.js.map +1 -1
  130. package/dist/src/cli/wordmark.d.ts +31 -0
  131. package/dist/src/cli/wordmark.js +2 -2
  132. package/dist/src/codex/doctor.d.ts +13 -0
  133. package/dist/src/codex/doctor.js +41 -0
  134. package/dist/src/codex/doctor.js.map +1 -0
  135. package/dist/src/codex/manifest.d.ts +49 -0
  136. package/dist/src/codex/manifest.js +103 -0
  137. package/dist/src/codex/manifest.js.map +1 -0
  138. package/dist/src/codex/templates.d.ts +41 -0
  139. package/dist/src/codex/templates.js +319 -0
  140. package/dist/src/codex/templates.js.map +1 -0
  141. package/dist/src/codex/trust.d.ts +19 -0
  142. package/dist/src/codex/trust.js +183 -0
  143. package/dist/src/codex/trust.js.map +1 -0
  144. package/dist/src/codex/workspace-plan.d.ts +131 -0
  145. package/dist/src/codex/workspace-plan.js +561 -0
  146. package/dist/src/codex/workspace-plan.js.map +1 -0
  147. package/dist/src/core/actor.d.ts +2 -0
  148. package/dist/src/core/actor.js +5 -0
  149. package/dist/src/core/actor.js.map +1 -0
  150. package/dist/src/core/advance-cycle.d.ts +170 -0
  151. package/dist/src/core/agents-md.d.ts +276 -0
  152. package/dist/src/core/apply-patch.d.ts +49 -0
  153. package/dist/src/core/apply-patch.js +266 -0
  154. package/dist/src/core/apply-patch.js.map +1 -0
  155. package/dist/src/core/attest.d.ts +420 -0
  156. package/dist/src/core/attest.js +13 -1
  157. package/dist/src/core/attest.js.map +1 -1
  158. package/dist/src/core/audit.d.ts +492 -0
  159. package/dist/src/core/budgets.d.ts +238 -0
  160. package/dist/src/core/checkpoint.d.ts +500 -0
  161. package/dist/src/core/child-env.d.ts +88 -0
  162. package/dist/src/core/clock.d.ts +52 -0
  163. package/dist/src/core/command-class.d.ts +543 -0
  164. package/dist/src/core/command-class.js +43 -8
  165. package/dist/src/core/command-class.js.map +1 -1
  166. package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
  167. package/dist/src/core/coverage-sources/gh.d.ts +48 -0
  168. package/dist/src/core/coverage-sources/git.d.ts +101 -0
  169. package/dist/src/core/coverage.d.ts +217 -0
  170. package/dist/src/core/credential-spec.d.ts +72 -0
  171. package/dist/src/core/dark-session.d.ts +331 -0
  172. package/dist/src/core/decision-refusal.d.ts +185 -0
  173. package/dist/src/core/env-file.d.ts +450 -0
  174. package/dist/src/core/execute.d.ts +858 -0
  175. package/dist/src/core/execute.js +44 -6
  176. package/dist/src/core/execute.js.map +1 -1
  177. package/dist/src/core/frontmatter.d.ts +78 -0
  178. package/dist/src/core/gate-window.d.ts +312 -0
  179. package/dist/src/core/gate.d.ts +1364 -0
  180. package/dist/src/core/gate.js +68 -13
  181. package/dist/src/core/gate.js.map +1 -1
  182. package/dist/src/core/git-run.d.ts +73 -0
  183. package/dist/src/core/harness-version.d.ts +157 -0
  184. package/dist/src/core/harness-version.js +2 -1
  185. package/dist/src/core/harness-version.js.map +1 -1
  186. package/dist/src/core/harness-wait.d.ts +55 -0
  187. package/dist/src/core/head-retry.d.ts +107 -0
  188. package/dist/src/core/instance.d.ts +253 -0
  189. package/dist/src/core/intake-limits.d.ts +247 -0
  190. package/dist/src/core/jcs.d.ts +52 -0
  191. package/dist/src/core/journal.d.ts +144 -0
  192. package/dist/src/core/live-draw.d.ts +436 -0
  193. package/dist/src/core/log-reconcile.d.ts +89 -0
  194. package/dist/src/core/log-subscribe.d.ts +36 -0
  195. package/dist/src/core/log-subscribe.js +162 -0
  196. package/dist/src/core/log-subscribe.js.map +1 -0
  197. package/dist/src/core/log.d.ts +278 -0
  198. package/dist/src/core/loop.d.ts +274 -0
  199. package/dist/src/core/loop.js +11 -0
  200. package/dist/src/core/loop.js.map +1 -1
  201. package/dist/src/core/md-fence.d.ts +41 -0
  202. package/dist/src/core/money.d.ts +147 -0
  203. package/dist/src/core/payload-census.d.ts +74 -0
  204. package/dist/src/core/payload-store.d.ts +175 -0
  205. package/dist/src/core/payload.d.ts +71 -0
  206. package/dist/src/core/policy-diff.d.ts +292 -0
  207. package/dist/src/core/policy-diff.js +27 -4
  208. package/dist/src/core/policy-diff.js.map +1 -1
  209. package/dist/src/core/policy-expectations.d.ts +199 -0
  210. package/dist/src/core/policy-explain.d.ts +150 -0
  211. package/dist/src/core/policy-explain.js +31 -3
  212. package/dist/src/core/policy-explain.js.map +1 -1
  213. package/dist/src/core/policy-load.d.ts +527 -0
  214. package/dist/src/core/policy-load.js +15 -3
  215. package/dist/src/core/policy-load.js.map +1 -1
  216. package/dist/src/core/policy-match.d.ts +281 -0
  217. package/dist/src/core/policy-match.js +20 -9
  218. package/dist/src/core/policy-match.js.map +1 -1
  219. package/dist/src/core/policy-proposal.d.ts +265 -0
  220. package/dist/src/core/prompt-layout.d.ts +221 -0
  221. package/dist/src/core/protected-path-guard.d.ts +453 -0
  222. package/dist/src/core/protected-path-guard.js +514 -35
  223. package/dist/src/core/protected-path-guard.js.map +1 -1
  224. package/dist/src/core/registration.d.ts +25 -0
  225. package/dist/src/core/reindex.d.ts +99 -0
  226. package/dist/src/core/sampler.d.ts +313 -0
  227. package/dist/src/core/sandbox.d.ts +290 -0
  228. package/dist/src/core/seal.d.ts +165 -0
  229. package/dist/src/core/state.d.ts +505 -0
  230. package/dist/src/core/task-file.d.ts +185 -0
  231. package/dist/src/core/telegram-config.d.ts +93 -0
  232. package/dist/src/core/token.d.ts +409 -0
  233. package/dist/src/core/token.js +21 -38
  234. package/dist/src/core/token.js.map +1 -1
  235. package/dist/src/core/validate.d.ts +138 -0
  236. package/dist/src/core/values.d.ts +137 -0
  237. package/dist/src/core/vault.d.ts +291 -0
  238. package/dist/src/core/verified-snapshot.d.ts +204 -0
  239. package/dist/src/core/verify.d.ts +336 -0
  240. package/dist/src/core/version.d.ts +8 -0
  241. package/dist/src/core/wysiwys.d.ts +370 -0
  242. package/dist/src/daemon/advance-child.d.ts +39 -0
  243. package/dist/src/daemon/advance.d.ts +466 -0
  244. package/dist/src/daemon/audit.d.ts +87 -0
  245. package/dist/src/daemon/daemon.d.ts +1180 -0
  246. package/dist/src/daemon/dark-session.d.ts +64 -0
  247. package/dist/src/daemon/draw-child.d.ts +36 -0
  248. package/dist/src/daemon/draw.d.ts +154 -0
  249. package/dist/src/daemon/git-evidence.d.ts +173 -0
  250. package/dist/src/daemon/git-evidence.js +1 -1
  251. package/dist/src/daemon/projection.d.ts +180 -0
  252. package/dist/src/daemon/prune.d.ts +207 -0
  253. package/dist/src/mcp/http.d.ts +113 -0
  254. package/dist/src/mcp/server.d.ts +265 -0
  255. package/dist/src/mcp/server.js +9 -1
  256. package/dist/src/mcp/server.js.map +1 -1
  257. package/docs/adapter-api.md +106 -0
  258. package/docs/cli-reference.md +389 -36
  259. package/docs/codex-enforced-session.md +30 -0
  260. package/package.json +12 -2
  261. package/schema/codex-instance.schema.json +82 -0
  262. package/schema/event.schema.json +2 -1
  263. package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
  264. package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
  265. package/schema/policy.schema.json +21 -1
  266. package/templates/codex/README.md +9 -0
@@ -17,20 +17,21 @@
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.
23
24
  *
24
25
  * ## What counts as evidence
25
26
  *
26
27
  * Evidence is about the CHANGE, not about the path (APRV-202). The guard reads
27
28
  * 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.
29
+ * request adds and removes, and requires every one of them to trace back to
30
+ * bound material in an authorization record. A path authorized last Tuesday
31
+ * and edited again today has evidence naming it and none covering today's
32
+ * lines, and that fails. Naming remains necessary; it stopped being sufficient.
32
33
  *
33
- * Three verdicts pass, and they are ordered by how much they prove:
34
+ * Four verdicts pass:
34
35
  *
35
36
  * 1. `attested` — the policy file, and the gate's ORGANS. `approval policy
36
37
  * amend --commit` appends `policy.updated` carrying `{policy_path, sha256}`,
@@ -52,7 +53,14 @@
52
53
  * match: a digest attested for one organ is not evidence for another, which
53
54
  * is why the organ record carries a whole relative path where the policy
54
55
  * record carries a basename.
55
- * 2. `granted-file` — a file-tool edit. The hook binds the CHANGE rather than
56
+ * 2. `policy-authorized-file` — an exact Edit or Write whose verified
57
+ * `execution.started` is preceded by its unique matching registration and no
58
+ * approval request. The registration, start and recomputed stored payload
59
+ * must agree on task, action, class and hash. The start must precede the
60
+ * change and the recorded class must be the class this path is routed to.
61
+ * This records authorization to execute, not successful completion; the
62
+ * exact hunk checks below establish whether those bytes landed.
63
+ * 3. `granted-file` — a file-tool edit. The hook binds the CHANGE rather than
56
64
  * the touch (APRV-124), so the bound material carries `file` plus the exact
57
65
  * edit: `{before, after}` for an Edit, `{content}` for a Write. That is
58
66
  * HUNK-level evidence, and it is used as such. The granted `after` bytes
@@ -62,7 +70,7 @@
62
70
  * in head is a grant for something that did not land, and covers nothing.
63
71
  * Some payloads carry the `{input}` fallback shape instead, which describes
64
72
  * no bytes; those name the path and cover nothing.
65
- * 3. `granted-command` — a shell edit. The bound material is `{command, cwd}`
73
+ * 4. `granted-command` — a shell edit. The bound material is `{command, cwd}`
66
74
  * or `{argv, cwd}`, and the guard re-runs the runtime's own
67
75
  * {@link classifyCommand} over it, requiring a segment that classifies as a
68
76
  * granting class BECAUSE of a word naming this path. A mention is not a
@@ -191,7 +199,9 @@
191
199
  * git plumbing and file reads live in the caller.
192
200
  */
193
201
  import { organAttestationOf } from "./attest.js";
194
- import { classifyCommand, isGateOrganPath, isProtectedPath, normalizePathSpelling, POLICY_EDIT_SUBCLASS, } from "./command-class.js";
202
+ import { parseApplyPatch } from "./apply-patch.js";
203
+ import { classifyCommand, isGateOrganPath, isProtectedPath, normalizePathSpelling, POLICY_EDIT_SUBCLASS, protectedPathClass, } from "./command-class.js";
204
+ import { payloadHash } from "./payload.js";
195
205
  /**
196
206
  * Classes whose grant authorizes a protected-path write.
197
207
  *
@@ -288,6 +298,10 @@ export const DEFAULT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
288
298
  * fresh approval, which costs one tap.
289
299
  */
290
300
  export const DEFAULT_COMMAND_ATTRIBUTION_MS = 6 * 60 * 60 * 1000;
301
+ /** Fail-closed resource bounds for exact protected-edit reconstruction. */
302
+ export const EXACT_REPLAY_MAX_CANDIDATES = 128;
303
+ export const EXACT_REPLAY_MAX_STATES = 2_048;
304
+ export const EXACT_REPLAY_MAX_EXAMINED_BYTES = 64 * 1024 * 1024;
291
305
  /** Split on either separator, dropping empties and `.` noise. */
292
306
  function segmentsOf(candidate) {
293
307
  return candidate.split(/[/\\]+/u).filter((part) => part.length > 0 && part !== ".");
@@ -413,6 +427,31 @@ function evidenceFor(material, path, policyProtectedPaths) {
413
427
  if (typeof material !== "object" || material === null || Array.isArray(material))
414
428
  return null;
415
429
  const map = material;
430
+ // Codex apply_patch is tagged explicitly. Parse it before the generic
431
+ // command fallback so patch text is never mistaken for a shell command and
432
+ // never receives time-based command attribution. Until a patch-to-blob
433
+ // proof exists, this is deliberately naming-only evidence and covers no
434
+ // bytes; it remains useful diagnosis without weakening the guard.
435
+ if (map["tool"] === "apply_patch" && typeof map["command"] === "string") {
436
+ const parsed = parseApplyPatch(map["command"]);
437
+ if (parsed.ok) {
438
+ const operation = parsed.operations.find((candidate) => endsWithSegments(candidate.path, path) ||
439
+ (candidate.kind === "update" &&
440
+ candidate.moveTo !== undefined &&
441
+ endsWithSegments(candidate.moveTo, path)));
442
+ if (operation !== undefined) {
443
+ return {
444
+ kind: "granted-file",
445
+ detail: `the granted material is a Codex apply_patch operation naming ${path}; patch bytes are not yet guard coverage`,
446
+ after: null,
447
+ before: null,
448
+ whole: false,
449
+ command: false,
450
+ };
451
+ }
452
+ }
453
+ return null;
454
+ }
416
455
  const file = map["file"];
417
456
  if (typeof file === "string" && endsWithSegments(file, path)) {
418
457
  const tool = typeof map["tool"] === "string" ? map["tool"] : "a file tool";
@@ -476,6 +515,396 @@ function evidenceFor(material, path, policyProtectedPaths) {
476
515
  }
477
516
  return null;
478
517
  }
518
+ /** Every declaration of this action key that existed before the start. */
519
+ function precedingBindings(records, start) {
520
+ const actionKey = start.action_key;
521
+ if (actionKey === undefined)
522
+ return [];
523
+ const found = [];
524
+ for (const record of records) {
525
+ if (record.seq >= start.seq || record.event !== "task.registered")
526
+ continue;
527
+ const actions = payloadOf(record)["actions"];
528
+ if (!Array.isArray(actions))
529
+ continue;
530
+ for (const raw of actions) {
531
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
532
+ continue;
533
+ const action = raw;
534
+ if (action["idempotency_key"] !== actionKey)
535
+ continue;
536
+ const cls = action["class"];
537
+ const hash = action["payload_hash"];
538
+ // Count malformed declarations too. A malformed entry sharing the key is
539
+ // ambiguity, never something a later well-formed entry can hide.
540
+ found.push({
541
+ record,
542
+ task: record.task ?? null,
543
+ actionKey,
544
+ cls: typeof cls === "string" ? cls : null,
545
+ payloadHash: typeof hash === "string" ? hash : null,
546
+ });
547
+ }
548
+ }
549
+ return found;
550
+ }
551
+ /** Exact file bytes, excluding fallback, command and apply_patch material. */
552
+ function policyFileEvidence(material, path, policyProtectedPaths) {
553
+ if (typeof material !== "object" || material === null || Array.isArray(material))
554
+ return null;
555
+ const map = material;
556
+ const tool = map["tool"];
557
+ const file = map["file"];
558
+ if (typeof file !== "string" ||
559
+ file.startsWith("/") ||
560
+ file.includes("\\"))
561
+ return null;
562
+ const named = segmentsOf(file);
563
+ if (named.includes("..") || named.join("/") !== segmentsOf(path).join("/"))
564
+ return null;
565
+ const keys = Object.keys(map);
566
+ const hasOnly = (allowed) => keys.every((key) => allowed.includes(key));
567
+ if (tool === "Edit") {
568
+ if (!hasOnly(["tool", "rule", "file", "before", "after", "replace_all"]) ||
569
+ (map["rule"] !== undefined && typeof map["rule"] !== "string") ||
570
+ typeof map["before"] !== "string" ||
571
+ typeof map["after"] !== "string" ||
572
+ (map["replace_all"] !== undefined && typeof map["replace_all"] !== "boolean"))
573
+ return null;
574
+ }
575
+ else if (tool === "Write") {
576
+ if (!hasOnly(["tool", "rule", "file", "content"]) ||
577
+ (map["rule"] !== undefined && typeof map["rule"] !== "string") ||
578
+ typeof map["content"] !== "string")
579
+ return null;
580
+ }
581
+ else {
582
+ return null;
583
+ }
584
+ const match = evidenceFor(material, path, policyProtectedPaths);
585
+ if (match === null || match.command || (match.after === null && match.before === null))
586
+ return null;
587
+ return {
588
+ ...match,
589
+ kind: "policy-authorized-file",
590
+ detail: match.detail.replace(/^the granted material is/u, "the policy-authorized material is"),
591
+ };
592
+ }
593
+ /** Differing applicable routes in an unlabeled policy union carry no single class. */
594
+ function hasAmbiguousRoute(path, policyProtectedPaths) {
595
+ const candidate = segmentsOf(path);
596
+ const classes = new Set();
597
+ for (const entry of policyProtectedPaths) {
598
+ const raw = typeof entry === "string" ? entry : entry.path;
599
+ const trimmed = raw.trim();
600
+ if (trimmed.length === 0 || trimmed.startsWith("/"))
601
+ continue;
602
+ const wanted = segmentsOf(trimmed);
603
+ if (wanted.includes("..") || wanted.length === 0 || wanted.length > candidate.length)
604
+ continue;
605
+ const directory = /[/\\]$/u.test(trimmed);
606
+ const matches = directory
607
+ ? candidate.some((_, start) => start + wanted.length <= candidate.length &&
608
+ wanted.every((segment, offset) => candidate[start + offset] === segment))
609
+ : wanted.every((segment, offset) => candidate[candidate.length - wanted.length + offset] === segment);
610
+ if (matches)
611
+ classes.add(typeof entry === "string" ? "policy.edit" : entry.class);
612
+ }
613
+ return classes.size > 1;
614
+ }
615
+ /**
616
+ * Exact-file evidence from an unattended execution authorized by policy.
617
+ *
618
+ * The start must reproduce the one declaration that preceded it, and the
619
+ * committed payload must hash back to that declaration. A prior request means
620
+ * the action entered the human-decision path, so this tier cannot carry it.
621
+ */
622
+ function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, payloadFor, anchorMs, lookbackMs) {
623
+ if (start.event !== "execution.started")
624
+ return null;
625
+ const task = start.task;
626
+ const actionKey = start.action_key;
627
+ if (task === undefined || actionKey === undefined)
628
+ return null;
629
+ const started = payloadOf(start);
630
+ const harnessStart = started["execution"] === "harness";
631
+ const coreStart = !Object.hasOwn(started, "execution");
632
+ if ((!harnessStart && !coreStart) ||
633
+ Object.hasOwn(started, "grant_seq") ||
634
+ Object.hasOwn(started, "token_sha256"))
635
+ return null;
636
+ const cls = started["class"];
637
+ const hash = started["payload_hash"];
638
+ if (typeof cls !== "string" || typeof hash !== "string")
639
+ return null;
640
+ // The exact routed class is authority. Merely living in policy.edit.* is not.
641
+ if (hasAmbiguousRoute(path, policyProtectedPaths))
642
+ return null;
643
+ const routed = protectedPathClass(path, policyProtectedPaths);
644
+ if (routed === null || routed === "policy.core" || routed === "log.mutate" || cls !== routed) {
645
+ return null;
646
+ }
647
+ const bindings = precedingBindings(records, start);
648
+ if (bindings.length !== 1)
649
+ return null;
650
+ const binding = bindings[0];
651
+ if (binding.task !== task ||
652
+ binding.actionKey !== actionKey ||
653
+ binding.cls !== cls ||
654
+ binding.payloadHash !== hash)
655
+ return null;
656
+ if (records.some((record) => record.seq < start.seq &&
657
+ record.action_key === actionKey &&
658
+ record.event === "approval.requested"))
659
+ return null;
660
+ // A start cannot authorize a change that already happened. This tier also
661
+ // requires a usable commit timestamp; missing temporal evidence fails closed.
662
+ if (anchorMs === null)
663
+ return null;
664
+ const at = Date.parse(start.ts);
665
+ if (Number.isNaN(at) || at > anchorMs || anchorMs - at > lookbackMs)
666
+ return null;
667
+ const material = payloadFor(hash);
668
+ if (material === null)
669
+ return null;
670
+ try {
671
+ if (payloadHash(material) !== hash)
672
+ return null;
673
+ }
674
+ catch {
675
+ return null;
676
+ }
677
+ return policyFileEvidence(material, path, policyProtectedPaths);
678
+ }
679
+ /** The closed, repository-relative Edit shape eligible for byte replay. */
680
+ function exactReplayEdit(material, path) {
681
+ if (typeof material !== "object" || material === null || Array.isArray(material))
682
+ return null;
683
+ const map = material;
684
+ const keys = Object.keys(map);
685
+ const allowed = ["tool", "rule", "file", "before", "after", "replace_all"];
686
+ if (keys.some((key) => !allowed.includes(key)))
687
+ return null;
688
+ if (map["tool"] !== "Edit" ||
689
+ typeof map["file"] !== "string" ||
690
+ map["file"].startsWith("/") ||
691
+ map["file"].includes("\\") ||
692
+ (map["rule"] !== undefined && typeof map["rule"] !== "string") ||
693
+ typeof map["before"] !== "string" ||
694
+ map["before"].length === 0 ||
695
+ typeof map["after"] !== "string" ||
696
+ map["before"] === map["after"] ||
697
+ (map["replace_all"] !== undefined && map["replace_all"] !== false))
698
+ return null;
699
+ const named = segmentsOf(map["file"]);
700
+ const changed = segmentsOf(path);
701
+ if (named.includes("..") ||
702
+ named.length !== changed.length ||
703
+ !named.every((segment, index) => segment === changed[index]))
704
+ return null;
705
+ return { before: map["before"], after: map["after"] };
706
+ }
707
+ /** The one token or harness spend that proves this manual grant actually ran. */
708
+ function startForReplayGrant(candidate, records, anchorMs, lookbackMs, path, policyProtectedPaths) {
709
+ if (anchorMs === null)
710
+ return null;
711
+ const grant = candidate.record;
712
+ const task = grant.task;
713
+ const actionKey = grant.action_key;
714
+ if (task === undefined || actionKey === undefined)
715
+ return null;
716
+ const granted = payloadOf(grant);
717
+ const cls = granted["class"];
718
+ const hash = granted["payload_hash"];
719
+ if (typeof cls !== "string" || typeof hash !== "string" || hash !== candidate.payloadHash)
720
+ return null;
721
+ if (hasAmbiguousRoute(path, policyProtectedPaths))
722
+ return null;
723
+ const routed = protectedPathClass(path, policyProtectedPaths);
724
+ if (routed === null || routed === "policy.core" || routed === "log.mutate" || cls !== routed) {
725
+ return null;
726
+ }
727
+ try {
728
+ if (payloadHash(candidate.material) !== hash)
729
+ return null;
730
+ }
731
+ catch {
732
+ return null;
733
+ }
734
+ const grantToken = granted["token_sha256"];
735
+ const starts = records.filter((record) => {
736
+ if (record.event !== "execution.started" ||
737
+ record.seq <= grant.seq ||
738
+ record.task !== task ||
739
+ record.action_key !== actionKey)
740
+ return false;
741
+ const started = payloadOf(record);
742
+ if (started["class"] !== cls || started["payload_hash"] !== hash)
743
+ return false;
744
+ if (Object.hasOwn(started, "execution") && started["execution"] !== "harness")
745
+ return false;
746
+ const hasGrantLink = Object.hasOwn(started, "grant_seq");
747
+ const hasTokenLink = Object.hasOwn(started, "token_sha256");
748
+ if (!hasGrantLink && !hasTokenLink)
749
+ return false;
750
+ if (hasGrantLink && started["grant_seq"] !== grant.seq)
751
+ return false;
752
+ if (hasTokenLink &&
753
+ (typeof grantToken !== "string" || started["token_sha256"] !== grantToken))
754
+ return false;
755
+ const at = Date.parse(record.ts);
756
+ return !Number.isNaN(at) && at <= anchorMs && anchorMs - at <= lookbackMs;
757
+ });
758
+ if (starts.length !== 1)
759
+ return null;
760
+ const start = starts[0];
761
+ const bindings = precedingBindings(records, start);
762
+ if (bindings.length !== 1)
763
+ return null;
764
+ const binding = bindings[0];
765
+ if (binding.record.seq >= grant.seq ||
766
+ binding.task !== task ||
767
+ binding.actionKey !== actionKey ||
768
+ binding.cls !== cls ||
769
+ binding.payloadHash !== hash)
770
+ return null;
771
+ const requested = records.some((record) => {
772
+ if (record.event !== "approval.requested" ||
773
+ record.seq <= binding.record.seq ||
774
+ record.seq >= grant.seq ||
775
+ record.task !== task ||
776
+ record.action_key !== actionKey)
777
+ return false;
778
+ const payload = payloadOf(record);
779
+ return payload["class"] === cls && payload["payload_hash"] === hash;
780
+ });
781
+ return requested ? start : null;
782
+ }
783
+ /**
784
+ * Replay authorized exact edits in execution order and demand byte equality.
785
+ *
786
+ * The log may contain older edits whose anchors survive in BASE but which did
787
+ * not produce this change. Search deterministic ordered subsequences: skip a
788
+ * candidate first, and apply it only when its anchor occurs exactly once in the
789
+ * current state. An ambiguous candidate cannot be applied, but it does not make
790
+ * an independent later proof ambiguous. Only a non-empty sequence that turns
791
+ * BASE into HEAD byte for byte is evidence; there is no reordering, substring
792
+ * credit, or partial result.
793
+ *
794
+ * Search is bounded by candidate count, distinct states, and cumulative bytes
795
+ * examined. Crossing any bound refuses the fallback rather than pruning a
796
+ * potentially valid branch and claiming the remaining search was complete.
797
+ */
798
+ function exactEditReplay(candidates, records, base, head, anchorMs, lookbackMs, path, policyProtectedPaths) {
799
+ if (base === null ||
800
+ head === null ||
801
+ base.includes("\uFFFD") ||
802
+ head.includes("\uFFFD"))
803
+ return { steps: null, bound: null };
804
+ const gathered = [];
805
+ for (const candidate of candidates) {
806
+ try {
807
+ if (payloadHash(candidate.material) !== candidate.payloadHash)
808
+ continue;
809
+ }
810
+ catch {
811
+ continue;
812
+ }
813
+ const edit = exactReplayEdit(candidate.material, path);
814
+ if (edit === null)
815
+ continue;
816
+ const start = candidate.source === "policy"
817
+ ? candidate.record
818
+ : startForReplayGrant(candidate, records, anchorMs, lookbackMs, path, policyProtectedPaths);
819
+ if (start !== null)
820
+ gathered.push({ candidate, start, ...edit });
821
+ }
822
+ gathered.sort((left, right) => left.start.seq - right.start.seq);
823
+ // A verified execution record can authorize at most one application. Collapse
824
+ // duplicate evidence views of the same binding. If two views give one start
825
+ // conflicting edit bytes, neither representation of that start is usable.
826
+ const byStart = new Map();
827
+ const conflictingStarts = new Set();
828
+ for (const step of gathered) {
829
+ const prior = byStart.get(step.start.seq);
830
+ if (prior === undefined) {
831
+ byStart.set(step.start.seq, step);
832
+ continue;
833
+ }
834
+ if (prior.before !== step.before || prior.after !== step.after) {
835
+ conflictingStarts.add(step.start.seq);
836
+ }
837
+ }
838
+ const steps = [...byStart.values()].filter((step) => !conflictingStarts.has(step.start.seq));
839
+ if (steps.length > EXACT_REPLAY_MAX_CANDIDATES) {
840
+ return { steps: null, bound: "candidate-limit" };
841
+ }
842
+ const headBytes = Buffer.byteLength(head, "utf8");
843
+ let examinedBytes = Buffer.byteLength(base, "utf8") + headBytes;
844
+ if (examinedBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
845
+ return { steps: null, bound: "byte-limit" };
846
+ }
847
+ let visitedStates = 0;
848
+ let exceeded = null;
849
+ // State equality is exact string equality, not a digest or lossy cache key.
850
+ // Empty/non-empty histories stay distinct because an empty replay is never
851
+ // evidence even when BASE already equals HEAD.
852
+ const visited = Array.from({ length: steps.length + 1 }, () => [new Set(), new Set()]);
853
+ const search = (index, replayed, applied) => {
854
+ if (exceeded !== null)
855
+ return null;
856
+ const replayedBytes = Buffer.byteLength(replayed, "utf8");
857
+ // Charge exact-state hashing/equality and the HEAD comparison before either
858
+ // can retain or accept this state.
859
+ const stateBytes = replayedBytes + headBytes;
860
+ if (examinedBytes + stateBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
861
+ exceeded = "byte-limit";
862
+ return null;
863
+ }
864
+ examinedBytes += stateBytes;
865
+ const seen = visited[index]?.[applied.length === 0 ? 0 : 1];
866
+ if (seen === undefined || seen.has(replayed))
867
+ return null;
868
+ seen.add(replayed);
869
+ visitedStates += 1;
870
+ if (visitedStates > EXACT_REPLAY_MAX_STATES) {
871
+ exceeded = "state-limit";
872
+ return null;
873
+ }
874
+ if (applied.length > 0 && replayed === head)
875
+ return [...applied];
876
+ if (index === steps.length)
877
+ return null;
878
+ // Deterministic skip-first DFS finds a late witness quickly and
879
+ // prevents irrelevant historical edits from polluting a valid proof.
880
+ const skipped = search(index + 1, replayed, applied);
881
+ if (skipped !== null || exceeded !== null)
882
+ return skipped;
883
+ const step = steps[index];
884
+ const scanBytes = replayedBytes * 2 +
885
+ Buffer.byteLength(step.before, "utf8");
886
+ if (examinedBytes + scanBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
887
+ exceeded = "byte-limit";
888
+ return null;
889
+ }
890
+ examinedBytes += scanBytes;
891
+ const first = replayed.indexOf(step.before);
892
+ if (first === -1 || replayed.indexOf(step.before, first + 1) !== -1)
893
+ return null;
894
+ // Bound the next allocation before constructing it. `replayed + after` is
895
+ // a conservative upper bound because the replacement removes `before`.
896
+ const prospectiveBytes = replayedBytes + Buffer.byteLength(step.after, "utf8");
897
+ if (examinedBytes + prospectiveBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
898
+ exceeded = "byte-limit";
899
+ return null;
900
+ }
901
+ examinedBytes += prospectiveBytes;
902
+ const next = `${replayed.slice(0, first)}${step.after}${replayed.slice(first + step.before.length)}`;
903
+ return search(index + 1, next, [...applied, step]);
904
+ };
905
+ const proof = search(0, base, []);
906
+ return { steps: proof, bound: exceeded };
907
+ }
479
908
  /** A blob's lines, with the empty tail a trailing newline leaves dropped. */
480
909
  function linesOf(text) {
481
910
  const parts = text.split("\n");
@@ -667,7 +1096,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
667
1096
  * spelled: two different (path, digest) pairs cannot collide into one key.
668
1097
  */
669
1098
  function organKey(organPath, sha256) {
670
- return `${normalizePathSpelling(organPath)}${sha256}`;
1099
+ return `${normalizePathSpelling(organPath)}\0${sha256}`;
671
1100
  }
672
1101
  /** The ordering rule, stated identically on every failure that could be lag. */
673
1102
  const ORDERING_RULE = "the committed log trails the primary checkout's live log, so if this edit WAS granted, " +
@@ -844,8 +1273,21 @@ export function evaluateProtectedPaths(input) {
844
1273
  stale.push(grant);
845
1274
  continue;
846
1275
  }
847
- candidates.push({ grant, match: found });
1276
+ candidates.push({ record: grant, match: found, source: "grant", material, payloadHash: hash });
1277
+ }
1278
+ for (const start of records) {
1279
+ const found = policyAuthorizedEvidence(records, start, path, input.policyProtectedPaths, input.payloadFor, anchorMs, lookbackMs);
1280
+ const hash = payloadOf(start)["payload_hash"];
1281
+ if (found !== null && typeof hash === "string") {
1282
+ const material = input.payloadFor(hash);
1283
+ if (material !== null) {
1284
+ candidates.push({ record: start, match: found, source: "policy", material, payloadHash: hash });
1285
+ }
1286
+ }
848
1287
  }
1288
+ const evidenceNoun = candidates.every((candidate) => candidate.source === "grant")
1289
+ ? `grant${candidates.length === 1 ? "" : "s"}`
1290
+ : `evidence record${candidates.length === 1 ? "" : "s"}`;
849
1291
  /** Distance from the change commit, or 0 when there is no anchor to measure from. */
850
1292
  const distance = (grant) => {
851
1293
  if (anchorMs === null)
@@ -853,9 +1295,9 @@ export function evaluateProtectedPaths(input) {
853
1295
  const at = Date.parse(grant.ts);
854
1296
  return Number.isNaN(at) ? Number.POSITIVE_INFINITY : Math.abs(at - anchorMs);
855
1297
  };
856
- const rank = (kind) => (kind === "granted-file" ? 0 : 1);
1298
+ const rank = (kind) => kind === "granted-file" ? 0 : kind === "policy-authorized-file" ? 1 : 2;
857
1299
  candidates.sort((left, right) => rank(left.match.kind) - rank(right.match.kind) ||
858
- distance(left.grant) - distance(right.grant));
1300
+ distance(left.record) - distance(right.record));
859
1301
  // The change this pull request makes to the path. Read before any coverage
860
1302
  // is attempted, and a failure to read it is a failure: a change the guard
861
1303
  // cannot see is not a change it has checked (APRV-202).
@@ -865,7 +1307,7 @@ export function evaluateProtectedPaths(input) {
865
1307
  path,
866
1308
  ok: false,
867
1309
  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}.`,
1310
+ 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
1311
  });
870
1312
  continue;
871
1313
  }
@@ -881,14 +1323,21 @@ export function evaluateProtectedPaths(input) {
881
1323
  const removedCover = new Set();
882
1324
  let whole = false;
883
1325
  for (const candidate of candidates) {
884
- const { grant, match } = candidate;
885
- const at = `the grant at seq ${grant.seq}`;
1326
+ const { record, match, source } = candidate;
1327
+ // An execution start says an exact file action was authorized, not that a
1328
+ // mode-only or whitespace-only change happened. Legacy human grants retain
1329
+ // their narrow no-substantive-hunk behavior; policy starts do not gain it.
1330
+ if (hunks.identical && source === "policy")
1331
+ continue;
1332
+ const at = source === "grant"
1333
+ ? `the grant at seq ${record.seq}`
1334
+ : `the policy-authorized execution at seq ${record.seq}`;
886
1335
  if (match.command) {
887
1336
  if (!commandTargetsPath(match.target ?? "", match.cwd, path)) {
888
1337
  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
1338
  continue;
890
1339
  }
891
- const run = attributeRun(grant, runs, anchorMs, attributionMs);
1340
+ const run = attributeRun(record, runs, anchorMs, attributionMs);
892
1341
  if (!run.ok) {
893
1342
  rejected.push(run.why);
894
1343
  continue;
@@ -896,7 +1345,7 @@ export function evaluateProtectedPaths(input) {
896
1345
  // A command payload describes no bytes, so attribution is all or
897
1346
  // nothing: the run that wrote this file wrote whatever is in it.
898
1347
  whole = true;
899
- contributors.push({ grant, kind: match.kind, why: `${match.detail}, and ${run.detail}`, whole: true });
1348
+ contributors.push({ record, kind: match.kind, why: `${match.detail}, and ${run.detail}`, whole: true, source });
900
1349
  continue;
901
1350
  }
902
1351
  if (match.after === null && match.before === null) {
@@ -947,8 +1396,34 @@ export function evaluateProtectedPaths(input) {
947
1396
  if (parts.length > 0)
948
1397
  contributed = `${match.detail}, and ${parts.join(" and ")}`;
949
1398
  }
950
- if (contributed !== null)
951
- contributors.push({ grant, kind: match.kind, why: contributed, whole: wholeHere });
1399
+ if (contributed !== null) {
1400
+ contributors.push({ record, kind: match.kind, why: contributed, whole: wholeHere, source });
1401
+ }
1402
+ }
1403
+ // Exact Edit payloads may bind fragments within a line, including several
1404
+ // independent fragments of the same long line. Whole-line set membership
1405
+ // cannot express that safely. Replay is the bounded fallback: genuine
1406
+ // starts, execution order, unique anchors, and final byte equality.
1407
+ const needsReplay = !whole &&
1408
+ !hunks.identical &&
1409
+ (hunks.reordered ||
1410
+ hunks.added.some((line) => !addedCover.has(line)) ||
1411
+ hunks.removed.some((line) => !removedCover.has(line)));
1412
+ const replay = needsReplay
1413
+ ? exactEditReplay(candidates, records, blobs.base, blobs.head, anchorMs, lookbackMs, path, input.policyProtectedPaths)
1414
+ : { steps: null, bound: null };
1415
+ if (replay.bound !== null) {
1416
+ rejected.unshift(`exact BASE-to-HEAD replay refused after reaching its ${replay.bound.replace("-", " ")}`);
1417
+ }
1418
+ if (replay.steps !== null) {
1419
+ whole = true;
1420
+ contributors.splice(0, contributors.length, ...replay.steps.map((step) => ({
1421
+ record: step.candidate.record,
1422
+ kind: step.candidate.match.kind,
1423
+ why: `${step.candidate.match.detail}, applied at execution.started seq ${step.start.seq} in an exact BASE-to-HEAD replay`,
1424
+ whole: true,
1425
+ source: step.candidate.source,
1426
+ })));
952
1427
  }
953
1428
  // What the bytes alone cover, before any whole-file attribution. If this is
954
1429
  // non-empty the change RESTS on the attribution, and the finding has to
@@ -976,12 +1451,14 @@ export function evaluateProtectedPaths(input) {
976
1451
  const lead = restsOnAttribution
977
1452
  ? (contributors.find((one) => one.whole) ?? contributors[0])
978
1453
  : contributors[0];
979
- const quiet = hunks.identical ? candidates[0] : undefined;
1454
+ const quiet = hunks.identical
1455
+ ? candidates.find((candidate) => candidate.source === "grant")
1456
+ : undefined;
980
1457
  const passing = lead !== undefined
981
- ? { record: lead.grant, kind: lead.kind, why: lead.why }
1458
+ ? { record: lead.record, kind: lead.kind, why: lead.why }
982
1459
  : quiet !== undefined
983
1460
  ? {
984
- record: quiet.grant,
1461
+ record: quiet.record,
985
1462
  kind: quiet.match.kind,
986
1463
  why: `${quiet.match.detail}, and this change alters no substantive line (whitespace, mode or metadata only), so there is no hunk to cover`,
987
1464
  }
@@ -997,14 +1474,16 @@ export function evaluateProtectedPaths(input) {
997
1474
  seq: record.seq,
998
1475
  ts: record.ts,
999
1476
  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
- : ""}`,
1477
+ coveredBy: contributors.map((one) => one.record.seq),
1478
+ detail: replay.steps !== null
1479
+ ? `${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}.`
1480
+ : `${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
1481
+ ? "there are no substantive hunks to cover"
1482
+ : whole
1483
+ ? "the whole change is attributed to that authorization record"
1484
+ : `${hunks.added.length} added and ${hunks.removed.length} removed line(s) all trace to authorized material`}${contributors.length > 1
1485
+ ? ` (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)`
1486
+ : ""}`,
1008
1487
  });
1009
1488
  continue;
1010
1489
  }
@@ -1019,8 +1498,8 @@ export function evaluateProtectedPaths(input) {
1019
1498
  ok: false,
1020
1499
  code: "uncovered-hunk",
1021
1500
  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(", ")}); `
1501
+ 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
1502
+ ? `${contributors.length} evidence record${contributors.length === 1 ? "" : "s"} covered part of it (seq ${contributors.map((one) => one.record.seq).join(", ")}); `
1024
1503
  : ""}${rejected.length > 0
1025
1504
  ? `${rejected.slice(0, 6).join("; ")}${rejected.length > 6 ? `; … and ${rejected.length - 6} other naming grants set aside for the same kinds of reason` : ""}. `
1026
1505
  : ""}uncovered: ${sample.map((line) => JSON.stringify(line)).join(", ")}${uncovered.length > sample.length ? `, … ${uncovered.length - sample.length} more` : ""}. ${windowText(input.window)}. ${ORDERING_RULE}.`,