approval-md 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/README.md +63 -24
  2. package/SPEC.md +57 -11
  3. package/dist/src/channels/contract.d.ts +34 -1
  4. package/dist/src/channels/contract.js +200 -7
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/telegram.d.ts +123 -11
  7. package/dist/src/channels/telegram.js +218 -23
  8. package/dist/src/channels/telegram.js.map +1 -1
  9. package/dist/src/channels/web.d.ts +9 -0
  10. package/dist/src/channels/web.js +17 -0
  11. package/dist/src/channels/web.js.map +1 -1
  12. package/dist/src/cli/amend.js +214 -30
  13. package/dist/src/cli/amend.js.map +1 -1
  14. package/dist/src/cli/attest.d.ts +9 -0
  15. package/dist/src/cli/attest.js +134 -7
  16. package/dist/src/cli/attest.js.map +1 -1
  17. package/dist/src/cli/channel-telegram.d.ts +99 -26
  18. package/dist/src/cli/channel-telegram.js +311 -13
  19. package/dist/src/cli/channel-telegram.js.map +1 -1
  20. package/dist/src/cli/channel.d.ts +9 -0
  21. package/dist/src/cli/channel.js +9 -0
  22. package/dist/src/cli/channel.js.map +1 -1
  23. package/dist/src/cli/codex-bridge.d.ts +819 -0
  24. package/dist/src/cli/codex-bridge.js +1607 -0
  25. package/dist/src/cli/codex-bridge.js.map +1 -0
  26. package/dist/src/cli/codex.d.ts +1 -1
  27. package/dist/src/cli/codex.js +304 -7
  28. package/dist/src/cli/codex.js.map +1 -1
  29. package/dist/src/cli/daemon.js +4 -1
  30. package/dist/src/cli/daemon.js.map +1 -1
  31. package/dist/src/cli/doctor.js +467 -12
  32. package/dist/src/cli/doctor.js.map +1 -1
  33. package/dist/src/cli/execute.js +25 -2
  34. package/dist/src/cli/execute.js.map +1 -1
  35. package/dist/src/cli/help.d.ts +6 -2
  36. package/dist/src/cli/help.js +165 -60
  37. package/dist/src/cli/help.js.map +1 -1
  38. package/dist/src/cli/hook-codex.d.ts +49 -1
  39. package/dist/src/cli/hook-codex.js +60 -1
  40. package/dist/src/cli/hook-codex.js.map +1 -1
  41. package/dist/src/cli/hook.d.ts +459 -3
  42. package/dist/src/cli/hook.js +1062 -114
  43. package/dist/src/cli/hook.js.map +1 -1
  44. package/dist/src/cli/import.js +1 -1
  45. package/dist/src/cli/import.js.map +1 -1
  46. package/dist/src/cli/main.js +5 -3
  47. package/dist/src/cli/main.js.map +1 -1
  48. package/dist/src/cli/policy-apply.d.ts +195 -0
  49. package/dist/src/cli/policy-apply.js +573 -0
  50. package/dist/src/cli/policy-apply.js.map +1 -0
  51. package/dist/src/cli/policy.js +14 -1
  52. package/dist/src/cli/policy.js.map +1 -1
  53. package/dist/src/cli/preflight.d.ts +151 -13
  54. package/dist/src/cli/preflight.js +398 -41
  55. package/dist/src/cli/preflight.js.map +1 -1
  56. package/dist/src/cli/sandbox.js +17 -1
  57. package/dist/src/cli/sandbox.js.map +1 -1
  58. package/dist/src/cli/scaffold.d.ts +1 -1
  59. package/dist/src/cli/scaffold.js +1 -1
  60. package/dist/src/cli/setup-channel.d.ts +9 -0
  61. package/dist/src/cli/setup-channel.js +28 -1
  62. package/dist/src/cli/setup-channel.js.map +1 -1
  63. package/dist/src/cli/setup-common.d.ts +3 -1
  64. package/dist/src/cli/setup-common.js +3 -2
  65. package/dist/src/cli/setup-common.js.map +1 -1
  66. package/dist/src/cli/setup.d.ts +2 -0
  67. package/dist/src/cli/setup.js +94 -2
  68. package/dist/src/cli/setup.js.map +1 -1
  69. package/dist/src/cli/up.js +115 -51
  70. package/dist/src/cli/up.js.map +1 -1
  71. package/dist/src/cli/values.js +3 -4
  72. package/dist/src/cli/values.js.map +1 -1
  73. package/dist/src/cli/verb-registry.js +174 -9
  74. package/dist/src/cli/verb-registry.js.map +1 -1
  75. package/dist/src/cli/wordmark.d.ts +2 -2
  76. package/dist/src/cli/wordmark.js +2 -2
  77. package/dist/src/codex/broker.d.ts +229 -0
  78. package/dist/src/codex/broker.js +548 -0
  79. package/dist/src/codex/broker.js.map +1 -0
  80. package/dist/src/codex/runner.d.ts +178 -0
  81. package/dist/src/codex/runner.js +231 -0
  82. package/dist/src/codex/runner.js.map +1 -0
  83. package/dist/src/codex/serve.d.ts +56 -0
  84. package/dist/src/codex/serve.js +98 -0
  85. package/dist/src/codex/serve.js.map +1 -0
  86. package/dist/src/codex/workspace-commit.d.ts +219 -0
  87. package/dist/src/codex/workspace-commit.js +549 -0
  88. package/dist/src/codex/workspace-commit.js.map +1 -0
  89. package/dist/src/core/advance-cycle.d.ts +51 -0
  90. package/dist/src/core/advance-cycle.js +66 -2
  91. package/dist/src/core/advance-cycle.js.map +1 -1
  92. package/dist/src/core/agents-md.d.ts +20 -18
  93. package/dist/src/core/agents-md.js +33 -31
  94. package/dist/src/core/agents-md.js.map +1 -1
  95. package/dist/src/core/attest.d.ts +215 -0
  96. package/dist/src/core/attest.js +317 -7
  97. package/dist/src/core/attest.js.map +1 -1
  98. package/dist/src/core/audit.d.ts +18 -0
  99. package/dist/src/core/audit.js +13 -0
  100. package/dist/src/core/audit.js.map +1 -1
  101. package/dist/src/core/channel-owner.d.ts +213 -0
  102. package/dist/src/core/channel-owner.js +358 -0
  103. package/dist/src/core/channel-owner.js.map +1 -0
  104. package/dist/src/core/command-class.d.ts +154 -0
  105. package/dist/src/core/command-class.js +673 -20
  106. package/dist/src/core/command-class.js.map +1 -1
  107. package/dist/src/core/commit-guard.d.ts +272 -0
  108. package/dist/src/core/commit-guard.js +424 -0
  109. package/dist/src/core/commit-guard.js.map +1 -0
  110. package/dist/src/core/daemon-actor.d.ts +45 -0
  111. package/dist/src/core/daemon-actor.js +54 -0
  112. package/dist/src/core/daemon-actor.js.map +1 -0
  113. package/dist/src/core/dark-session.d.ts +109 -8
  114. package/dist/src/core/dark-session.js +266 -82
  115. package/dist/src/core/dark-session.js.map +1 -1
  116. package/dist/src/core/decision-refusal.d.ts +23 -2
  117. package/dist/src/core/decision-refusal.js +24 -2
  118. package/dist/src/core/decision-refusal.js.map +1 -1
  119. package/dist/src/core/env-file.d.ts +5 -0
  120. package/dist/src/core/env-file.js +60 -1
  121. package/dist/src/core/env-file.js.map +1 -1
  122. package/dist/src/core/execute.d.ts +15 -2
  123. package/dist/src/core/execute.js +15 -2
  124. package/dist/src/core/execute.js.map +1 -1
  125. package/dist/src/core/gate.d.ts +86 -1
  126. package/dist/src/core/gate.js +81 -1
  127. package/dist/src/core/gate.js.map +1 -1
  128. package/dist/src/core/gesture-refusal.d.ts +166 -0
  129. package/dist/src/core/gesture-refusal.js +188 -0
  130. package/dist/src/core/gesture-refusal.js.map +1 -0
  131. package/dist/src/core/harness-version.d.ts +1 -1
  132. package/dist/src/core/harness-version.js +3 -1
  133. package/dist/src/core/harness-version.js.map +1 -1
  134. package/dist/src/core/instance.d.ts +59 -2
  135. package/dist/src/core/instance.js +113 -0
  136. package/dist/src/core/instance.js.map +1 -1
  137. package/dist/src/core/log.d.ts +39 -1
  138. package/dist/src/core/log.js.map +1 -1
  139. package/dist/src/core/policy-explain.d.ts +10 -0
  140. package/dist/src/core/policy-explain.js +32 -0
  141. package/dist/src/core/policy-explain.js.map +1 -1
  142. package/dist/src/core/policy-load.d.ts +41 -1
  143. package/dist/src/core/policy-load.js +21 -3
  144. package/dist/src/core/policy-load.js.map +1 -1
  145. package/dist/src/core/policy-match.d.ts +43 -0
  146. package/dist/src/core/policy-match.js +52 -0
  147. package/dist/src/core/policy-match.js.map +1 -1
  148. package/dist/src/core/policy-proposal.d.ts +52 -0
  149. package/dist/src/core/policy-proposal.js +102 -2
  150. package/dist/src/core/policy-proposal.js.map +1 -1
  151. package/dist/src/core/protected-path-guard.d.ts +117 -4
  152. package/dist/src/core/protected-path-guard.js +362 -48
  153. package/dist/src/core/protected-path-guard.js.map +1 -1
  154. package/dist/src/core/question-preempted.d.ts +141 -0
  155. package/dist/src/core/question-preempted.js +152 -0
  156. package/dist/src/core/question-preempted.js.map +1 -0
  157. package/dist/src/core/read-scope.d.ts +172 -0
  158. package/dist/src/core/read-scope.js +252 -0
  159. package/dist/src/core/read-scope.js.map +1 -0
  160. package/dist/src/core/sandbox.d.ts +81 -0
  161. package/dist/src/core/sandbox.js +190 -1
  162. package/dist/src/core/sandbox.js.map +1 -1
  163. package/dist/src/core/sender-identity.d.ts +476 -0
  164. package/dist/src/core/sender-identity.js +572 -0
  165. package/dist/src/core/sender-identity.js.map +1 -0
  166. package/dist/src/core/shlex.d.ts +102 -0
  167. package/dist/src/core/shlex.js +159 -0
  168. package/dist/src/core/shlex.js.map +1 -0
  169. package/dist/src/core/values.d.ts +18 -8
  170. package/dist/src/core/values.js +36 -1
  171. package/dist/src/core/values.js.map +1 -1
  172. package/dist/src/daemon/advance.d.ts +10 -0
  173. package/dist/src/daemon/advance.js +25 -4
  174. package/dist/src/daemon/advance.js.map +1 -1
  175. package/dist/src/daemon/daemon.js +9 -0
  176. package/dist/src/daemon/daemon.js.map +1 -1
  177. package/dist/src/daemon/git-evidence.d.ts +2 -2
  178. package/dist/src/daemon/git-evidence.js +1 -1
  179. package/dist/src/mcp/server.js +8 -0
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/docs/cli-reference.md +932 -32
  182. package/docs/codex-enforced-session.md +75 -2
  183. package/docs/codex-workspace-broker.md +118 -0
  184. package/package.json +3 -1
  185. package/schema/event.schema.json +538 -9
  186. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  187. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  188. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  189. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  190. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  191. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  192. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  193. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  194. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  195. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  196. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  197. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  198. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  199. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  200. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  201. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  202. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  203. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  204. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  205. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  206. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  207. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  208. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  209. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  210. package/schema/fixtures/policy/valid/canonical.json +1 -1
  211. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  212. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  213. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  214. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  215. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  216. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  217. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  218. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  219. package/schema/fixtures/values/invalid/version-float.json +1 -0
  220. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  221. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  222. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  223. package/schema/fixtures/values/valid/full.json +5 -7
  224. package/schema/fixtures/values/valid/minimal.json +1 -1
  225. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  226. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  227. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  228. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  229. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  230. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  231. package/schema/fixtures/values-md/valid/absent.md +1 -1
  232. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  233. package/schema/policy.schema.json +54 -2
  234. package/schema/values.schema.json +7 -11
  235. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -22,6 +22,17 @@
