approval-md 0.3.0 → 0.4.1

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 (270) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +101 -13
  3. package/cli.js +134 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +72 -0
  8. package/dist/src/channels/contract.js +161 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.d.ts +25 -0
  17. package/dist/src/channels/tagging.js +63 -6
  18. package/dist/src/channels/tagging.js.map +1 -1
  19. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  20. package/dist/src/channels/telegram-minimal.js +618 -0
  21. package/dist/src/channels/telegram-minimal.js.map +1 -0
  22. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  23. package/dist/src/channels/telegram-webhook.js +536 -0
  24. package/dist/src/channels/telegram-webhook.js.map +1 -0
  25. package/dist/src/channels/telegram.d.ts +725 -20
  26. package/dist/src/channels/telegram.js +1332 -123
  27. package/dist/src/channels/telegram.js.map +1 -1
  28. package/dist/src/cli/amend.js +358 -5
  29. package/dist/src/cli/amend.js.map +1 -1
  30. package/dist/src/cli/attest.js +43 -6
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/audit-card.d.ts +1 -1
  33. package/dist/src/cli/audit-card.js +40 -1
  34. package/dist/src/cli/audit-card.js.map +1 -1
  35. package/dist/src/cli/audit.d.ts +7 -5
  36. package/dist/src/cli/audit.js +22 -10
  37. package/dist/src/cli/audit.js.map +1 -1
  38. package/dist/src/cli/channel-relay.d.ts +108 -0
  39. package/dist/src/cli/channel-relay.js +334 -0
  40. package/dist/src/cli/channel-relay.js.map +1 -0
  41. package/dist/src/cli/channel-telegram-webhook.d.ts +275 -0
  42. package/dist/src/cli/channel-telegram-webhook.js +716 -0
  43. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  44. package/dist/src/cli/channel-telegram.d.ts +347 -28
  45. package/dist/src/cli/channel-telegram.js +648 -56
  46. package/dist/src/cli/channel-telegram.js.map +1 -1
  47. package/dist/src/cli/channel.d.ts +1 -1
  48. package/dist/src/cli/channel.js +5 -1
  49. package/dist/src/cli/channel.js.map +1 -1
  50. package/dist/src/cli/daemon.js +37 -5
  51. package/dist/src/cli/daemon.js.map +1 -1
  52. package/dist/src/cli/doctor.d.ts +63 -0
  53. package/dist/src/cli/doctor.js +856 -111
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +29 -1
  56. package/dist/src/cli/execute.js +387 -21
  57. package/dist/src/cli/execute.js.map +1 -1
  58. package/dist/src/cli/exit-codes.d.ts +12 -0
  59. package/dist/src/cli/exit-codes.js +13 -0
  60. package/dist/src/cli/exit-codes.js.map +1 -1
  61. package/dist/src/cli/gate.d.ts +2 -0
  62. package/dist/src/cli/gate.js +233 -2
  63. package/dist/src/cli/gate.js.map +1 -1
  64. package/dist/src/cli/help.d.ts +10 -1
  65. package/dist/src/cli/help.js +283 -54
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook.d.ts +485 -6
  68. package/dist/src/cli/hook.js +2370 -70
  69. package/dist/src/cli/hook.js.map +1 -1
  70. package/dist/src/cli/init.js +16 -7
  71. package/dist/src/cli/init.js.map +1 -1
  72. package/dist/src/cli/instructions.js +24 -3
  73. package/dist/src/cli/instructions.js.map +1 -1
  74. package/dist/src/cli/log-anchor.d.ts +55 -0
  75. package/dist/src/cli/log-anchor.js +199 -60
  76. package/dist/src/cli/log-anchor.js.map +1 -1
  77. package/dist/src/cli/log-unlock.d.ts +23 -0
  78. package/dist/src/cli/log-unlock.js +109 -0
  79. package/dist/src/cli/log-unlock.js.map +1 -0
  80. package/dist/src/cli/main.d.ts +0 -12
  81. package/dist/src/cli/main.js +72 -4
  82. package/dist/src/cli/main.js.map +1 -1
  83. package/dist/src/cli/muse.d.ts +2 -0
  84. package/dist/src/cli/muse.js +62 -0
  85. package/dist/src/cli/muse.js.map +1 -0
  86. package/dist/src/cli/payload.js +79 -1
  87. package/dist/src/cli/payload.js.map +1 -1
  88. package/dist/src/cli/scaffold.js +6 -0
  89. package/dist/src/cli/scaffold.js.map +1 -1
  90. package/dist/src/cli/serve.d.ts +53 -0
  91. package/dist/src/cli/serve.js +270 -0
  92. package/dist/src/cli/serve.js.map +1 -0
  93. package/dist/src/cli/up.js +26 -2
  94. package/dist/src/cli/up.js.map +1 -1
  95. package/dist/src/cli/verb-registry.d.ts +2 -0
  96. package/dist/src/cli/verb-registry.js +338 -39
  97. package/dist/src/cli/verb-registry.js.map +1 -1
  98. package/dist/src/cli/wordmark.d.ts +2 -2
  99. package/dist/src/cli/wordmark.js +2 -2
  100. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  101. package/dist/src/core/attest-bootstrap.js +135 -0
  102. package/dist/src/core/attest-bootstrap.js.map +1 -0
  103. package/dist/src/core/attest.d.ts +20 -3
  104. package/dist/src/core/attest.js +25 -0
  105. package/dist/src/core/attest.js.map +1 -1
  106. package/dist/src/core/audit.d.ts +153 -12
  107. package/dist/src/core/audit.js +415 -20
  108. package/dist/src/core/audit.js.map +1 -1
  109. package/dist/src/core/channel-lease.d.ts +322 -0
  110. package/dist/src/core/channel-lease.js +663 -0
  111. package/dist/src/core/channel-lease.js.map +1 -0
  112. package/dist/src/core/command-class.d.ts +51 -0
  113. package/dist/src/core/command-class.js +766 -8
  114. package/dist/src/core/command-class.js.map +1 -1
  115. package/dist/src/core/daemon-host.d.ts +178 -0
  116. package/dist/src/core/daemon-host.js +228 -0
  117. package/dist/src/core/daemon-host.js.map +1 -0
  118. package/dist/src/core/daemon-identity.d.ts +238 -0
  119. package/dist/src/core/daemon-identity.js +252 -0
  120. package/dist/src/core/daemon-identity.js.map +1 -0
  121. package/dist/src/core/gate.d.ts +364 -6
  122. package/dist/src/core/gate.js +802 -27
  123. package/dist/src/core/gate.js.map +1 -1
  124. package/dist/src/core/harness-version.d.ts +128 -4
  125. package/dist/src/core/harness-version.js +179 -12
  126. package/dist/src/core/harness-version.js.map +1 -1
  127. package/dist/src/core/harness-wait.d.ts +82 -3
  128. package/dist/src/core/harness-wait.js +89 -3
  129. package/dist/src/core/harness-wait.js.map +1 -1
  130. package/dist/src/core/log-lock.d.ts +435 -0
  131. package/dist/src/core/log-lock.js +1093 -0
  132. package/dist/src/core/log-lock.js.map +1 -0
  133. package/dist/src/core/log-subscribe.d.ts +21 -0
  134. package/dist/src/core/log-subscribe.js +16 -6
  135. package/dist/src/core/log-subscribe.js.map +1 -1
  136. package/dist/src/core/log-write-layer.d.ts +55 -0
  137. package/dist/src/core/log-write-layer.js +91 -0
  138. package/dist/src/core/log-write-layer.js.map +1 -0
  139. package/dist/src/core/log.d.ts +130 -22
  140. package/dist/src/core/log.js +473 -50
  141. package/dist/src/core/log.js.map +1 -1
  142. package/dist/src/core/payload-census.d.ts +51 -0
  143. package/dist/src/core/payload-census.js +125 -2
  144. package/dist/src/core/payload-census.js.map +1 -1
  145. package/dist/src/core/payload-store.d.ts +31 -0
  146. package/dist/src/core/payload-store.js +113 -8
  147. package/dist/src/core/payload-store.js.map +1 -1
  148. package/dist/src/core/payload.d.ts +18 -3
  149. package/dist/src/core/payload.js +20 -4
  150. package/dist/src/core/payload.js.map +1 -1
  151. package/dist/src/core/policy-diff.d.ts +10 -0
  152. package/dist/src/core/policy-diff.js +10 -2
  153. package/dist/src/core/policy-diff.js.map +1 -1
  154. package/dist/src/core/policy-explain.d.ts +7 -1
  155. package/dist/src/core/policy-explain.js +16 -1
  156. package/dist/src/core/policy-explain.js.map +1 -1
  157. package/dist/src/core/policy-load.d.ts +24 -0
  158. package/dist/src/core/policy-load.js.map +1 -1
  159. package/dist/src/core/policy-match.d.ts +38 -0
  160. package/dist/src/core/policy-match.js +35 -0
  161. package/dist/src/core/policy-match.js.map +1 -1
  162. package/dist/src/core/policy-proposal.d.ts +40 -0
  163. package/dist/src/core/policy-proposal.js +37 -16
  164. package/dist/src/core/policy-proposal.js.map +1 -1
  165. package/dist/src/core/prompt-layout.d.ts +186 -1
  166. package/dist/src/core/prompt-layout.js +345 -3
  167. package/dist/src/core/prompt-layout.js.map +1 -1
  168. package/dist/src/core/read-scope.d.ts +10 -0
  169. package/dist/src/core/read-scope.js +4 -0
  170. package/dist/src/core/read-scope.js.map +1 -1
  171. package/dist/src/core/run-payload.d.ts +160 -0
  172. package/dist/src/core/run-payload.js +304 -0
  173. package/dist/src/core/run-payload.js.map +1 -0
  174. package/dist/src/core/sender-identity.d.ts +59 -11
  175. package/dist/src/core/sender-identity.js +79 -11
  176. package/dist/src/core/sender-identity.js.map +1 -1
  177. package/dist/src/core/state.d.ts +52 -1
  178. package/dist/src/core/state.js +52 -6
  179. package/dist/src/core/state.js.map +1 -1
  180. package/dist/src/core/telegram-config.d.ts +79 -0
  181. package/dist/src/core/telegram-config.js +108 -0
  182. package/dist/src/core/telegram-config.js.map +1 -1
  183. package/dist/src/core/validate.d.ts +20 -6
  184. package/dist/src/core/validate.js +0 -0
  185. package/dist/src/core/validate.js.map +1 -1
  186. package/dist/src/core/verify.d.ts +23 -1
  187. package/dist/src/core/verify.js +20 -2
  188. package/dist/src/core/verify.js.map +1 -1
  189. package/dist/src/daemon/audit.d.ts +80 -5
  190. package/dist/src/daemon/audit.js +126 -6
  191. package/dist/src/daemon/audit.js.map +1 -1
  192. package/dist/src/daemon/daemon.d.ts +174 -2
  193. package/dist/src/daemon/daemon.js +298 -7
  194. package/dist/src/daemon/daemon.js.map +1 -1
  195. package/dist/src/daemon/git-evidence.d.ts +2 -2
  196. package/dist/src/daemon/git-evidence.js +1 -1
  197. package/dist/src/daemon/projection.d.ts +13 -2
  198. package/dist/src/daemon/projection.js +39 -6
  199. package/dist/src/daemon/projection.js.map +1 -1
  200. package/dist/src/mcp/server.d.ts +39 -3
  201. package/dist/src/mcp/server.js +22 -3
  202. package/dist/src/mcp/server.js.map +1 -1
  203. package/dist/src/muse/server.d.ts +16 -0
  204. package/dist/src/muse/server.js +238 -0
  205. package/dist/src/muse/server.js.map +1 -0
  206. package/dist/src/serve/archive.d.ts +166 -0
  207. package/dist/src/serve/archive.js +363 -0
  208. package/dist/src/serve/archive.js.map +1 -0
  209. package/dist/src/serve/arguments.d.ts +93 -0
  210. package/dist/src/serve/arguments.js +228 -0
  211. package/dist/src/serve/arguments.js.map +1 -0
  212. package/dist/src/serve/credentials.d.ts +91 -0
  213. package/dist/src/serve/credentials.js +143 -0
  214. package/dist/src/serve/credentials.js.map +1 -0
  215. package/dist/src/serve/follow.d.ts +86 -0
  216. package/dist/src/serve/follow.js +101 -0
  217. package/dist/src/serve/follow.js.map +1 -0
  218. package/dist/src/serve/hook-thread.d.ts +134 -0
  219. package/dist/src/serve/hook-thread.js +333 -0
  220. package/dist/src/serve/hook-thread.js.map +1 -0
  221. package/dist/src/serve/hook-worker.d.ts +134 -0
  222. package/dist/src/serve/hook-worker.js +173 -0
  223. package/dist/src/serve/hook-worker.js.map +1 -0
  224. package/dist/src/serve/server.d.ts +412 -0
  225. package/dist/src/serve/server.js +1251 -0
  226. package/dist/src/serve/server.js.map +1 -0
  227. package/docs/README-extended.md +768 -0
  228. package/docs/cli-reference.md +2139 -67
  229. package/package.json +4 -2
  230. package/schema/event.schema.json +413 -30
  231. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  232. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  233. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  234. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  235. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  236. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  237. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  238. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  239. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  240. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  241. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  242. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  243. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  244. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  245. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  246. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  247. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  248. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  249. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  250. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  251. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  252. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  253. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  254. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  255. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  256. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  257. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  258. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  259. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  260. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  261. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  262. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  263. package/schema/fixtures/event/valid/daemon-identity.json +16 -0
  264. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  265. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  266. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  267. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  268. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  269. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  270. package/schema/policy.schema.json +102 -6
