approval-md 0.4.0 → 0.4.2

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 (190) hide show
  1. package/SPEC.md +11 -0
  2. package/cli.js +72 -4
  3. package/dist/src/channels/contract.d.ts +53 -0
  4. package/dist/src/channels/contract.js +56 -0
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/tagging.d.ts +25 -0
  7. package/dist/src/channels/tagging.js +57 -5
  8. package/dist/src/channels/tagging.js.map +1 -1
  9. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  10. package/dist/src/channels/telegram-minimal.js +618 -0
  11. package/dist/src/channels/telegram-minimal.js.map +1 -0
  12. package/dist/src/channels/telegram.d.ts +454 -19
  13. package/dist/src/channels/telegram.js +931 -80
  14. package/dist/src/channels/telegram.js.map +1 -1
  15. package/dist/src/cli/amend.js +8 -1
  16. package/dist/src/cli/amend.js.map +1 -1
  17. package/dist/src/cli/attest.js +2 -1
  18. package/dist/src/cli/attest.js.map +1 -1
  19. package/dist/src/cli/audit-card.d.ts +1 -1
  20. package/dist/src/cli/audit-card.js +40 -1
  21. package/dist/src/cli/audit-card.js.map +1 -1
  22. package/dist/src/cli/audit.d.ts +7 -5
  23. package/dist/src/cli/audit.js +25 -12
  24. package/dist/src/cli/audit.js.map +1 -1
  25. package/dist/src/cli/channel-telegram-webhook.d.ts +2 -0
  26. package/dist/src/cli/channel-telegram-webhook.js +7 -0
  27. package/dist/src/cli/channel-telegram-webhook.js.map +1 -1
  28. package/dist/src/cli/channel-telegram.d.ts +75 -2
  29. package/dist/src/cli/channel-telegram.js +172 -16
  30. package/dist/src/cli/channel-telegram.js.map +1 -1
  31. package/dist/src/cli/channel-web.js +2 -1
  32. package/dist/src/cli/channel-web.js.map +1 -1
  33. package/dist/src/cli/channel.js +2 -1
  34. package/dist/src/cli/channel.js.map +1 -1
  35. package/dist/src/cli/doctor.js +1 -1
  36. package/dist/src/cli/doctor.js.map +1 -1
  37. package/dist/src/cli/execute.d.ts +12 -0
  38. package/dist/src/cli/execute.js +50 -4
  39. package/dist/src/cli/execute.js.map +1 -1
  40. package/dist/src/cli/gate.js +2 -1
  41. package/dist/src/cli/gate.js.map +1 -1
  42. package/dist/src/cli/help.d.ts +1 -0
  43. package/dist/src/cli/help.js +39 -18
  44. package/dist/src/cli/help.js.map +1 -1
  45. package/dist/src/cli/hook.d.ts +121 -3
  46. package/dist/src/cli/hook.js +719 -79
  47. package/dist/src/cli/hook.js.map +1 -1
  48. package/dist/src/cli/log-checkpoint.js +2 -1
  49. package/dist/src/cli/log-checkpoint.js.map +1 -1
  50. package/dist/src/cli/log-unlock.d.ts +23 -0
  51. package/dist/src/cli/log-unlock.js +110 -0
  52. package/dist/src/cli/log-unlock.js.map +1 -0
  53. package/dist/src/cli/main.js +23 -6
  54. package/dist/src/cli/main.js.map +1 -1
  55. package/dist/src/cli/setup-common.js +2 -1
  56. package/dist/src/cli/setup-common.js.map +1 -1
  57. package/dist/src/cli/up.js +5 -0
  58. package/dist/src/cli/up.js.map +1 -1
  59. package/dist/src/cli/vault.js +2 -1
  60. package/dist/src/cli/vault.js.map +1 -1
  61. package/dist/src/cli/verb-registry.js +1 -0
  62. package/dist/src/cli/verb-registry.js.map +1 -1
  63. package/dist/src/cli/wordmark.d.ts +2 -2
  64. package/dist/src/cli/wordmark.js +2 -2
  65. package/dist/src/core/attest-bootstrap.js +2 -1
  66. package/dist/src/core/attest-bootstrap.js.map +1 -1
  67. package/dist/src/core/attest.js +5 -4
  68. package/dist/src/core/attest.js.map +1 -1
  69. package/dist/src/core/audit.d.ts +113 -9
  70. package/dist/src/core/audit.js +382 -18
  71. package/dist/src/core/audit.js.map +1 -1
  72. package/dist/src/core/budgets.d.ts +12 -0
  73. package/dist/src/core/budgets.js +54 -1
  74. package/dist/src/core/budgets.js.map +1 -1
  75. package/dist/src/core/checkpoint.js +3 -2
  76. package/dist/src/core/checkpoint.js.map +1 -1
  77. package/dist/src/core/command-class.js +6 -0
  78. package/dist/src/core/command-class.js.map +1 -1
  79. package/dist/src/core/decision-refusal.js +2 -1
  80. package/dist/src/core/decision-refusal.js.map +1 -1
  81. package/dist/src/core/delegation.d.ts +109 -0
  82. package/dist/src/core/delegation.js +244 -0
  83. package/dist/src/core/delegation.js.map +1 -0
  84. package/dist/src/core/execute.js +12 -3
  85. package/dist/src/core/execute.js.map +1 -1
  86. package/dist/src/core/gate-window.js +4 -3
  87. package/dist/src/core/gate-window.js.map +1 -1
  88. package/dist/src/core/gate.d.ts +84 -43
  89. package/dist/src/core/gate.js +114 -6
  90. package/dist/src/core/gate.js.map +1 -1
  91. package/dist/src/core/gesture-refusal.js +2 -1
  92. package/dist/src/core/gesture-refusal.js.map +1 -1
  93. package/dist/src/core/identity.d.ts +105 -0
  94. package/dist/src/core/identity.js +122 -0
  95. package/dist/src/core/identity.js.map +1 -0
  96. package/dist/src/core/log-lock.d.ts +435 -0
  97. package/dist/src/core/log-lock.js +1093 -0
  98. package/dist/src/core/log-lock.js.map +1 -0
  99. package/dist/src/core/log.d.ts +82 -3
  100. package/dist/src/core/log.js +285 -40
  101. package/dist/src/core/log.js.map +1 -1
  102. package/dist/src/core/loop.d.ts +11 -0
  103. package/dist/src/core/loop.js +63 -4
  104. package/dist/src/core/loop.js.map +1 -1
  105. package/dist/src/core/policy-diff.js +20 -1
  106. package/dist/src/core/policy-diff.js.map +1 -1
  107. package/dist/src/core/policy-explain.js +18 -2
  108. package/dist/src/core/policy-explain.js.map +1 -1
  109. package/dist/src/core/policy-load.d.ts +38 -1
  110. package/dist/src/core/policy-load.js +34 -2
  111. package/dist/src/core/policy-load.js.map +1 -1
  112. package/dist/src/core/policy-match.js +30 -5
  113. package/dist/src/core/policy-match.js.map +1 -1
  114. package/dist/src/core/policy-proposal.d.ts +17 -0
  115. package/dist/src/core/policy-proposal.js +32 -16
  116. package/dist/src/core/policy-proposal.js.map +1 -1
  117. package/dist/src/core/prompt-layout.d.ts +186 -1
  118. package/dist/src/core/prompt-layout.js +345 -3
  119. package/dist/src/core/prompt-layout.js.map +1 -1
  120. package/dist/src/core/sender-identity.d.ts +24 -1
  121. package/dist/src/core/sender-identity.js +31 -0
  122. package/dist/src/core/sender-identity.js.map +1 -1
  123. package/dist/src/core/token.js +11 -0
  124. package/dist/src/core/token.js.map +1 -1
  125. package/dist/src/core/tool-map.d.ts +125 -0
  126. package/dist/src/core/tool-map.js +179 -0
  127. package/dist/src/core/tool-map.js.map +1 -0
  128. package/dist/src/core/validate.d.ts +20 -6
  129. package/dist/src/core/validate.js +0 -0
  130. package/dist/src/core/validate.js.map +1 -1
  131. package/dist/src/daemon/git-evidence.d.ts +2 -2
  132. package/dist/src/daemon/git-evidence.js +1 -1
  133. package/dist/src/serve/archive.d.ts +8 -0
  134. package/dist/src/serve/archive.js +11 -2
  135. package/dist/src/serve/archive.js.map +1 -1
  136. package/docs/README-extended.md +15 -0
  137. package/docs/cli-reference.md +571 -28
  138. package/package.json +2 -2
  139. package/schema/event.schema.json +387 -29
  140. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  141. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  142. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  143. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  144. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  145. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  146. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  147. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  148. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  149. package/schema/fixtures/event/invalid/audit-reviewed-model-actor.json +20 -0
  150. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  151. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  152. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  153. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  154. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  155. package/schema/fixtures/event/invalid/audit-reviewed-verdict-source-model.json +20 -0
  156. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  157. package/schema/fixtures/event/invalid/execution-started-harness-tool-space.json +18 -0
  158. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  159. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  160. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  161. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  162. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  163. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  164. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  165. package/schema/fixtures/event/valid/daemon-identity.json +2 -1
  166. package/schema/fixtures/event/valid/execution-started-harness-tool.json +19 -0
  167. package/schema/fixtures/policy/invalid/delegation-advice-string.json +15 -0
  168. package/schema/fixtures/policy/invalid/delegation-class-duplicate.json +18 -0
  169. package/schema/fixtures/policy/invalid/delegation-class-wildcard.json +17 -0
  170. package/schema/fixtures/policy/invalid/delegation-daily-cap-fractional.json +15 -0
  171. package/schema/fixtures/policy/invalid/delegation-daily-cap-negative.json +15 -0
  172. package/schema/fixtures/policy/invalid/delegation-daily-cap-too-high.json +15 -0
  173. package/schema/fixtures/policy/invalid/delegation-escalate-duplicate.json +18 -0
  174. package/schema/fixtures/policy/invalid/delegation-escalate-unknown.json +17 -0
  175. package/schema/fixtures/policy/invalid/delegation-max-autonomy-alias.json +15 -0
  176. package/schema/fixtures/policy/invalid/delegation-max-autonomy-autonomous.json +15 -0
  177. package/schema/fixtures/policy/invalid/delegation-model-no-version.json +15 -0
  178. package/schema/fixtures/policy/invalid/delegation-model-provider-form.json +15 -0
  179. package/schema/fixtures/policy/invalid/delegation-null.json +7 -0
  180. package/schema/fixtures/policy/invalid/delegation-reviewer-agent.json +17 -0
  181. package/schema/fixtures/policy/invalid/delegation-reviewer-bare-id.json +17 -0
  182. package/schema/fixtures/policy/invalid/delegation-unknown-key.json +16 -0
  183. package/schema/fixtures/policy/invalid/tools-class-wildcard.json +9 -0
  184. package/schema/fixtures/policy/invalid/tools-match-double-star.json +9 -0
  185. package/schema/fixtures/policy/invalid/unmapped-tool-unknown.json +7 -0
  186. package/schema/fixtures/policy/valid/delegation-empty.json +7 -0
  187. package/schema/fixtures/policy/valid/delegation-grammar-full.json +41 -0
  188. package/schema/fixtures/policy/valid/delegation-off.json +15 -0
  189. package/schema/fixtures/policy/valid/tools-mapping.json +17 -0
  190. package/schema/policy.schema.json +178 -0