22
22
  * execution. A change with no evidence fails the pull request. Session wiring
23
23
  * is not an input.
24
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.
35
+ *
25
36
  * ## What counts as evidence
26
37
  *
27
38
  * Evidence is about the CHANGE, not about the path (APRV-202). The guard reads
@@ -53,6 +64,18 @@
53
64
  * match: a digest attested for one organ is not evidence for another, which
54
65
  * is why the organ record carries a whole relative path where the policy
55
66
  * record carries a basename.
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.
56
79
  * 2. `policy-authorized-file` — an exact Edit or Write whose verified
57
80
  * `execution.started` is preceded by its unique matching registration and no
58
81
  * approval request. The registration, start and recomputed stored payload
@@ -60,6 +83,21 @@
60
83
  * change and the recorded class must be the class this path is routed to.
61
84
  * This records authorization to execute, not successful completion; the
62
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.
63
101
  * 3. `granted-file` — a file-tool edit. The hook binds the CHANGE rather than
64
102
  * the touch (APRV-124), so the bound material carries `file` plus the exact
65
103
  * edit: `{before, after}` for an Edit, `{content}` for a Write. That is
@@ -134,6 +172,22 @@
134
172
  * against the head commit would have been theatre. The finding says which of
135
173
  * the two it got, every time.