@@ -70,11 +70,16 @@
70
70
  * the timestamp is read from the injected clock at the write boundary, and the
71
71
  * party being audited does not author the clock it is judged by.
72
72
  */
73
+ import { readFileSync } from "node:fs";
73
74
  import { tick } from "./clock.js";
74
- import { findDeclaration, indexDeclarations } from "./execute.js";
75
+ import { declaringTasks, findDeclaration, indexDeclarations } from "./execute.js";
76
+ import { namesApprover, policyPathOf } from "./gate.js";
77
+ import { attestationRefusal, attestationSha256, checkAttestationOfBytes, isPolicySha256, POLICY_HASH_FIELD, policyBytesHash, unreadablePolicyStatus, } from "./attest.js";
75
78
  import { appendEvent, } from "./log.js";
76
- import { loadPolicy } from "./policy-load.js";
79
+ import { loadPolicy, loadPolicyText, } from "./policy-load.js";
77
80
  import { resolve } from "./policy-match.js";
81
+ import { payloadStoreDirFor } from "./payload-store.js";
82
+ import { storedPolicyText } from "./policy-proposal.js";
78
83
  import { resolveSampler } from "./sampler.js";
79
84
  import { payloadOf, readVerifiedRecords } from "./state.js";
80
85
  /**
@@ -93,12 +98,54 @@ const HUMAN_ACTOR = /^human:.+/u;
93
98
  export const AUDIT_REFUSAL_CODES = [
94
99
  /** Review was attempted by an actor that is not `human:<id>`. */