@@ -70,13 +70,19 @@
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";
85
+ import { HUMAN_ACTOR_RULE, reservedKindNote } from "./identity.js";
80
86
  /**
81
87
  * SPEC.md §8: the sampler is the runtime, so its actor is `system:`. Distinct
82
88
  * from `system:gate` (expiries) and `system:daemon` (envelope drift) so a reader
@@ -84,7 +90,22 @@ import { payloadOf, readVerifiedRecords } from "./state.js";
84
90
  */
85
91
  export const AUDIT_ACTOR = "system:audit";
86
92
  /** `human:<id>`, the only actor a review may carry. */
87
- const HUMAN_ACTOR = /^human:.+/u;
93
+ const HUMAN_ACTOR = HUMAN_ACTOR_RULE;
94
+ /**
95
+ * The `verdict_source` vocabulary of `audit.reviewed` (PR #614 F5): the
96
+ * values this runtime WRITES. One, `explicit`: the reviewer said the verdict.
97
+ */
98
+ export const VERDICT_SOURCES = ["explicit"];
99
+ /**
100
+ * `verdict_source` values RESERVED for a later core and never written by this
101
+ * one (APRV-500, amended SPEC.md §10.3 proposed). `model` will mark a review
102
+ * given by the judge (`model:<name>@<version>`, `delegation.reviewers`).
103
+ * Registered here so the word cannot be taken for anything else; refused at the
104
+ * write boundary (`event.schema.json` keeps `const: "explicit"` and a
105
+ * `^human:` actor), and a follower that meets it MAY ignore it, which is the
106
+ * safe failure: a model review is then never counted as a human approval.
107
+ */
108
+ export const RESERVED_VERDICT_SOURCES = ["model"];
88
109
  /**
89
110
  * The closed set of audit refusal codes. Frozen public API in the same sense the
90
111
  * gate's and the executor's are: a supervisor branches on these strings, so
@@ -93,12 +114,54 @@ const HUMAN_ACTOR = /^human:.+/u;
93
114
  export const AUDIT_REFUSAL_CODES = [
94
115
  /** Review was attempted by an actor that is not `human:<id>`. */