136
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
+ *
137
191
  * ## How a hunk is decided to be covered
138
192
  *
139
193
  * The unit is a line of text. `added` is the multiset of lines the head blob
@@ -145,6 +199,18 @@
145
199
  * several approved edits to one file; the finding names every contributing
146
200
  * grant and puts the strongest and nearest at the head.
147
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
+ *
148
214
  * Three properties of that choice are worth stating, because each is a limit:
149
215
  *
150
216
  * - A blob that differs while its line multiset does not is a REORDERING, and
@@ -198,7 +264,7 @@
198
264
  * This module appends nothing, reads no clock, and performs no IO of its own —
199
265
  * git plumbing and file reads live in the caller.
200
266
  */
201
- import { organAttestationOf } from "./attest.js";
267
+ import { organAttestationOf, pathSignOffOf } from "./attest.js";
202
268
  import { parseApplyPatch } from "./apply-patch.js";
203
269
  import { classifyCommand, isGateOrganPath, isProtectedPath, normalizePathSpelling, POLICY_EDIT_SUBCLASS, protectedPathClass, } from "./command-class.js";
204
270
  import { payloadHash } from "./payload.js";
@@ -315,6 +381,50 @@ function endsWithSegments(candidate, want) {
315
381
  const offset = have.length - tail.length;
316
382
  return tail.every((segment, index) => segment === have[offset + index]);
317
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
+ }
318
428
  /**
319
429
  * Does this command line WRITE `path`, as the runtime's own classifier reads it?
320
430
  *
@@ -555,12 +665,7 @@ function policyFileEvidence(material, path, policyProtectedPaths) {
555
665
  const map = material;
556
666
  const tool = map["tool"];
557
667
  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("/"))
668
+ if (typeof file !== "string" || !namesProtectedFile(file, path))
564
669
  return null;
565
670
  const keys = Object.keys(map);
566
671
  const hasOnly = (allowed) => keys.every((key) => allowed.includes(key));
@@ -612,6 +717,28 @@ function hasAmbiguousRoute(path, policyProtectedPaths) {
612
717
  }
613
718
  return classes.size > 1;
614
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
+ }
615
742
  /**
616
743
  * Exact-file evidence from an unattended execution authorized by policy.
617
744
  *
@@ -619,7 +746,7 @@ function hasAmbiguousRoute(path, policyProtectedPaths) {
619
746
  * committed payload must hash back to that declaration. A prior request means
620
747
  * the action entered the human-decision path, so this tier cannot carry it.
621
748
  */