95
100
  "actor-not-human",
101
+ /**
102
+ * The reviewer is a person the class's `approvers` roster does not name
103
+ * (APRV-483). Same comparison and same code as a grant's (`core/gate.ts`
104
+ * `namesApprover`), because under supervised-retro a review IS the approval,
105
+ * and a roster that bound the grant and not the review would be a roster the
106
+ * retrospective path walks around, including through `--as`. A rule that
107
+ * names no roster restricts nobody, exactly as for grants. Evaluated once the
108
+ * sample (and so its class) is located; nothing is appended.
109
+ */
110
+ "actor-not-approver",
111
+ /**
112
+ * The policy the reviewer's roster would be read from is not the attested
113
+ * one: never attested, edited since, or unreadable (APRV-483 refutation).
114
+ * Same spelling as the gate's, with the attestation module's message. Before
115
+ * this a review read no policy and needed no attestation; once a review is
116
+ * held to a roster, a roster read from an unattested file (or from any file a
117
+ * terminal reviewer names with `--policy`) is a roster the reviewer chose,
118
+ * and a policy that fails to load would restrict nobody. Evaluated once the
119
+ * sample is located; nothing is appended.
120
+ */
121
+ "policy-not-attested",
122
+ /**
123
+ * The attested policy bytes do not load (schema-invalid, a YAML error, no
124
+ * policy block), so no roster can be read from them (PR #614 refutation F1).
125
+ * A gate meeting the same bytes resolves every class `manual`, which
126
+ * compensates; a review has nothing that compensates, because the action
127
+ * already ran and this record is its approval. So a policy that names nobody
128
+ * because it failed to load is refused here rather than read as "no roster".
129
+ * Distinct from `policy-not-attested`: the bytes ARE the attested ones, and
130
+ * the repair is a corrected policy and a new attestation, not re-attesting
131
+ * what is on disk. Evaluated once the sample is located; nothing is appended.
132
+ */
133
+ "policy-invalid",
96
134
  /** No `audit.sampled` record matches the subject named. */
97
135
  "not-sampled",
98
136
  /** That sample already has a later `audit.reviewed`. */
99
137
  "already-reviewed",
100
138
  /** An action key with more than one unreviewed sample; name the seq instead. */
101
139
  "ambiguous-subject",
140
+ /**
141
+ * The reviewing surface said it rendered the payload whole and named a hash
142
+ * that is not the sampled execution's recorded binding (APRV-481). Nothing is
143
+ * appended: a review's `payload_hash` says which bytes the reviewer read, and
144
+ * one naming bytes the execution did not bind to would claim a reading of
145
+ * something that never ran. Evaluated after the sample is located, before
146
+ * anything is written.
147
+ */
148
+ "rendered-payload-mismatch",
102
149
  /** No `reconciliation.required` record at the seq named (APRV-127). */
103
150
  "not-obliged",
104
151
  /** That obligation already has a `reconciliation.satisfied` (APRV-127). */