95
116
  "actor-not-human",
117
+ /**
118
+ * The reviewer is a person the class's `approvers` roster does not name
119
+ * (APRV-483). Same comparison and same code as a grant's (`core/gate.ts`
120
+ * `namesApprover`), because under supervised-retro a review IS the approval,
121
+ * and a roster that bound the grant and not the review would be a roster the
122
+ * retrospective path walks around, including through `--as`. A rule that
123
+ * names no roster restricts nobody, exactly as for grants. Evaluated once the
124
+ * sample (and so its class) is located; nothing is appended.
125
+ */
126
+ "actor-not-approver",
127
+ /**
128
+ * The policy the reviewer's roster would be read from is not the attested
129
+ * one: never attested, edited since, or unreadable (APRV-483 refutation).
130
+ * Same spelling as the gate's, with the attestation module's message. Before
131
+ * this a review read no policy and needed no attestation; once a review is
132
+ * held to a roster, a roster read from an unattested file (or from any file a
133
+ * terminal reviewer names with `--policy`) is a roster the reviewer chose,
134
+ * and a policy that fails to load would restrict nobody. Evaluated once the
135
+ * sample is located; nothing is appended.
136
+ */
137
+ "policy-not-attested",
138
+ /**
139
+ * The attested policy bytes do not load (schema-invalid, a YAML error, no
140
+ * policy block), so no roster can be read from them (PR #614 refutation F1).
141
+ * A gate meeting the same bytes resolves every class `manual`, which
142
+ * compensates; a review has nothing that compensates, because the action
143
+ * already ran and this record is its approval. So a policy that names nobody
144
+ * because it failed to load is refused here rather than read as "no roster".
145
+ * Distinct from `policy-not-attested`: the bytes ARE the attested ones, and
146
+ * the repair is a corrected policy and a new attestation, not re-attesting
147
+ * what is on disk. Evaluated once the sample is located; nothing is appended.
148
+ */
149
+ "policy-invalid",
96
150
  /** No `audit.sampled` record matches the subject named. */