622
- function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, payloadFor, anchorMs, lookbackMs) {
749
+ function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, payloadFor, anchor, lookbackMs) {
623
750
  if (start.event !== "execution.started")
624
751
  return null;
625
752
  const task = start.task;
@@ -657,12 +784,15 @@ function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, pa
657
784
  record.action_key === actionKey &&
658
785
  record.event === "approval.requested"))
659
786
  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)
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)
663
793
  return null;
664
794
  const at = Date.parse(start.ts);
665
- if (Number.isNaN(at) || at > anchorMs || anchorMs - at > lookbackMs)
795
+ if (Number.isNaN(at) || at > committerMs || authorMs - at > lookbackMs)
666
796
  return null;
667
797
  const material = payloadFor(hash);
668
798
  if (material === null)
@@ -676,7 +806,7 @@ function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, pa
676
806
  }
677
807
  return policyFileEvidence(material, path, policyProtectedPaths);
678
808
  }
679
- /** The closed, repository-relative Edit shape eligible for byte replay. */
809
+ /** The closed Edit shape, naming this path, eligible for byte replay. */
680
810
  function exactReplayEdit(material, path) {
681
811
  if (typeof material !== "object" || material === null || Array.isArray(material))
682
812
  return null;
@@ -687,8 +817,7 @@ function exactReplayEdit(material, path) {
687
817
  return null;
688
818
  if (map["tool"] !== "Edit" ||
689
819
  typeof map["file"] !== "string" ||
690
- map["file"].startsWith("/") ||
691
- map["file"].includes("\\") ||
820
+ !namesProtectedFile(map["file"], path) ||
692
821
  (map["rule"] !== undefined && typeof map["rule"] !== "string") ||
693
822
  typeof map["before"] !== "string" ||
694
823
  map["before"].length === 0 ||
@@ -696,17 +825,12 @@ function exactReplayEdit(material, path) {
696
825
  map["before"] === map["after"] ||
697
826
  (map["replace_all"] !== undefined && map["replace_all"] !== false))
698
827
  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
828
  return { before: map["before"], after: map["after"] };
706
829
  }
707
830
  /** 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)
831
+ function startForReplayGrant(candidate, records, anchor, lookbackMs, path, policyProtectedPaths) {
832
+ const { authorMs, committerMs } = anchor;
833
+ if (authorMs === null || committerMs === null)
710
834
  return null;
711
835
  const grant = candidate.record;
712
836
  const task = grant.task;
@@ -752,8 +876,10 @@ function startForReplayGrant(candidate, records, anchorMs, lookbackMs, path, pol
752
876
  if (hasTokenLink &&
753
877
  (typeof grantToken !== "string" || started["token_sha256"] !== grantToken))
754
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.
755
881
  const at = Date.parse(record.ts);
756
- return !Number.isNaN(at) && at <= anchorMs && anchorMs - at <= lookbackMs;
882
+ return !Number.isNaN(at) && at <= committerMs && authorMs - at <= lookbackMs;
757
883
  });
758
884
  if (starts.length !== 1)
759
885
  return null;
@@ -795,7 +921,7 @@ function startForReplayGrant(candidate, records, anchorMs, lookbackMs, path, pol
795
921
  * examined. Crossing any bound refuses the fallback rather than pruning a
796
922
  * potentially valid branch and claiming the remaining search was complete.
797
923
  */
798
- function exactEditReplay(candidates, records, base, head, anchorMs, lookbackMs, path, policyProtectedPaths) {
924
+ function exactEditReplay(candidates, records, base, head, anchor, lookbackMs, path, policyProtectedPaths) {
799
925
  if (base === null ||
800
926
  head === null ||
801
927
  base.includes("\uFFFD") ||
@@ -815,7 +941,7 @@ function exactEditReplay(candidates, records, base, head, anchorMs, lookbackMs,
815
941
  continue;
816
942
  const start = candidate.source === "policy"
817
943
  ? candidate.record
818
- : startForReplayGrant(candidate, records, anchorMs, lookbackMs, path, policyProtectedPaths);
944
+ : startForReplayGrant(candidate, records, anchor, lookbackMs, path, policyProtectedPaths);
819
945
  if (start !== null)
820
946
  gathered.push({ candidate, start, ...edit });
821
947
  }
@@ -912,6 +1038,38 @@ function linesOf(text) {
912
1038
  parts.pop();
913
1039
  return parts;
914
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
+ }
915
1073
  /** A line that carries content. Blank lines neither need coverage nor give it. */
916
1074
  function substantive(line) {
917
1075
  return line.trim().length > 0;
@@ -984,8 +1142,8 @@ function windowText(window) {
984
1142
  }
985
1143
  /**
986
1144
  * How far past the change commit a run may still start and be its cause: five
987
- * minutes, for clock disagreement between the log and git's author date. It is
988
- * 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.
989
1147
  */
990
1148
  const SKEW_GRACE_MS = 5 * 60 * 1000;
991
1149
  /** Milliseconds as something a failure message can say out loud. */
@@ -1009,7 +1167,12 @@ function spanText(ms) {
1009
1167
  * interval it actually occupied: a batch that started before the commit and
1010
1168
  * finished after it brackets the commit, which is the strongest form of this.
1011
1169
  */
1012
- 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;
1013
1176
  const key = grant.action_key;
1014
1177
  if (key === undefined) {
1015
1178
  return {
@@ -1027,7 +1190,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
1027
1190
  }
1028
1191
  const completed = tied.filter((record) => record.event === "execution.completed");
1029
1192
  const endOf = (record) => completed.find((done) => done.seq > record.seq);
1030
- if (anchorMs === null) {
1193
+ if (authorMs === null || committerMs === null) {
1031
1194
  const first = started[0];
1032
1195
  return {
1033
1196
  ok: true,
@@ -1040,7 +1203,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
1040
1203
  // the record is appended before the process is spawned. The real log shows
1041
1204
  // what the symmetric window costs — a SPEC.md batch run four hours AFTER
1042
1205
  // PR #187's commit would otherwise have carried that commit's changes.
1043
- // `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)
1044
1207
  // disagreeing, not for ordering.
1045
1208
  let best = null;
1046
1209
  let laterOnly = null;
@@ -1048,7 +1211,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
1048
1211
  const from = Date.parse(record.ts);
1049
1212
  if (Number.isNaN(from))
1050
1213
  continue;
1051
- if (from > anchorMs + SKEW_GRACE_MS) {
1214
+ if (from > committerMs + SKEW_GRACE_MS) {
1052
1215
  if (laterOnly === null)
1053
1216
  laterOnly = record;
1054
1217
  continue;
@@ -1056,9 +1219,9 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
1056
1219
  const end = endOf(record);
1057
1220
  const to = end === undefined ? from : Date.parse(end.ts);
1058
1221
  const upper = Number.isNaN(to) ? from : to;
1059
- 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)
1060
1223
  ? 0
1061
- : Math.min(Math.abs(from - anchorMs), Math.abs(upper - anchorMs));
1224
+ : Math.min(Math.abs(from - authorMs), Math.abs(upper - authorMs));
1062
1225
  if (best === null || distance < best.distance)
1063
1226
  best = { record, end, distance };
1064
1227
  }
@@ -1098,6 +1261,40 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
1098
1261
  function organKey(organPath, sha256) {
1099
1262
  return `${normalizePathSpelling(organPath)}\0${sha256}`;
1100
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.`;
1297
+ }
1101
1298
  /** The ordering rule, stated identically on every failure that could be lag. */
1102
1299
  const ORDERING_RULE = "the committed log trails the primary checkout's live log, so if this edit WAS granted, " +
1103
1300
  "the log advance carrying the grant must be pushed to a records branch or merged to main " +
@@ -1162,6 +1359,18 @@ export function evaluateProtectedPaths(input) {
1162
1359
  continue;
1163
1360
  organAttestations.set(organKey(fields.organPath, fields.sha256), record);
1164
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
+ }
1165
1374
  // The grants of a class that can authorize a protected write.
1166
1375
  const grants = records.filter((record) => record.event === "approval.granted" &&
1167
1376
  isGrantingClass(String(payloadOf(record)["class"] ?? "")));
@@ -1186,8 +1395,7 @@ export function evaluateProtectedPaths(input) {
1186
1395
  const findings = [];
1187
1396
  for (const path of guarded) {
1188
1397
  // 1. The policy file, by attestation.
1189
- if (endsWithSegments(input.policyPath, path) ||
1190
- endsWithSegments(path, input.policyPath)) {
1398
+ if (namesPolicyFile(path, input.policyPath)) {
1191
1399
  if (input.policySha256AtHead !== null) {
1192
1400
  const attested = attestations.get(input.policySha256AtHead);
1193
1401
  if (attested !== undefined) {
@@ -1235,18 +1443,24 @@ export function evaluateProtectedPaths(input) {
1235
1443
  const lookbackMs = input.lookbackMs ?? DEFAULT_LOOKBACK_MS;
1236
1444
  // ONE derivation of the anchor, so the bound that is enforced and the bound
1237
1445
  // that is reported cannot disagree. An unparseable timestamp is no anchor,
1238
- // exactly as a missing one is not: both land in `anchorMs === null`.
1239
- const parsed = changeTs === null ? Number.NaN : Date.parse(changeTs);
1240
- 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;
1241
1452
  const inWindow = (ts) => {
1242
1453
  if (anchorMs === null)
1243
1454
  return true;
1244
1455
  const at = Date.parse(ts);
1245
1456
  return !Number.isNaN(at) && Math.abs(at - anchorMs) <= lookbackMs;
1246
1457
  };
1458
+ const datedText = anchor.committerTs === null || anchor.committerTs === anchor.authorTs
1459
+ ? `${anchor.authorTs}`
1460
+ : `authored ${anchor.authorTs}, committed ${anchor.committerTs}`;
1247
1461
  const boundText = anchorMs === null
1248
- ? `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`
1249
- : `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})`;
1250
1464
  // EVERY qualifying grant is collected and the best one is reported, rather
1251
1465
  // than the first one found. The first-match version passed commit 41d2c9f
1252
1466
  // on a `cp SPEC.md <dir>/` granted four days earlier while the grant that
@@ -1276,7 +1490,7 @@ export function evaluateProtectedPaths(input) {
1276
1490
  candidates.push({ record: grant, match: found, source: "grant", material, payloadHash: hash });
1277
1491
  }
1278
1492
  for (const start of records) {
1279
- const found = policyAuthorizedEvidence(records, start, path, input.policyProtectedPaths, input.payloadFor, anchorMs, lookbackMs);
1493
+ const found = policyAuthorizedEvidence(records, start, path, input.policyProtectedPaths, input.payloadFor, anchor, lookbackMs);
1280
1494
  const hash = payloadOf(start)["payload_hash"];
1281
1495
  if (found !== null && typeof hash === "string") {
1282
1496
  const material = input.payloadFor(hash);
@@ -1337,7 +1551,7 @@ export function evaluateProtectedPaths(input) {
1337
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`);
1338
1552
  continue;
1339
1553
  }
1340
- const run = attributeRun(record, runs, anchorMs, attributionMs);
1554
+ const run = attributeRun(record, runs, anchor, attributionMs);
1341
1555
  if (!run.ok) {
1342
1556
  rejected.push(run.why);
1343
1557
  continue;
@@ -1400,6 +1614,71 @@ export function evaluateProtectedPaths(input) {
1400
1614
  contributors.push({ record, kind: match.kind, why: contributed, whole: wholeHere, source });
1401
1615
  }
1402
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
+ }
1403
1682
  // Exact Edit payloads may bind fragments within a line, including several
1404
1683
  // independent fragments of the same long line. Whole-line set membership
1405
1684
  // cannot express that safely. Replay is the bounded fallback: genuine
@@ -1410,7 +1689,7 @@ export function evaluateProtectedPaths(input) {
1410
1689
  hunks.added.some((line) => !addedCover.has(line)) ||
1411
1690
  hunks.removed.some((line) => !removedCover.has(line)));
1412
1691
  const replay = needsReplay
1413
- ? exactEditReplay(candidates, records, blobs.base, blobs.head, anchorMs, lookbackMs, path, input.policyProtectedPaths)
1692
+ ? exactEditReplay(candidates, records, blobs.base, blobs.head, anchor, lookbackMs, path, input.policyProtectedPaths)
1414
1693
  : { steps: null, bound: null };
1415
1694
  if (replay.bound !== null) {
1416
1695
  rejected.unshift(`exact BASE-to-HEAD replay refused after reaching its ${replay.bound.replace("-", " ")}`);
@@ -1487,6 +1766,35 @@ export function evaluateProtectedPaths(input) {
1487
1766
  });
1488
1767
  continue;
1489
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
+ }
1490
1798
  // Naming grants exist and the change is not made of them: the repeat-edit
1491
1799
  // shape. Its own code, because the reader's next move differs from
1492
1800
  // `no-evidence` — take THIS change to the gate, rather than hunt for a
@@ -1502,7 +1810,7 @@ export function evaluateProtectedPaths(input) {
1502
1810
  ? `${contributors.length} evidence record${contributors.length === 1 ? "" : "s"} covered part of it (seq ${contributors.map((one) => one.record.seq).join(", ")}); `
1503
1811
  : ""}${rejected.length > 0
1504
1812
  ? `${rejected.slice(0, 6).join("; ")}${rejected.length > 6 ? `; … and ${rejected.length - 6} other naming grants set aside for the same kinds of reason` : ""}. `
1505
- : ""}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)}`,
1506
1814
  });
1507
1815
  continue;
1508
1816
  }
@@ -1516,10 +1824,16 @@ export function evaluateProtectedPaths(input) {
1516
1824
  if (unresolved.length > 0) {
1517
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`);
1518
1826
  }
1519
- if ((endsWithSegments(input.policyPath, path) || endsWithSegments(path, input.policyPath)) &&
1520
- input.policySha256AtHead !== null) {
1827
+ if (namesPolicyFile(path, input.policyPath) && input.policySha256AtHead !== null) {
1521
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`);
1522
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
+ }
1523
1837
  if (isGateOrganPath(path)) {
1524
1838
  // The one failure in this guard whose repair is NOT "take the change to
1525
1839
  // the gate": there is no gate for it. `policy.core` is human-only, the
@@ -1535,7 +1849,7 @@ export function evaluateProtectedPaths(input) {
1535
1849
  path,
1536
1850
  ok: false,
1537
1851
  code: "no-evidence",
1538
- 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)}`,
1539
1853
  });
1540
1854
  }
1541
1855
  return {