@@ -123,6 +170,17 @@ export const AUDIT_REFUSAL_CODES = [
123
170
  * operator is pleased. The reviewer is asked to say which they meant.
124
171
  */
125
172
  "reaction-conflicts-verdict",
173
+ /**
174
+ * A review that names no verdict (APRV-482). Under supervised-retro a review
175
+ * counts as the individual approval nobody gave before the action ran, and an
176
+ * approval is an affirmative act: a reaction tap on a card, or a bare
177
+ * `approval audit review <seq>`, used to write verdict `ok` on the reviewer's
178
+ * behalf. Now the verdict is said or nothing is written. Evaluated once the
179
+ * sample is located and the reviewer has passed the roster check (PR #614
180
+ * refutation N6: who may review is said before what the review lacks);
181
+ * nothing is appended.
182
+ */
183
+ "verdict-required",
126
184
  /**
127
185
  * A `gated-revert` obligation whose satisfaction names no completed revert
128
186
  * (APRV-127). The obligation is to undo the action THROUGH THE GATE, and the
@@ -250,6 +308,7 @@ export function sampledSubjects(records) {
250
308
  task: stringOrNull(record.task) ?? stringOrNull(payload["task"]),
251
309
  subjectHash: stringOrNull(payload["subject_hash"]),
252
310
  subjectSeq: typeof subjectSeq === "number" && Number.isInteger(subjectSeq) ? subjectSeq : null,
311
+ policySha256: isPolicySha256(payload[POLICY_HASH_FIELD]) ? payload[POLICY_HASH_FIELD] : null,
253
312
  reviewedSeq: null,
254
313
  });
255
314
  }
@@ -275,6 +334,33 @@ export function sampledSubjects(records) {
275
334
  export function openSamples(records) {
276
335
  return sampledSubjects(records).filter((subject) => subject.reviewedSeq === null);
277
336
  }
337
+ /**
338
+ * The payload hash the sampled execution bound to, or `null` when the log
339
+ * records none (APRV-480).
340
+ *
341
+ * Read off the `execution.started` the sample names, whose `payload_hash` is the
342
+ * executor's recomputation of the bytes it ran; failing that, off the
343
+ * registration's declared binding. Both are records the log already holds, so
344
+ * nothing here takes a payload hash from the party under review, and the review
345
+ * card (`cli/audit-card.ts`) and the review record (APRV-481) derive the same
346
+ * answer from the same records.
347
+ */
348
+ export function boundPayloadHash(records, subject) {
349
+ const start = subject.subjectSeq === null
350
+ ? undefined
351
+ : records.find((record) => record.seq === subject.subjectSeq && record.event === "execution.started");
352
+ const started = start === undefined ? null : stringOrNull(payloadOf(start)["payload_hash"]);
353
+ if (started !== null)
354
+ return started;
355
+ if (subject.actionKey === null)
356
+ return null;
357
+ // PR #614 refutation N7: findDeclaration's contract. A key two tasks declare
358
+ // is a collision registration refuses, so a log holding one cannot say which
359
+ // binding governs, and no binding is the fail-closed answer.
360
+ if (declaringTasks(records, subject.actionKey).length > 1)
361
+ return null;
362
+ return findDeclaration(records, subject.actionKey)?.payload_hash ?? null;
363
+ }
278
364
  /** The candidates a sweep would sample now: eligible, selected, not yet sampled. */
279
365
  export function pendingSamples(records, load, sampler) {
280
366
  if (!sampler.enabled)
@@ -291,6 +377,32 @@ export function pendingSamples(records, load, sampler) {
291
377
  return supervisedExecutions(records, load).filter((candidate) => !alreadySampled.has(candidate.hash) &&
292
378
  sampler.selectsFor(candidate.class, candidate.hash));
293
379
  }
380
+ /**
381
+ * The append errors a sampling sweep classifies as TRANSIENT (APRV-381).
382
+ *
383
+ * Both mean the same two things: the log was not written, and nothing about the
384
+ * candidate changed. `lock-timeout` is another writer holding the lockfile for
385
+ * longer than the wait; `head-moved` is another writer landing a record between
386
+ * this sweep's read and its append. Neither says anything about whether the
387
+ * sample should be taken, so the next sweep, which re-derives the whole question
388
+ * from the verified log, will ask again and get the same answer.
389
+ *
390
+ * Every other append error is a fact about the record or the file: `validation`
391
+ * and `canonicalization` say this event cannot be written as it stands,
392
+ * `corrupt-tail` says nothing may chain onto this log, `io` says the file could
393
+ * not be written. Retrying those on a cadence would print the same failure every
394
+ * tick and repair none of it, so they keep the refusal form an operator is meant
395
+ * to act on.
396
+ *
397
+ * A closed list rather than a "not fatal" default: a new append error code added
398
+ * later is non-transient until someone decides otherwise, which is the fail-closed
399
+ * reading (SPEC.md §11.1).
400
+ */
401
+ export const TRANSIENT_APPEND_CODES = ["lock-timeout", "head-moved"];
402
+ /** Whether `error` is one the sweep retries on its next pass rather than reports. */
403
+ export function isTransientAppendError(error) {
404
+ return TRANSIENT_APPEND_CODES.includes(error.code);
405
+ }
294
406
  /**
295
407
  * Sample every supervised execution the log does not yet carry an
296
408
  * `audit.sampled` for, and append one event per selection.
@@ -299,7 +411,11 @@ export function pendingSamples(records, load, sampler) {
299
411
  * compare-and-append is made against is the head the decision was made from. A
300
412
  * `head-moved` refusal is collected and reported rather than retried: only the
301
413
  * next sweep, which re-derives the whole question from the log as it now is,
302
- * knows whether the candidate is still a candidate.
414
+ * knows whether the candidate is still a candidate. Since APRV-381 it is
415
+ * collected on `deferred` rather than on `refusals`, beside `lock-timeout`: the
416
+ * two are the same event to this function (nothing written, nothing decided) and
417
+ * the split is what lets a caller say "later" in one case and "look at this" in
418
+ * the other.
303
419
  *
304
420
  * Returns `ok` with an empty `appended` list when sampling is disabled; the
305
421
  * reason travels on `sampler`. A disabled sampler is not a refusal, because
@@ -310,9 +426,10 @@ export function sampleSupervised(logPath, cwd, options = {}) {
310
426
  const load = policyFor(options, cwd);
311
427
  const sampler = resolveSampler(load, options.env ?? process.env);
312
428
  if (!sampler.enabled)
313
- return { ok: true, sampler, appended: [], refusals: [] };
429
+ return { ok: true, sampler, appended: [], refusals: [], deferred: [] };
314
430
  const appended = [];
315
431
  const refusals = [];
432
+ const deferred = [];
316
433
  const validate = options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir };
317
434
  for (;;) {
318
435
  const read = readVerifiedRecords(logPath, validate);
@@ -321,22 +438,94 @@ export function sampleSupervised(logPath, cwd, options = {}) {
321
438
  // in the log (each append is its own record) and nothing is rolled back.
322
439
  return appended.length === 0
323
440
  ? refuse(read.code, read.message)
324
- : { ok: true, sampler, appended, refusals: [...refusals, refuse(read.code, read.message)] };
441
+ : {
442
+ ok: true,
443
+ sampler,
444
+ appended,
445
+ refusals: [...refusals, refuse(read.code, read.message)],
446
+ deferred,
447
+ };
325
448
  }
326
449
  const pending = pendingSamples(read.records, load, sampler);
327
450
  const next = pending.find((candidate) => !appended.some((done) => done.candidate.hash === candidate.hash));
328
451
  if (next === undefined)
329
452
  break;
330
- const result = appendSample(logPath, next, sampler, read.head, options);
453
+ // PR #614 refutation F2, recheck NF-3. The sample pins the attested policy
454
+ // the action RAN under (see `executionPolicySha256`), so the review is held
455
+ // to that policy's roster and not to one attested between the execution and
456
+ // this sweep. Only an execution older than every attestation in the log
457
+ // falls back to the latest attestation, the reading before NF-3. A log with
458
+ // no attestation cannot have run a supervised action (the executor refuses
459
+ // `policy-not-attested` on that path), so meeting one here is a fact about
460
+ // the log an operator must look at, never a sample written unpinned.
461
+ const policySha256 = executionPolicySha256(read.records, next) ?? latestAttestedSha256(read.records);
462
+ if (policySha256 === null) {
463
+ refusals.push(refuse("policy-not-attested", `audit.sampled for ${next.actionKey} was not appended: the log holds no policy attestation, so the sample could not name the policy it was taken under, and its review would have no roster to be held to. A human must run \`approval policy attest\`.`));
464
+ break;
465
+ }
466
+ const result = appendSample(logPath, next, sampler, read.head, policySha256, options);
331
467
  if (!result.ok) {
332
- refusals.push(result);
468
+ // One failure ends the sweep either way, transient or not. A held lock or a
469
+ // moved head means another writer is mid-transaction, and walking the rest
470
+ // of the candidates into the same contention would spend the tick losing
471
+ // the same race repeatedly; a real refusal is a fact about the file or the
472
+ // record that the next candidate would meet too.
473
+ if (result.append !== undefined && isTransientAppendError(result.append)) {
474
+ deferred.push({ candidate: next, append: result.append, refusal: result });
475
+ }
476
+ else {
477
+ refusals.push(result);
478
+ }
333
479
  break;
334
480
  }
335
481
  appended.push({ record: result.record, candidate: next });
336
482
  }
337
- return { ok: true, sampler, appended, refusals };
483
+ return { ok: true, sampler, appended, refusals, deferred };
484
+ }
485
+ /**
486
+ * The `sha256` the latest policy attestation in `records` names, or `null`
487
+ * when the log holds none (PR #614 refutation F2). The same record
488
+ * `checkAttestationOfBytes` compares against: the latest attestation is the
489
+ * policy in force.
490
+ */
491
+ function latestAttestedSha256(records) {
492
+ for (let index = records.length - 1; index >= 0; index -= 1) {
493
+ const sha = attestationSha256(records[index]);
494
+ if (sha !== null)
495
+ return sha;
496
+ }
497
+ return null;
498
+ }
499
+ /**
500
+ * The attested policy the sampled execution ran under, or `null` when the log
501
+ * cannot say (PR #614 recheck NF-3).
502
+ *
503
+ * The latest attestation BEFORE the execution's seq. The log is append-only,
504
+ * so the attestations before a record's seq are exactly the ones that existed
505
+ * when it was written, and the latest of them is the policy in force at that
506
+ * append. That is the value the gate stamps on a harness `execution.started`
507
+ * (APRV-447: the attestation its write boundary checked, under the same
508
+ * compare-and-append), so the two agree by construction; reading it from the
509
+ * log rather than from the stamp also covers the starts no stamp is written on
510
+ * (`approval run`'s path, and every start older than APRV-447). `null` only
511
+ * for an execution older than every attestation in the log; the caller then
512
+ * pins the latest one, the reading samples had before NF-3.
513
+ *
514
+ * Read from verified records only, at the moment the sample is drawn, and
515
+ * never from a caller: the pin is a runtime-written fact about the log.
516
+ */
517
+ function executionPolicySha256(records, candidate) {
518
+ let before = null;
519
+ for (const record of records) {
520
+ if (record.seq >= candidate.seq)
521
+ break;
522
+ const sha = attestationSha256(record);
523
+ if (sha !== null)
524
+ before = sha;
525
+ }
526
+ return before;
338
527
  }
339
- function appendSample(logPath, candidate, sampler, head, options) {
528
+ function appendSample(logPath, candidate, sampler, head, policySha256, options) {
340
529
  const payload = {
341
530
  // What was sampled, named by the chain's own identifiers so a reviewer (and
342
531
  // a reproducing operator) can find the subject without a projection.
@@ -358,6 +547,10 @@ function appendSample(logPath, candidate, sampler, head, options) {
358
547
  // was in force.
359
548
  rate: sampler.rateFor(candidate.class).rate ?? sampler.rate,
360
549
  autonomy: "supervised",
550
+ // PR #614 refutation F2, recheck NF-3: the attested policy the sampled
551
+ // execution ran under. Its review reads the class's roster from these
552
+ // bytes only.
553
+ [POLICY_HASH_FIELD]: policySha256,
361
554
  };
362
555
  const result = appendEvent(logPath, {
363
556
  ts: tick(options),
@@ -460,27 +653,37 @@ export function obligationFor(reversible) {
460
653
  * would be the party under oversight closing its own audit item, and a backlog
461
654
  * that can be emptied by the thing it supervises measures nothing.
462
655
  *
463
- * No attestation is required, for the reason `execution resolve` states: review
464
- * records an observation and exercises no policy authority. It authorizes
465
- * nothing, spends no budget, and mints no token.
656
+ * It needs the ATTESTED policy since APRV-483 (it needed none before): under
657
+ * supervised-retro a review is the approval, so the reviewer is held to the
658
+ * class's `approvers` roster, and that roster is read only from bytes a human
659
+ * attested ({@link reviewerRoster}). It spends no budget and mints no token.
466
660
  *
467
661
  * `--note` is optional and recorded verbatim when present. It is not mandatory
468
662
  * the way `execution resolve`'s is, because that verb writes an *outcome* the
469
663
  * runtime does not know while this one writes only "seen".
470
664
  */
471
665
  export function reviewSample(logPath, ref, actor, note, options = {}) {
472
- const verdict = options.verdict ?? "ok";
473
666
  if (!HUMAN_ACTOR.test(actor)) {
474
667
  return refuse("actor-not-human", `audit review is human-only: the event's entire content is that a person looked at a sampled action, and a runtime that could mark its own samples reviewed would be a supervision backlog that empties itself. The actor must match human:<id>, got ${JSON.stringify(actor)}.`);
475
668
  }
669
+ // APRV-482. The verdict is an affirmative act or it is nothing. Read here and
670
+ // REFUSED after the roster check (PR #614 refutation N6): who may review comes
671
+ // before what the review lacks, so a reviewer off the roster who taps a grade
672
+ // is told `actor-not-approver` at once rather than `verdict-required` first
673
+ // and the roster only on their next tap.
674
+ const verdict = options.verdict;
675
+ const hasVerdict = verdict === "ok" || verdict === "denied";
476
676
  // APRV-239, and deliberately here: after the actor check and BEFORE the log is
477
677
  // read. Both rules are properties of the two arguments in front of this
478
678
  // function, so neither needs a log to decide, and a refusal that had already
479
679
  // read (and verified) a log would report a log failure for an invocation that
480
680
  // was malformed before it ever touched one. Nothing is appended on either
481
- // path; the reviewer fixes the invocation and reviews again.
681
+ // path; the reviewer fixes the invocation and reviews again. Both judge a
682
+ // grade beside a verdict, so a review with no verdict skips them and meets
683
+ // `verdict-required` below: a card's lone `loved` is told it lacks a verdict,
684
+ // not a note it will be asked for once the verdict comes.
482
685
  const reaction = options.reaction;
483
- if (reaction !== undefined) {
686
+ if (hasVerdict && reaction !== undefined) {
484
687
  if (verdict === "denied" && (reaction === "liked" || reaction === "loved")) {
485
688
  return refuse("reaction-conflicts-verdict", `a denied review cannot also be ${reaction}: \`verdict\` says the action should not have happened and \`reaction\` says the human was pleased by it, and only the first of those is enforcement. A record carrying both reads afterwards as evidence of whichever half suits the reader, and reads to an agent as a denial being survivable when the operator is pleased. Nothing was appended. Say which one you meant: drop --deny, or use --reaction disliked or indifferent and put the nuance in --note.`);
486
689
  }
@@ -496,14 +699,54 @@ export function reviewSample(logPath, ref, actor, note, options = {}) {
496
699
  if (!located.ok)
497
700
  return located;
498
701
  const subject = located.subject;
702
+ // APRV-481. A review names the execution it judged, by the hash the sample
703
+ // recorded for it. Every sample the runtime writes carries one; a sample that
704
+ // does not names nothing a review could be about, and the write boundary
705
+ // requires the field, so this says so in the audit vocabulary rather than as
706
+ // a schema failure.
707
+ const sampledSubjectHash = subject.subjectHash;
708
+ if (sampledSubjectHash === null) {
709
+ return refuse("not-sampled", `the sample at seq ${String(subject.seq)} names no subject hash, so a review of it could not say which execution it judged. Nothing was appended.`, { seq: subject.seq });
710
+ }
711
+ // APRV-483. The roster check a grant makes, made of the reviewer. The class
712
+ // and reversibility come from the registration (never from the review or the
713
+ // execution's own payload, global invariant 4), failing that from the
714
+ // runtime-written sample; the rule is the single winner `resolve` picks, as
715
+ // for a grant; and a rule with no `approvers` restricts nobody. The roster is
716
+ // read from the ATTESTED policy bytes, as a grant's is.
717
+ const rosterRefusal = reviewerRoster(logPath, read.records, subject, actor, options);
718
+ if (rosterRefusal !== null)
719
+ return rosterRefusal;
720
+ if (!hasVerdict) {
721
+ return refuse("verdict-required", `a review must say its verdict: under supervised-retro it counts as the approval nobody gave before the action ran, so a grade alone, or a review that names nothing, records nothing. At a terminal pass --ok or --deny; on a review card tap OK, or Deny twice. Nothing was appended.`, { seq: subject.seq });
722
+ }
723
+ // APRV-481. The surface's word that it showed the bytes is checked against
724
+ // the binding the log holds, never copied on trust (SPEC.md §11.1 invariant 4
725
+ // in spirit: a field that claims more scrutiny than happened is the same
726
+ // defect as one that lowers it).
727
+ const rendered = options.renderedPayloadHash;
728
+ if (rendered !== undefined) {
729
+ const bound = boundPayloadHash(read.records, subject);
730
+ if (bound === null || bound !== rendered) {
731
+ return refuse("rendered-payload-mismatch", `the reviewing surface says it showed the payload with sha256 ${rendered}, and the execution the sample at seq ${String(subject.seq)} names is bound to ${bound === null ? "no payload hash at all" : bound}. A review's payload_hash says which bytes the reviewer read; one naming bytes that never ran would claim a reading of something else. Nothing was appended.`, { seq: subject.seq });
732
+ }
733
+ }
499
734
  const payload = {
500
735
  subject_seq: subject.seq,
501
736
  subject_event: "audit.sampled",
502
737
  reviewed: true,
503
738
  verdict,
739
+ // PR #614 refutation F5: the discriminator a follower reads. Since APRV-482
740
+ // every verdict is the reviewer's own word; a review written before then
741
+ // may carry an `ok` the runtime filled in, and only this field (absent
742
+ // there) tells the two apart.
743
+ verdict_source: "explicit",
744
+ sampled_subject_hash: sampledSubjectHash,
504
745
  };
505
- if (subject.subjectHash !== null)
506
- payload["sampled_subject_hash"] = subject.subjectHash;
746
+ // Only when the bytes were on the screen (APRV-481): absent says the
747
+ // reviewer saw a hash, nothing, or a terminal.
748
+ if (rendered !== undefined)
749
+ payload["payload_hash"] = rendered;
507
750
  if (note !== null && note.trim().length > 0)
508
751
  payload["note"] = note;
509
752
  // Written only when it was given. An omitted reaction leaves no key, which is
@@ -555,6 +798,158 @@ export function reviewSample(logPath, ref, actor, note, options = {}) {
555
798
  return obliged;
556
799
  return { ok: true, record: result.record, subject, obligation: obliged.record };
557
800
  }
801
+ /**
802
+ * `actor-not-approver` when the class's roster does not name `actor`,
803
+ * `policy-not-attested` when the roster cannot be read from the attested
804
+ * policy, else `null` (APRV-483).
805
+ *
806
+ * Fail-closed in every input it does not control (APRV-483 refutation):
807
+ *
808
+ * - the policy is read ONCE from the file a grant would read
809
+ * (`core/gate.ts` `policyPathOf`), its bytes must be the policy the sample
810
+ * pinned, the one its execution ran under (PR #614 refutation F2, recheck
811
+ * NF-3; for an unpinned pre-fix sample, the latest attestation's), and the
812
+ * roster is parsed from those same bytes, so neither
813
+ * an edited file, an unreadable one, a later re-attestation, nor a
814
+ * `--policy` pointed elsewhere can supply the roster;
815
+ * - a class that matches no rule in that policy is refused
816
+ * `actor-not-approver` (F2): the defaults carry no roster, and for a review
817
+ * "no roster" would mean "anyone";
818
+ * - a sample whose class cannot be named (no registration and no class on the
819
+ * runtime-written sample) is refused rather than resolved as "no rule, no
820
+ * roster", because that reading would let anyone review it;
821
+ * - attested bytes the loader refuses are refused `policy-invalid` (PR #614
822
+ * refutation F1): their fail-closed resolution names no roster, and for a
823
+ * review "no roster" would mean "anyone".
824
+ *
825
+ * What still restricts nobody, by design and exactly as for a grant: an
826
+ * attested rule that names no `approvers`.
827
+ */
828
+ function reviewerRoster(logPath, records, subject, actor, options) {
829
+ const path = policyPathOf(options.policy === undefined ? {} : { policy: options.policy });
830
+ // PR #614 refutation F2 (ruling), recheck NF-3: the roster is the one in
831
+ // force when the action RAN, which the sample pins. A sample that pinned its
832
+ // policy is reviewed only against bytes hashing to that pin, so a later
833
+ // re-attestation that renames, re-rosters or drops the class's rule neither
834
+ // opens the review to a new reviewer nor leaves it with no roster; a sample
835
+ // written before samples pinned one keeps the latest-attestation reading.
836
+ // Fix round 3: the pinned bytes come from the payload store first, so a
837
+ // re-attestation does not strand the older open samples.
838
+ let bytes;
839
+ if (subject.policySha256 === null) {
840
+ try {
841
+ bytes = readFileSync(path);
842
+ }
843
+ catch (cause) {
844
+ return attestationRefused(unreadablePolicyStatus(path, cause instanceof Error ? cause.message : String(cause)), subject);
845
+ }
846
+ const attested = attestationRefused(checkAttestationOfBytes(records, bytes), subject);
847
+ if (attested !== null)
848
+ return attested;
849
+ }
850
+ else {
851
+ const pinned = pinnedPolicyBytes(logPath, records, subject, subject.policySha256, path);
852
+ if (!pinned.ok)
853
+ return pinned;
854
+ bytes = pinned.bytes;
855
+ }
856
+ // PR #614 refutation N7: findDeclaration's contract on an enforcement path.
857
+ // A key two tasks declare is a collision registration refuses (APRV-138), so
858
+ // a log holding one cannot say which class, and so which roster, governs.
859
+ if (subject.actionKey !== null &&
860
+ declaringTasks(records, subject.actionKey).length > 1) {
861
+ return refuse("actor-not-approver", `action ${subject.actionKey} is declared by more than one task, a collision registration refuses, so the log cannot say which class (and so which approvers roster) governs the sample at seq ${String(subject.seq)}. Nothing was appended.`, { seq: subject.seq });
862
+ }
863
+ const declared = subject.actionKey === null ? null : findDeclaration(records, subject.actionKey);
864
+ const sampleRecord = records.find((record) => record.seq === subject.seq);
865
+ const sampledClass = sampleRecord === undefined ? null : stringOrNull(payloadOf(sampleRecord)["class"]);
866
+ const cls = stringOrNull(declared?.class) ?? sampledClass;
867
+ if (cls === null) {
868
+ return refuse("actor-not-approver", `the sample at seq ${String(subject.seq)} names no class, so no approvers roster can be checked for it and no reviewer can be shown to be on one. Nothing was appended.`, { seq: subject.seq });
869
+ }
870
+ const load = loadPolicyText(path, Buffer.from(bytes).toString("utf8"), options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
871
+ // PR #614 refutation F1. Attested is not the same as valid: `policy attest`
872
+ // hashes bytes and never parses them, so a policy the loader refuses can be
873
+ // attested. The fail-closed resolution such bytes produce carries
874
+ // `approvers: null`, which reads as "no restriction"; for a review that is
875
+ // the opposite of failing closed. Both checks, because the provenance is the
876
+ // resolution's own word for the same fact and a second spelling of it should
877
+ // not be able to slip past the first.
878
+ if (!load.ok)
879
+ return policyInvalid(path, `${load.code}: ${load.message}`, subject);
880
+ const resolution = resolve(load, cls, declared?.reversible === null || declared?.reversible === undefined
881
+ ? {}
882
+ : { reversible: declared.reversible });
883
+ if (resolution.provenance === "fail-closed") {
884
+ return policyInvalid(path, "the policy resolved on its fail-closed path", subject);
885
+ }
886
+ // PR #614 refutation F2. A class no rule matches resolves to the defaults,
887
+ // which carry no roster; for a grant that is "restricts nobody", and for a
888
+ // review it would be "anyone may approve". Refused instead, with the reason.
889
+ if (resolution.matched === null) {
890
+ return refuse("actor-not-approver", `class ${cls} matches no rule in the policy ${subject.policySha256 === null ? "in force" : `the action sampled at seq ${String(subject.seq)} ran under (sha256 ${subject.policySha256})`}, so that policy names no approvers roster for it and no reviewer can be shown to be on one. Under supervised-retro a review is the approval, and a class with no rule would otherwise be approvable by anyone. Nothing was appended.`, { seq: subject.seq });
891
+ }
892
+ const approvers = resolution.approvers;
893
+ if (approvers === null || namesApprover(approvers, actor))
894
+ return null;
895
+ return refuse("actor-not-approver", `${actor} is not named in the approvers list for class ${cls}: the rule ${resolution.matched === null ? "in force" : `\`${resolution.matched.pattern}\``} names ${approvers.length === 0 ? "nobody" : approvers.map((name) => `\`${name}\``).join(", ")}. Under supervised-retro a review is the approval, so it is held to the roster a grant is. Ask a named approver to review it, or amend the policy and re-attest. Nothing was appended.`, { seq: subject.seq });
896
+ }
897
+ /**
898
+ * The bytes of the policy the sample pinned, or `policy-not-attested` (PR #614
899
+ * refutation F2, recheck NF-3, fix round 3).
900
+ *
901
+ * Authority first: a human must have attested the pinned hash before the
902
+ * sample was taken. The sampler copies the hash from the log, so a pin no
903
+ * earlier attestation names is a record nobody attested, refused like
904
+ * unattested bytes.
905
+ *
906
+ * Then the bytes, from two places, each verified against the pin and neither
907
+ * trusted otherwise:
908
+ *
909
+ * 1. the APRV-356 payload store beside the log, where every attestation since
910
+ * APRV-356 (and every phone proposal) leaves the attested text, read by
911
+ * `storedPolicyText`, which hashes what it reads. This is what keeps a
912
+ * re-attestation, a settings save included, from stranding the samples
913
+ * pinned to the earlier policy;
914
+ * 2. the policy file on disk, only when its bytes hash to the pin (a chain last
915
+ * attested before APRV-356 stored nothing).
916
+ *
917
+ * Bytes in neither place are refused, naming the hash needed. The pin is never
918
+ * compared with the latest attestation: the question is which roster governed
919
+ * the action when it ran, whether or not a later policy has been attested.
920
+ */
921
+ function pinnedPolicyBytes(logPath, records, subject, pinned, path) {
922
+ const attestedBefore = records.some((record) => record.seq < subject.seq && attestationSha256(record) === pinned);
923
+ if (!attestedBefore) {
924
+ return refuse("policy-not-attested", `the sample at seq ${String(subject.seq)} pins policy sha256 ${pinned}, and no attestation before it names that hash, so the roster it pins is one nobody attested. Nothing was appended.`, { seq: subject.seq });
925
+ }
926
+ const stored = storedPolicyText(records, payloadStoreDirFor(logPath), pinned);
927
+ if (stored !== null)
928
+ return { ok: true, bytes: Buffer.from(stored, "utf8") };
929
+ let onDisk;
930
+ try {
931
+ const bytes = readFileSync(path);
932
+ const live = policyBytesHash(bytes);
933
+ if (live === pinned)
934
+ return { ok: true, bytes };
935
+ onDisk = `bytes hashing to ${live}`;
936
+ }
937
+ catch (cause) {
938
+ onDisk = `nothing readable (${cause instanceof Error ? cause.message : String(cause)})`;
939
+ }
940
+ return refuse("policy-not-attested", `the action sampled at seq ${String(subject.seq)} ran under the policy attested as sha256 ${pinned}. Its bytes are not in the payload store beside the log (an attestation made before APRV-356 stored none), and ${path} holds ${onDisk}. A review is held to the approvers roster in force when the action ran, so it needs the policy bytes hashing to ${pinned}: restore them to ${path}. Nothing was appended.`, { seq: subject.seq });
941
+ }
942
+ /** `policy-invalid`: attested bytes the loader refuses name no roster (F1). */
943
+ function policyInvalid(path, cause, subject) {
944
+ return refuse("policy-invalid", `the attested policy ${path} does not load (${cause}), so no approvers roster can be read from it for the sample at seq ${String(subject.seq)}. A gate meeting these bytes would hold every class for a human; a review has nothing that compensates, because the action already ran and the review is its approval. Correct the policy and attest the corrected bytes, then review again. Nothing was appended.`, { seq: subject.seq });
945
+ }
946
+ /** `policy-not-attested` for a non-attested status, else `null`. */
947
+ function attestationRefused(status, subject) {
948
+ const refusal = attestationRefusal(status);
949
+ if (refusal === null)
950
+ return null;
951
+ return refuse("policy-not-attested", `${refusal.message}. A review is held to the class's approvers roster, and a roster read from a policy nobody attested is one the reviewer could have chosen. Nothing was appended.`, { seq: subject.seq });
952
+ }
558
953
  /**
559
954
  * Append the `reconciliation.required` that a denial creates.
560
955
  *
@@ -693,9 +1088,9 @@ export function openObligations(records) {
693
1088
  * ceremony with its own `policy.updated` record — so the note is the discharge
694
1089
  * there, and the note is required.
695
1090
  *
696
- * No attestation is required, for the reason `audit review` and `execution
697
- * resolve` state: this record exercises no policy authority, authorizes nothing,
698
- * spends no budget, and mints no token.
1091
+ * No attestation is required, for the reason `execution resolve` states: this
1092
+ * record exercises no policy authority, authorizes nothing, spends no budget,
1093
+ * and mints no token.
699
1094
  */
700
1095
  export function satisfyObligation(logPath, obligationSeq, actor, input, options = {}) {
701
1096
  if (!HUMAN_ACTOR.test(actor)) {