97
151
  "not-sampled",
98
152
  /** That sample already has a later `audit.reviewed`. */
99
153
  "already-reviewed",
100
154
  /** An action key with more than one unreviewed sample; name the seq instead. */
101
155
  "ambiguous-subject",
156
+ /**
157
+ * The reviewing surface said it rendered the payload whole and named a hash
158
+ * that is not the sampled execution's recorded binding (APRV-481). Nothing is
159
+ * appended: a review's `payload_hash` says which bytes the reviewer read, and
160
+ * one naming bytes the execution did not bind to would claim a reading of
161
+ * something that never ran. Evaluated after the sample is located, before
162
+ * anything is written.
163
+ */
164
+ "rendered-payload-mismatch",
102
165
  /** No `reconciliation.required` record at the seq named (APRV-127). */
103
166
  "not-obliged",
104
167
  /** That obligation already has a `reconciliation.satisfied` (APRV-127). */
@@ -123,6 +186,17 @@ export const AUDIT_REFUSAL_CODES = [
123
186
  * operator is pleased. The reviewer is asked to say which they meant.
124
187
  */
125
188
  "reaction-conflicts-verdict",
189
+ /**
190
+ * A review that names no verdict (APRV-482). Under supervised-retro a review
191
+ * counts as the individual approval nobody gave before the action ran, and an
192
+ * approval is an affirmative act: a reaction tap on a card, or a bare
193
+ * `approval audit review <seq>`, used to write verdict `ok` on the reviewer's
194
+ * behalf. Now the verdict is said or nothing is written. Evaluated once the
195
+ * sample is located and the reviewer has passed the roster check (PR #614
196
+ * refutation N6: who may review is said before what the review lacks);
197
+ * nothing is appended.
198
+ */
199
+ "verdict-required",
126
200
  /**
127
201
  * A `gated-revert` obligation whose satisfaction names no completed revert
128
202
  * (APRV-127). The obligation is to undo the action THROUGH THE GATE, and the
@@ -250,6 +324,7 @@ export function sampledSubjects(records) {
250
324
  task: stringOrNull(record.task) ?? stringOrNull(payload["task"]),
251
325
  subjectHash: stringOrNull(payload["subject_hash"]),
252
326
  subjectSeq: typeof subjectSeq === "number" && Number.isInteger(subjectSeq) ? subjectSeq : null,
327
+ policySha256: isPolicySha256(payload[POLICY_HASH_FIELD]) ? payload[POLICY_HASH_FIELD] : null,
253
328
  reviewedSeq: null,
254
329
  });
255
330
  }
@@ -275,6 +350,33 @@ export function sampledSubjects(records) {
275
350
  export function openSamples(records) {
276
351
  return sampledSubjects(records).filter((subject) => subject.reviewedSeq === null);
277
352
  }
353
+ /**
354
+ * The payload hash the sampled execution bound to, or `null` when the log
355
+ * records none (APRV-480).
356
+ *
357
+ * Read off the `execution.started` the sample names, whose `payload_hash` is the
358
+ * executor's recomputation of the bytes it ran; failing that, off the
359
+ * registration's declared binding. Both are records the log already holds, so
360
+ * nothing here takes a payload hash from the party under review, and the review
361
+ * card (`cli/audit-card.ts`) and the review record (APRV-481) derive the same
362
+ * answer from the same records.
363
+ */
364
+ export function boundPayloadHash(records, subject) {
365
+ const start = subject.subjectSeq === null
366
+ ? undefined
367
+ : records.find((record) => record.seq === subject.subjectSeq && record.event === "execution.started");
368
+ const started = start === undefined ? null : stringOrNull(payloadOf(start)["payload_hash"]);
369
+ if (started !== null)
370
+ return started;
371
+ if (subject.actionKey === null)
372
+ return null;
373
+ // PR #614 refutation N7: findDeclaration's contract. A key two tasks declare
374
+ // is a collision registration refuses, so a log holding one cannot say which
375
+ // binding governs, and no binding is the fail-closed answer.
376
+ if (declaringTasks(records, subject.actionKey).length > 1)
377
+ return null;
378
+ return findDeclaration(records, subject.actionKey)?.payload_hash ?? null;
379
+ }
278
380
  /** The candidates a sweep would sample now: eligible, selected, not yet sampled. */
279
381
  export function pendingSamples(records, load, sampler) {
280
382
  if (!sampler.enabled)
@@ -364,7 +466,20 @@ export function sampleSupervised(logPath, cwd, options = {}) {
364
466
  const next = pending.find((candidate) => !appended.some((done) => done.candidate.hash === candidate.hash));
365
467
  if (next === undefined)
366
468
  break;
367
- const result = appendSample(logPath, next, sampler, read.head, options);
469
+ // PR #614 refutation F2, recheck NF-3. The sample pins the attested policy
470
+ // the action RAN under (see `executionPolicySha256`), so the review is held
471
+ // to that policy's roster and not to one attested between the execution and
472
+ // this sweep. Only an execution older than every attestation in the log
473
+ // falls back to the latest attestation, the reading before NF-3. A log with
474
+ // no attestation cannot have run a supervised action (the executor refuses
475
+ // `policy-not-attested` on that path), so meeting one here is a fact about
476
+ // the log an operator must look at, never a sample written unpinned.
477
+ const policySha256 = executionPolicySha256(read.records, next) ?? latestAttestedSha256(read.records);
478
+ if (policySha256 === null) {
479
+ 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\`.`));
480
+ break;
481
+ }
482
+ const result = appendSample(logPath, next, sampler, read.head, policySha256, options);
368
483
  if (!result.ok) {
369
484
  // One failure ends the sweep either way, transient or not. A held lock or a
370
485
  // moved head means another writer is mid-transaction, and walking the rest
@@ -383,7 +498,50 @@ export function sampleSupervised(logPath, cwd, options = {}) {
383
498
  }
384
499
  return { ok: true, sampler, appended, refusals, deferred };
385
500
  }
386
- function appendSample(logPath, candidate, sampler, head, options) {
501
+ /**
502
+ * The `sha256` the latest policy attestation in `records` names, or `null`
503
+ * when the log holds none (PR #614 refutation F2). The same record
504
+ * `checkAttestationOfBytes` compares against: the latest attestation is the
505
+ * policy in force.
506
+ */
507
+ function latestAttestedSha256(records) {
508
+ for (let index = records.length - 1; index >= 0; index -= 1) {
509
+ const sha = attestationSha256(records[index]);
510
+ if (sha !== null)
511
+ return sha;
512
+ }
513
+ return null;
514
+ }
515
+ /**
516
+ * The attested policy the sampled execution ran under, or `null` when the log
517
+ * cannot say (PR #614 recheck NF-3).
518
+ *
519
+ * The latest attestation BEFORE the execution's seq. The log is append-only,
520
+ * so the attestations before a record's seq are exactly the ones that existed
521
+ * when it was written, and the latest of them is the policy in force at that
522
+ * append. That is the value the gate stamps on a harness `execution.started`
523
+ * (APRV-447: the attestation its write boundary checked, under the same
524
+ * compare-and-append), so the two agree by construction; reading it from the
525
+ * log rather than from the stamp also covers the starts no stamp is written on
526
+ * (`approval run`'s path, and every start older than APRV-447). `null` only
527
+ * for an execution older than every attestation in the log; the caller then
528
+ * pins the latest one, the reading samples had before NF-3.
529
+ *
530
+ * Read from verified records only, at the moment the sample is drawn, and
531
+ * never from a caller: the pin is a runtime-written fact about the log.
532
+ */
533
+ function executionPolicySha256(records, candidate) {
534
+ let before = null;
535
+ for (const record of records) {
536
+ if (record.seq >= candidate.seq)
537
+ break;
538
+ const sha = attestationSha256(record);
539
+ if (sha !== null)
540
+ before = sha;
541
+ }
542
+ return before;
543
+ }
544
+ function appendSample(logPath, candidate, sampler, head, policySha256, options) {
387
545
  const payload = {
388
546
  // What was sampled, named by the chain's own identifiers so a reviewer (and
389
547
  // a reproducing operator) can find the subject without a projection.
@@ -405,6 +563,10 @@ function appendSample(logPath, candidate, sampler, head, options) {
405
563
  // was in force.
406
564
  rate: sampler.rateFor(candidate.class).rate ?? sampler.rate,
407
565
  autonomy: "supervised",
566
+ // PR #614 refutation F2, recheck NF-3: the attested policy the sampled
567
+ // execution ran under. Its review reads the class's roster from these
568
+ // bytes only.
569
+ [POLICY_HASH_FIELD]: policySha256,
408
570
  };
409
571
  const result = appendEvent(logPath, {
410
572
  ts: tick(options),
@@ -507,27 +669,37 @@ export function obligationFor(reversible) {
507
669
  * would be the party under oversight closing its own audit item, and a backlog
508
670
  * that can be emptied by the thing it supervises measures nothing.
509
671
  *
510
- * No attestation is required, for the reason `execution resolve` states: review
511
- * records an observation and exercises no policy authority. It authorizes
512
- * nothing, spends no budget, and mints no token.
672
+ * It needs the ATTESTED policy since APRV-483 (it needed none before): under
673
+ * supervised-retro a review is the approval, so the reviewer is held to the
674
+ * class's `approvers` roster, and that roster is read only from bytes a human
675
+ * attested ({@link reviewerRoster}). It spends no budget and mints no token.
513
676
  *
514
677
  * `--note` is optional and recorded verbatim when present. It is not mandatory
515
678
  * the way `execution resolve`'s is, because that verb writes an *outcome* the
516
679
  * runtime does not know while this one writes only "seen".
517
680
  */
518
681
  export function reviewSample(logPath, ref, actor, note, options = {}) {
519
- const verdict = options.verdict ?? "ok";
520
682
  if (!HUMAN_ACTOR.test(actor)) {
521
- 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)}.`);
683
+ 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)}${reservedKindNote(actor)}.`);
522
684
  }
685
+ // APRV-482. The verdict is an affirmative act or it is nothing. Read here and
686
+ // REFUSED after the roster check (PR #614 refutation N6): who may review comes
687
+ // before what the review lacks, so a reviewer off the roster who taps a grade
688
+ // is told `actor-not-approver` at once rather than `verdict-required` first
689
+ // and the roster only on their next tap.
690
+ const verdict = options.verdict;
691
+ const hasVerdict = verdict === "ok" || verdict === "denied";
523
692
  // APRV-239, and deliberately here: after the actor check and BEFORE the log is
524
693
  // read. Both rules are properties of the two arguments in front of this
525
694
  // function, so neither needs a log to decide, and a refusal that had already
526
695
  // read (and verified) a log would report a log failure for an invocation that
527
696
  // was malformed before it ever touched one. Nothing is appended on either
528
- // path; the reviewer fixes the invocation and reviews again.
697
+ // path; the reviewer fixes the invocation and reviews again. Both judge a
698
+ // grade beside a verdict, so a review with no verdict skips them and meets
699
+ // `verdict-required` below: a card's lone `loved` is told it lacks a verdict,
700
+ // not a note it will be asked for once the verdict comes.
529
701
  const reaction = options.reaction;
530
- if (reaction !== undefined) {
702
+ if (hasVerdict && reaction !== undefined) {
531
703
  if (verdict === "denied" && (reaction === "liked" || reaction === "loved")) {
532
704
  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.`);
533
705
  }
@@ -543,14 +715,54 @@ export function reviewSample(logPath, ref, actor, note, options = {}) {
543
715
  if (!located.ok)
544
716
  return located;
545
717
  const subject = located.subject;
718
+ // APRV-481. A review names the execution it judged, by the hash the sample
719
+ // recorded for it. Every sample the runtime writes carries one; a sample that
720
+ // does not names nothing a review could be about, and the write boundary
721
+ // requires the field, so this says so in the audit vocabulary rather than as
722
+ // a schema failure.
723
+ const sampledSubjectHash = subject.subjectHash;
724
+ if (sampledSubjectHash === null) {
725
+ 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 });
726
+ }
727
+ // APRV-483. The roster check a grant makes, made of the reviewer. The class
728
+ // and reversibility come from the registration (never from the review or the
729
+ // execution's own payload, global invariant 4), failing that from the
730
+ // runtime-written sample; the rule is the single winner `resolve` picks, as
731
+ // for a grant; and a rule with no `approvers` restricts nobody. The roster is
732
+ // read from the ATTESTED policy bytes, as a grant's is.
733
+ const rosterRefusal = reviewerRoster(logPath, read.records, subject, actor, options);
734
+ if (rosterRefusal !== null)
735
+ return rosterRefusal;
736
+ if (!hasVerdict) {
737
+ 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 });
738
+ }
739
+ // APRV-481. The surface's word that it showed the bytes is checked against
740
+ // the binding the log holds, never copied on trust (SPEC.md §11.1 invariant 4
741
+ // in spirit: a field that claims more scrutiny than happened is the same
742
+ // defect as one that lowers it).
743
+ const rendered = options.renderedPayloadHash;
744
+ if (rendered !== undefined) {
745
+ const bound = boundPayloadHash(read.records, subject);
746
+ if (bound === null || bound !== rendered) {
747
+ 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 });
748
+ }
749
+ }
546
750
  const payload = {
547
751
  subject_seq: subject.seq,
548
752
  subject_event: "audit.sampled",
549
753
  reviewed: true,
550
754
  verdict,
755
+ // PR #614 refutation F5: the discriminator a follower reads. Since APRV-482
756
+ // every verdict is the reviewer's own word; a review written before then
757
+ // may carry an `ok` the runtime filled in, and only this field (absent
758
+ // there) tells the two apart.
759
+ verdict_source: "explicit",
760
+ sampled_subject_hash: sampledSubjectHash,
551
761
  };
552
- if (subject.subjectHash !== null)
553
- payload["sampled_subject_hash"] = subject.subjectHash;
762
+ // Only when the bytes were on the screen (APRV-481): absent says the
763
+ // reviewer saw a hash, nothing, or a terminal.
764
+ if (rendered !== undefined)
765
+ payload["payload_hash"] = rendered;
554
766
  if (note !== null && note.trim().length > 0)
555
767
  payload["note"] = note;
556
768
  // Written only when it was given. An omitted reaction leaves no key, which is
@@ -602,6 +814,158 @@ export function reviewSample(logPath, ref, actor, note, options = {}) {
602
814
  return obliged;
603
815
  return { ok: true, record: result.record, subject, obligation: obliged.record };
604
816
  }
817
+ /**
818
+ * `actor-not-approver` when the class's roster does not name `actor`,
819
+ * `policy-not-attested` when the roster cannot be read from the attested
820
+ * policy, else `null` (APRV-483).
821
+ *
822
+ * Fail-closed in every input it does not control (APRV-483 refutation):
823
+ *
824
+ * - the policy is read ONCE from the file a grant would read
825
+ * (`core/gate.ts` `policyPathOf`), its bytes must be the policy the sample
826
+ * pinned, the one its execution ran under (PR #614 refutation F2, recheck
827
+ * NF-3; for an unpinned pre-fix sample, the latest attestation's), and the
828
+ * roster is parsed from those same bytes, so neither
829
+ * an edited file, an unreadable one, a later re-attestation, nor a
830
+ * `--policy` pointed elsewhere can supply the roster;
831
+ * - a class that matches no rule in that policy is refused
832
+ * `actor-not-approver` (F2): the defaults carry no roster, and for a review
833
+ * "no roster" would mean "anyone";
834
+ * - a sample whose class cannot be named (no registration and no class on the
835
+ * runtime-written sample) is refused rather than resolved as "no rule, no
836
+ * roster", because that reading would let anyone review it;
837
+ * - attested bytes the loader refuses are refused `policy-invalid` (PR #614
838
+ * refutation F1): their fail-closed resolution names no roster, and for a
839
+ * review "no roster" would mean "anyone".
840
+ *
841
+ * What still restricts nobody, by design and exactly as for a grant: an
842
+ * attested rule that names no `approvers`.
843
+ */
844
+ function reviewerRoster(logPath, records, subject, actor, options) {
845
+ const path = policyPathOf(options.policy === undefined ? {} : { policy: options.policy });
846
+ // PR #614 refutation F2 (ruling), recheck NF-3: the roster is the one in
847
+ // force when the action RAN, which the sample pins. A sample that pinned its
848
+ // policy is reviewed only against bytes hashing to that pin, so a later
849
+ // re-attestation that renames, re-rosters or drops the class's rule neither
850
+ // opens the review to a new reviewer nor leaves it with no roster; a sample
851
+ // written before samples pinned one keeps the latest-attestation reading.
852
+ // Fix round 3: the pinned bytes come from the payload store first, so a
853
+ // re-attestation does not strand the older open samples.
854
+ let bytes;
855
+ if (subject.policySha256 === null) {
856
+ try {
857
+ bytes = readFileSync(path);
858
+ }
859
+ catch (cause) {
860
+ return attestationRefused(unreadablePolicyStatus(path, cause instanceof Error ? cause.message : String(cause)), subject);
861
+ }
862
+ const attested = attestationRefused(checkAttestationOfBytes(records, bytes), subject);
863
+ if (attested !== null)
864
+ return attested;
865
+ }
866
+ else {
867
+ const pinned = pinnedPolicyBytes(logPath, records, subject, subject.policySha256, path);
868
+ if (!pinned.ok)
869
+ return pinned;
870
+ bytes = pinned.bytes;
871
+ }
872
+ // PR #614 refutation N7: findDeclaration's contract on an enforcement path.
873
+ // A key two tasks declare is a collision registration refuses (APRV-138), so
874
+ // a log holding one cannot say which class, and so which roster, governs.
875
+ if (subject.actionKey !== null &&
876
+ declaringTasks(records, subject.actionKey).length > 1) {
877
+ 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 });
878
+ }
879
+ const declared = subject.actionKey === null ? null : findDeclaration(records, subject.actionKey);
880
+ const sampleRecord = records.find((record) => record.seq === subject.seq);
881
+ const sampledClass = sampleRecord === undefined ? null : stringOrNull(payloadOf(sampleRecord)["class"]);
882
+ const cls = stringOrNull(declared?.class) ?? sampledClass;
883
+ if (cls === null) {
884
+ 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 });
885
+ }
886
+ const load = loadPolicyText(path, Buffer.from(bytes).toString("utf8"), options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
887
+ // PR #614 refutation F1. Attested is not the same as valid: `policy attest`
888
+ // hashes bytes and never parses them, so a policy the loader refuses can be
889
+ // attested. The fail-closed resolution such bytes produce carries
890
+ // `approvers: null`, which reads as "no restriction"; for a review that is
891
+ // the opposite of failing closed. Both checks, because the provenance is the
892
+ // resolution's own word for the same fact and a second spelling of it should
893
+ // not be able to slip past the first.
894
+ if (!load.ok)
895
+ return policyInvalid(path, `${load.code}: ${load.message}`, subject);
896
+ const resolution = resolve(load, cls, declared?.reversible === null || declared?.reversible === undefined
897
+ ? {}
898
+ : { reversible: declared.reversible });
899
+ if (resolution.provenance === "fail-closed") {
900
+ return policyInvalid(path, "the policy resolved on its fail-closed path", subject);
901
+ }
902
+ // PR #614 refutation F2. A class no rule matches resolves to the defaults,
903
+ // which carry no roster; for a grant that is "restricts nobody", and for a
904
+ // review it would be "anyone may approve". Refused instead, with the reason.
905
+ if (resolution.matched === null) {
906
+ 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 });
907
+ }
908
+ const approvers = resolution.approvers;
909
+ if (approvers === null || namesApprover(approvers, actor))
910
+ return null;
911
+ 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 });
912
+ }
913
+ /**
914
+ * The bytes of the policy the sample pinned, or `policy-not-attested` (PR #614
915
+ * refutation F2, recheck NF-3, fix round 3).
916
+ *
917
+ * Authority first: a human must have attested the pinned hash before the
918
+ * sample was taken. The sampler copies the hash from the log, so a pin no
919
+ * earlier attestation names is a record nobody attested, refused like
920
+ * unattested bytes.
921
+ *
922
+ * Then the bytes, from two places, each verified against the pin and neither
923
+ * trusted otherwise:
924
+ *
925
+ * 1. the APRV-356 payload store beside the log, where every attestation since
926
+ * APRV-356 (and every phone proposal) leaves the attested text, read by
927
+ * `storedPolicyText`, which hashes what it reads. This is what keeps a
928
+ * re-attestation, a settings save included, from stranding the samples
929
+ * pinned to the earlier policy;
930
+ * 2. the policy file on disk, only when its bytes hash to the pin (a chain last
931
+ * attested before APRV-356 stored nothing).
932
+ *
933
+ * Bytes in neither place are refused, naming the hash needed. The pin is never
934
+ * compared with the latest attestation: the question is which roster governed
935
+ * the action when it ran, whether or not a later policy has been attested.
936
+ */
937
+ function pinnedPolicyBytes(logPath, records, subject, pinned, path) {
938
+ const attestedBefore = records.some((record) => record.seq < subject.seq && attestationSha256(record) === pinned);
939
+ if (!attestedBefore) {
940
+ 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 });
941
+ }
942
+ const stored = storedPolicyText(records, payloadStoreDirFor(logPath), pinned);
943
+ if (stored !== null)
944
+ return { ok: true, bytes: Buffer.from(stored, "utf8") };
945
+ let onDisk;
946
+ try {
947
+ const bytes = readFileSync(path);
948
+ const live = policyBytesHash(bytes);
949
+ if (live === pinned)
950
+ return { ok: true, bytes };
951
+ onDisk = `bytes hashing to ${live}`;
952
+ }
953
+ catch (cause) {
954
+ onDisk = `nothing readable (${cause instanceof Error ? cause.message : String(cause)})`;
955
+ }
956
+ 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 });
957
+ }
958
+ /** `policy-invalid`: attested bytes the loader refuses name no roster (F1). */
959
+ function policyInvalid(path, cause, subject) {
960
+ 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 });
961
+ }
962
+ /** `policy-not-attested` for a non-attested status, else `null`. */
963
+ function attestationRefused(status, subject) {
964
+ const refusal = attestationRefusal(status);
965
+ if (refusal === null)
966
+ return null;
967
+ 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 });
968
+ }
605
969
  /**
606
970
  * Append the `reconciliation.required` that a denial creates.
607
971
  *
@@ -740,13 +1104,13 @@ export function openObligations(records) {
740
1104
  * ceremony with its own `policy.updated` record — so the note is the discharge
741
1105
  * there, and the note is required.
742
1106
  *
743
- * No attestation is required, for the reason `audit review` and `execution
744
- * resolve` state: this record exercises no policy authority, authorizes nothing,
745
- * spends no budget, and mints no token.
1107
+ * No attestation is required, for the reason `execution resolve` states: this
1108
+ * record exercises no policy authority, authorizes nothing, spends no budget,
1109
+ * and mints no token.
746
1110
  */
747
1111
  export function satisfyObligation(logPath, obligationSeq, actor, input, options = {}) {
748
1112
  if (!HUMAN_ACTOR.test(actor)) {
749
- return refuse("actor-not-human", `satisfying a reconciliation obligation is human-only: the event's entire content is that a PERSON judged the obligation discharged, and a runtime that could close its own obligations would be a reconciliation backlog that empties itself. The actor must match human:<id>, got ${JSON.stringify(actor)}.`);
1113
+ return refuse("actor-not-human", `satisfying a reconciliation obligation is human-only: the event's entire content is that a PERSON judged the obligation discharged, and a runtime that could close its own obligations would be a reconciliation backlog that empties itself. The actor must match human:<id>, got ${JSON.stringify(actor)}${reservedKindNote(actor)}.`);
750
1114
  }
751
1115
  const note = input.note.trim();
752
1116
  if (note.length === 0) {