@sanning/proof 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +30 -29
  2. package/dist/agent-record.d.ts +62 -0
  3. package/dist/agent-record.js +527 -0
  4. package/dist/agent-schemas.generated.d.ts +15 -0
  5. package/dist/agent-schemas.generated.js +5759 -0
  6. package/dist/case-file.d.ts +32 -89
  7. package/dist/case-file.js +41 -93
  8. package/dist/case.d.ts +343 -810
  9. package/dist/case.js +693 -1151
  10. package/dist/chain.d.ts +19 -47
  11. package/dist/chain.js +78 -205
  12. package/dist/claims.d.ts +211 -0
  13. package/dist/claims.js +1081 -0
  14. package/dist/cli.d.ts +45 -68
  15. package/dist/cli.js +618 -1022
  16. package/dist/compose.js +23 -65
  17. package/dist/crypto.d.ts +7 -8
  18. package/dist/crypto.js +60 -147
  19. package/dist/declaration.d.ts +68 -194
  20. package/dist/declaration.js +175 -518
  21. package/dist/environment.d.ts +9 -30
  22. package/dist/environment.js +17 -66
  23. package/dist/evidence.d.ts +119 -160
  24. package/dist/evidence.js +744 -1103
  25. package/dist/exit-codes.d.ts +11 -27
  26. package/dist/exit-codes.js +24 -59
  27. package/dist/file-match.d.ts +33 -79
  28. package/dist/file-match.js +33 -69
  29. package/dist/grading.d.ts +98 -135
  30. package/dist/grading.js +224 -380
  31. package/dist/handoff.d.ts +62 -160
  32. package/dist/handoff.js +153 -393
  33. package/dist/index.d.ts +14 -10
  34. package/dist/index.js +60 -167
  35. package/dist/json-schema.d.ts +29 -0
  36. package/dist/json-schema.js +298 -0
  37. package/dist/merkle.js +16 -28
  38. package/dist/message-parts.d.ts +56 -212
  39. package/dist/message-parts.js +142 -502
  40. package/dist/side-content.d.ts +11 -22
  41. package/dist/side-content.js +22 -65
  42. package/dist/types.d.ts +17 -0
  43. package/dist/types.js +19 -5
  44. package/dist/verifier.d.ts +40 -114
  45. package/dist/verifier.js +196 -535
  46. package/dist/witness-cache.d.ts +24 -45
  47. package/dist/witness-cache.js +38 -81
  48. package/dist/witness-lookup.d.ts +36 -57
  49. package/dist/witness-lookup.js +89 -139
  50. package/dist/zip-reader.d.ts +18 -33
  51. package/dist/zip-reader.js +19 -34
  52. package/package.json +11 -3
  53. package/src/agent-record.ts +608 -0
  54. package/src/agent-schemas.generated.ts +5764 -0
  55. package/src/case-file.ts +45 -109
  56. package/src/case.ts +848 -1322
  57. package/src/chain.ts +85 -224
  58. package/src/claims.ts +1253 -0
  59. package/src/cli.ts +714 -1176
  60. package/src/compose.ts +33 -79
  61. package/src/crypto.ts +60 -147
  62. package/src/declaration.ts +219 -656
  63. package/src/environment.ts +17 -65
  64. package/src/evidence.ts +958 -1381
  65. package/src/exit-codes.ts +24 -63
  66. package/src/file-match.ts +40 -89
  67. package/src/grading.ts +287 -421
  68. package/src/handoff.ts +175 -427
  69. package/src/index.ts +132 -177
  70. package/src/json-schema.ts +295 -0
  71. package/src/merkle.ts +16 -28
  72. package/src/message-parts.ts +155 -533
  73. package/src/side-content.ts +25 -68
  74. package/src/types.ts +55 -36
  75. package/src/verifier.ts +202 -526
  76. package/src/witness-cache.ts +45 -94
  77. package/src/witness-lookup.ts +107 -177
  78. package/src/zip-reader.ts +20 -38
  79. package/vectors/README.md +52 -34
  80. package/vectors/agent-record.json +2175 -0
  81. package/vectors/case-file-index.json +1 -1
  82. package/vectors/case-split.json +2 -2
  83. package/vectors/chain-reading.json +3 -3
  84. package/vectors/file-match.json +285 -148
  85. package/vectors/jcs-reader.json +6 -6
  86. package/vectors/pack-grading.json +102 -25
  87. package/vectors/sandbox-evidence.json +597 -580
  88. package/vectors/stamp-on-chain.json +18 -18
  89. package/vectors/witness-cache.json +49 -49
  90. package/dist/agent-proof.d.ts +0 -28
  91. package/dist/agent-proof.js +0 -226
  92. package/dist/run-adapters.d.ts +0 -10
  93. package/dist/run-adapters.js +0 -35
  94. package/dist/run.d.ts +0 -209
  95. package/dist/run.js +0 -1405
  96. package/src/agent-proof.ts +0 -264
  97. package/src/run-adapters.ts +0 -44
  98. package/src/run.ts +0 -1598
package/src/grading.ts CHANGED
@@ -1,72 +1,36 @@
1
- // Pack grading — what a verification SURFACE may claim about a pack it was
2
- // handed, as distinct from what the wrapper verdict says about a bundle.
1
+ // Pack grading: what a verification surface may claim about a pack (a bundle
2
+ // plus the bytes handed in beside it), as distinct from the bundle verdict.
3
3
  //
4
- // WHY THIS LIVES IN THE KERNEL.
5
- // This rule was, until now, derived twice: once in a CLI trust close and once
6
- // in a browser verify page, in two repos and two languages, held together by a
7
- // conformance table copied byte-for-byte into both. The table caught an
8
- // implementation drifting from the rule. Nothing caught the table itself being
9
- // edited on one side — and the two surfaces had already diverged once, one
10
- // binding disclosed bytes per record and the other believed to bind by set
11
- // membership. Two implementations of one verification rule is the defect; a
12
- // shared table is a mitigation of it.
4
+ // Pure: no `fs`, no `fetch`. A directory read and a browser drop both arrive
5
+ // as a content map and must produce the same grade. Causes are structured so a
6
+ // surface can render its own prose or call `renderCause`.
13
7
  //
14
- // Grading is kernel semantics that leaked into consumers. The rule it encodes
15
- // is already normative in `specs/evidence-bundle.md`: absent disclosure — and
16
- // an absent committed `content_hash` — is **undetermined, not failed** (§5.1),
17
- // a genuine mismatch fails the event and the rollup (§5.1), and no status ever
18
- // means "safe" (§6). What the surfaces added was a sharper word for the
19
- // undetermined case and an account of what the pack failed to disclose. Both
20
- // belong next to the verifier that produced the result.
21
- //
22
- // ── the seam ────────────────────────────────────────────────────────────────
23
- //
24
- // Everything here is PURE: data in, data out. No `fs`, no `fetch`, no key
25
- // lifecycle — the kernel's standing boundary (`specs/architecture.md` §3) is
26
- // unchanged. How disclosed bytes were transported is a consumer concern; a
27
- // directory read and a browser drop both arrive here as a content map and MUST
28
- // produce the same verdict. `causes` come back STRUCTURED, so a surface can
29
- // render its own prose (or call `renderCause` and get the house wording)
30
- // without the kernel taking on presentation.
31
- //
32
- // ── the rule ────────────────────────────────────────────────────────────────
33
- //
34
- // Three outcomes where the old code had two:
35
- //
36
- // verified every record's own bytes hashed to its OWN committed
8
+ // Outcomes (evidence-bundle.md sections 3 and 5.1), read off six claims (`claims.ts`):
9
+ // verified every record's own bytes hashed to its own committed
37
10
  // content_hash, and nothing is missing, unreadable, surplus or
38
11
  // omitted
39
- // failed some record's bytes DISAGREE with what was committed — tamper
40
- // incomplete some record's bytes are absent or unreadable, or the pack
41
- // presents a different number of records than a checkpoint
42
- // signed for — UNKNOWN
12
+ // failed a claim is disproven: some record's bytes disagree with its
13
+ // commitment, or (reason kind nonconforming) an agent record is
14
+ // signed and intact and does not follow its vocabulary
15
+ // incomplete nothing is disproven and a claim is not proven: some record's
16
+ // bytes are absent or unreadable, the pack presents a different
17
+ // record count than a checkpoint signed, or an agent record
18
+ // reads partial
43
19
  //
44
- // `incomplete` is one of the eight words of `evidence-bundle.md` §3 (v1.17,
45
- // A18), and it is a PACK verdict, never a bundle status. The bundle status
46
- // grades a signed bundle; this grades a PACK — a strictly larger object, the
47
- // bundle plus whatever bytes travelled beside it. A pack can be incomplete
48
- // while the bundle it carries is impeccable, which is exactly the case that
49
- // used to render green over zero hashed bytes. One vocabulary, two layers,
50
- // each issuing the subset it can reach.
20
+ // `incomplete` is a pack verdict, never a bundle status: a pack can be
21
+ // incomplete while its bundle is sound.
51
22
  //
52
- // `recordsVerified` counts CONTENT-BOUND records only. The kernel's own
53
- // `event.ok` tolerates undisclosed content by design — a bundle may legitimately
54
- // withhold a record (minimal disclosure, §5.1) — which is right for the kernel
55
- // and wrong for a surface whose headline is that the logs were checked.
56
- // Counting `ok` events is how "26 records verified" got printed over zero
57
- // hashed bytes.
23
+ // `recordsVerified` counts content-bound records only. The kernel's `event.ok`
24
+ // tolerates withheld content (minimal disclosure, §5.1), so counting `ok`
25
+ // events would report records verified over zero hashed bytes.
58
26
  //
59
- // Binding is PER RECORD, never per set. The bytes disclosed for `event_id` are
60
- // hashed against the commitment inside THAT event's signed record; the verifier
61
- // does this and this module never re-implements it. A set-membership check
62
- // ("is this digest anywhere in the bundle?") passes a swap of two records'
63
- // bytes, and that is the shape of the divergence this module exists to end.
27
+ // Binding is per record, never per set: a set-membership check passes a swap
28
+ // of two records' bytes. The verifier does the binding; this module never
29
+ // re-implements it.
64
30
  //
65
- // The conformance statement of all of the above is `../vectors/pack-grading.json`,
66
- // which ships in the published tarball so a consumer can diff its own vendored
67
- // copy against it. If an implementation disagrees with the table, the
68
- // implementation is wrong.
31
+ // Conformance: `../vectors/pack-grading.json`, shipped in the package.
69
32
 
33
+ import { INCOMPLETE_REASONS } from "./agent-record.js";
70
34
  import { hexToBytes } from "./crypto.js";
71
35
  import type {
72
36
  DeclarationCheck,
@@ -76,62 +40,57 @@ import type {
76
40
  } from "./declaration.js";
77
41
  import { renderContentFinding, renderDeviation } from "./declaration.js";
78
42
  import { hasSideContent, sideContentKeys, type SideContentMap } from "./side-content.js";
79
- // The kernel's one reader for bytes a producer signed: strict UTF-8, no leading
80
- // byte-order mark, no non-finite literal. `jcs_loads` in
81
- // `src/sanning_proof/canonicalize.py` is the twin (proof#140 gate 5).
43
+ import {
44
+ VERDICTS,
45
+ bundleProblems,
46
+ claimsOf,
47
+ problem,
48
+ verdictOf,
49
+ type Problem,
50
+ type ProblemItem,
51
+ type ReasonKind,
52
+ type Verdict,
53
+ } from "./claims.js";
82
54
  import { jcsParse } from "./verifier.js";
83
55
 
84
- // ---- The verdict a surface may print ----------------------------------------
85
-
86
- // A superset of `EvidenceStatus`: the four the kernel can return, plus the
87
- // pack-level `incomplete`, plus `pending` from the shared §3 vocabulary so a
88
- // kernel headline this module does not downgrade can pass through unchanged.
89
- export const PACK_VERDICTS = [
90
- "verified",
91
- "partial",
92
- "pending",
93
- "not_found",
94
- "mixed",
95
- "failed",
96
- "malformed",
97
- "incomplete",
98
- ] as const;
99
- export type PackVerdict = (typeof PACK_VERDICTS)[number];
100
-
101
- // ---- Inputs ------------------------------------------------------------------
56
+ // The three verdicts (evidence-bundle.md section 3). A pack reads its claims.
57
+ export const PACK_VERDICTS = VERDICTS;
58
+ export type PackVerdict = Verdict;
102
59
 
103
60
  /**
104
- * The minimum this module reads out of a verification result.
105
- *
106
- * `EvidenceBundleResult` satisfies it structurally, so a consumer normally
107
- * passes the kernel's own result straight through. It is stated as a minimum
108
- * rather than an import so a surface running an OLDER pinned kernel — or one
109
- * grading a result it adapted from another verifier — can still be held to
110
- * this rule.
61
+ * The minimum this module reads from a verification result.
62
+ * `EvidenceBundleResult` satisfies it, and an adapted result from another
63
+ * verifier can satisfy it too.
111
64
  */
112
65
  export interface GradeInput {
113
66
  status: string | null | undefined;
67
+ /** The bundle could not be read as one (`EvidenceBundleResult.unreadable`). */
68
+ unreadable?: boolean;
114
69
  /**
115
- * `eventId` and `payloadBindingOk` are read by rule version 6 alone (A22):
116
- * an input that does not carry both is never withheld by its declaration,
117
- * so an adapter written for rule version 5 keeps the stricter reading.
70
+ * `eventId` and `payloadBindingOk` are read only for declared withholding
71
+ * (evidence-bundle §5.4.5). An input without both is never withheld, so it
72
+ * gets the stricter reading.
118
73
  */
119
74
  events: {
120
75
  ok: boolean;
121
76
  contentOk: boolean | null;
122
77
  eventId?: string;
123
78
  payloadBindingOk?: boolean | null;
79
+ /** The record's agent reading, where it is one (rule version 9). Its findings move nothing. */
80
+ agent?: {
81
+ verdict: string | null;
82
+ reason?: string;
83
+ at?: string;
84
+ reasons?: readonly { reason: string; at: string }[];
85
+ absent?: readonly string[];
86
+ } | null;
124
87
  }[];
125
88
  }
126
89
 
127
90
  /**
128
- * The account of which records disclosed bytes, and what could not be used.
129
- *
130
- * An empty account is NOT a clean bill of health: callers gate on this, never
131
- * on the absence of mismatches. The old readers wrapped the disclosure read in
132
- * `try {} catch {}` and fell back to an empty map, so deleting every log turned
133
- * every content check undetermined, left every event `ok`, and printed a
134
- * verified verdict over zero hashed bytes.
91
+ * Which records disclosed bytes, and what could not be used. An empty account
92
+ * is not a clean result: callers gate on this, never on the absence of
93
+ * mismatches.
135
94
  */
136
95
  export interface DisclosureAccount {
137
96
  /** `event_id`s the bundle presents, in bundle order. */
@@ -147,15 +106,13 @@ export interface DisclosureAccount {
147
106
  }
148
107
 
149
108
  /**
150
- * A completeness note: the pack presents a different number of records for a
151
- * checkpoint than that checkpoint's SIGNED record commits to.
109
+ * The pack presents a different number of records for a checkpoint than that
110
+ * checkpoint's signed record commits to.
152
111
  */
153
112
  export type CompletenessNote =
154
113
  | { checkpointRef: string; signed: number; presented: number; kind: "omitted" | "surplus" }
155
114
  | { checkpointRef: string; signed: null; presented: null; kind: "unreadable" };
156
115
 
157
- // ---- Output ------------------------------------------------------------------
158
-
159
116
  export const PACK_GRADE_CAUSE_KINDS = [
160
117
  "records_omitted",
161
118
  "records_surplus",
@@ -166,27 +123,23 @@ export const PACK_GRADE_CAUSE_KINDS = [
166
123
  "unaccounted_disclosure",
167
124
  "declaration_deviation",
168
125
  /**
169
- * The pack and the UNSIGNED content handed in beside it do not line up
170
- * (proof#147). A cause, so the pack cannot read clean over it and a reader
171
- * is told which record; NOT a deviation, so it never reaches `failed`.
126
+ * The pack and the unsigned content beside it do not line up. A cause, so the
127
+ * pack cannot read clean; not a deviation, so it never reaches `failed`.
172
128
  */
173
129
  "declaration_content_dependent",
130
+ /** A reason of an agent record that reads `failed` (agent-events.md section 10): the pack is `failed`, reason kind `nonconforming`. */
131
+ "agent_record_nonconforming",
132
+ /** A reason of an agent record that reads `incomplete`: the pack is `incomplete`. */
133
+ "agent_record_incomplete",
174
134
  ] as const;
175
135
  export type PackGradeCauseKind = (typeof PACK_GRADE_CAUSE_KINDS)[number];
176
136
 
177
- /**
178
- * One reason a pack is not `verified`, as data.
179
- *
180
- * Structured because the two surfaces that used to derive this rule also
181
- * derived its prose, and prose is the part that drifts hardest and matters
182
- * least. `renderCause` produces the house wording; a surface with its own
183
- * voice reads the fields.
184
- */
137
+ /** One reason a pack is not `verified`, as data. `renderCause` gives the house wording. */
185
138
  export interface PackGradeCause {
186
139
  kind: PackGradeCauseKind;
187
140
  /** The checkpoint this is about, for the completeness kinds. */
188
141
  checkpointRef?: string;
189
- /** The count a checkpoint's signed record commits to. */
142
+ /** The count the checkpoint's signed record commits to. */
190
143
  signed?: number;
191
144
  /** The count the pack actually presents for that checkpoint. */
192
145
  presented?: number;
@@ -204,36 +157,49 @@ export interface PackGradeCause {
204
157
  * `declaration_content_dependent`.
205
158
  */
206
159
  deviation?: DeclarationDeviationKind | DeclarationContentKind;
160
+ /**
161
+ * The JSON Pointer the agent reading's reason is about, for
162
+ * `agent_record_nonconforming` and `agent_record_incomplete`; `reason` is its reason
163
+ * code (agent-events.md section 10.1).
164
+ */
165
+ at?: string;
166
+ /**
167
+ * For `agent_record_incomplete` with `listed_record_absent`, the `event_id` the
168
+ * closing record lists and the pack does not hold.
169
+ */
170
+ listedEventId?: string;
207
171
  }
208
172
 
209
173
  export interface PackGrade {
210
174
  verdict: PackVerdict;
175
+ /** `changed` or `nonconforming` where `verdict` is `failed`. */
176
+ reasonKind?: ReasonKind;
177
+ /**
178
+ * Every problem behind the verdict, each mapped to one claim and one state
179
+ * (`claims.ts`): the bundle's own, then the causes', then the declaration's.
180
+ * Empty exactly where `verdict` is `verified`.
181
+ */
182
+ problems: Problem[];
211
183
  /** Records whose OWN bytes bound to their OWN commitment. Nothing else. */
212
184
  recordsVerified: number;
213
185
  recordsFailed: number;
214
186
  recordsUndetermined: number;
215
187
  causes: PackGradeCause[];
216
188
  /**
217
- * The declaration check's own verdict, carried so a consumer that reads only
218
- * this value can tell a declared pack from an undeclared one. Absent when no
219
- * check was passed, which is the pre-§5.4 call.
189
+ * The declaration check's verdict, so a consumer can tell a declared pack
190
+ * from an undeclared one. Absent when no check was passed.
220
191
  */
221
192
  declaration?: DeclarationVerdict;
222
193
  /**
223
- * Records WITHHELD BY THEIR DECLARATION (rule version 6, evidence-bundle
224
- * 5.4.5 v1.20, A22): a readable declaration lists them in
225
- * `declaredWithheld`, their `ok` is true, their `contentOk` is `null` and
226
- * their `payloadBindingOk` is true. Never counted in `recordsVerified`: no
227
- * byte of their content was hashed, and a surface MUST NOT print them as
228
- * verified records.
194
+ * Records withheld by their declaration (evidence-bundle §5.4.5). Never
195
+ * counted in `recordsVerified`: none of their content was hashed, and a
196
+ * surface must not print them as verified.
229
197
  */
230
198
  recordsWithheld: number;
231
199
  /** Their `event_id`s, in bundle order. */
232
200
  withheldEventIds: string[];
233
201
  }
234
202
 
235
- // ---- Reading a bundle --------------------------------------------------------
236
-
237
203
  interface BundleLike {
238
204
  body?: {
239
205
  events?: {
@@ -245,22 +211,10 @@ interface BundleLike {
245
211
  }
246
212
 
247
213
  /**
248
- * The checkpoint's join key, in whichever vocabulary the bundle speaks.
249
- *
250
- * ── Why this is read from the entry and not from the body_type ──
251
- *
252
- * Grading runs over a PACK, which a consumer may hand us in pieces, so the
253
- * wrapper is not always in scope here. Reading the key off the entry keeps
254
- * grading honest for both forms with no wrapper lookup. It is deliberately
255
- * NOT a fallback chain that would accept either spelling on the same entry:
256
- * an entry carrying both is a mixed bundle, and `verifyEvidenceBundle`
257
- * rejects it — grading must not quietly grade what the verifier refuses.
258
- *
259
- * Returning `null` (rather than a "(no tx_id)" placeholder that compares
260
- * equal to itself) matters: before this was dual-form, a v2 pack read every
261
- * checkpoint key as absent, every `presented` count as zero, and reported
262
- * EVERY checkpoint as "omitted" — a completeness accusation manufactured by
263
- * a field rename. A key we cannot read is unreadable, never zero.
214
+ * The checkpoint's join key, `checkpoint_ref` or the retired `tx_id`. Read from
215
+ * the entry because the wrapper may not be in scope. An entry carrying both is
216
+ * a mixed bundle the verifier rejects, so it reads as `null`. `null` means
217
+ * unreadable, never zero, so a key we cannot read is never counted as omitted.
264
218
  */
265
219
  function joinKeyOf(o: { checkpoint_ref?: unknown; tx_id?: unknown } | null | undefined): string | null {
266
220
  if (!o) return null;
@@ -280,10 +234,8 @@ function inclusionKeyOf(
280
234
  return (hasV2 ? incl.checkpoint_ref : incl.checkpoint_tx_id) as string;
281
235
  }
282
236
 
283
- // The key a checkpoint and an inclusion are MATCHED on. A `checkpoint_ref` is
284
- // folded to lower case, because the verifier lowercases it before comparing.
285
- // A retired `tx_id` / `checkpoint_tx_id` is compared exactly as written, as the
286
- // retired reader did, even when it happens to look like hex.
237
+ // The key a checkpoint and an inclusion are matched on. `checkpoint_ref` is
238
+ // lowercased, as the verifier does; a retired key is compared exactly.
287
239
  function checkpointMatchKey(o: { checkpoint_ref?: unknown; tx_id?: unknown } | null | undefined): string | null {
288
240
  const k = joinKeyOf(o);
289
241
  return k !== null && typeof o?.checkpoint_ref === "string" ? k.toLowerCase() : k;
@@ -306,84 +258,32 @@ const checkpointsOf = (bundle: unknown) => {
306
258
  return Array.isArray(c) ? c : [];
307
259
  };
308
260
 
309
- // Presence in a caller-built content map — DEFERRED to `side-content.ts`, which
310
- // is the same rule the VERIFIER applies, because two answers to "is there an
311
- // entry" inside one kernel is two implementations of one verification rule, and
312
- // ending that is why this module was brought into the kernel at all.
313
- //
314
- // This module used to answer it laxly: own + enumerable, nothing more. The
315
- // verifier additionally requires a DATA property (an accessor can throw, and a
316
- // verifier must never raise on adversarial input) holding something other than
317
- // null/undefined (`{ [id]: undefined }` and `new Map([[id, undefined]])` are
318
- // how "no entry" is spelled in the two shapes). So on one map this module said
319
- // PRESENT and the verifier said nothing was offered — and `gradePack` returned
320
- // `incomplete` with ZERO causes, the very outcome the comment in
321
- // `accountDisclosure` below says was removed. Now there is one definition and
322
- // two call sites.
261
+ // Presence in a content map is decided by `side-content.ts`, the same rule the
262
+ // verifier applies, so the grade and the verdict never disagree on an entry.
323
263
 
324
264
  /**
325
- * `event_id`s name files on every transport this rule has met. An id that
326
- * cannot safely name one cannot be bound to disclosed bytes, so it is refused
327
- * rather than joined into a path.
328
- *
329
- * ── WHAT THIS IS AND IS NOT ─────────────────────────────────────────────────
265
+ * `event_id`s name files on every transport. An id that cannot safely name one
266
+ * cannot be bound to disclosed bytes, so it reads as unreadable. This is an
267
+ * honesty guard, not a traversal fix (nothing here joins an id into a path);
268
+ * refusing an id can only move a verdict toward unknown, never toward verified.
330
269
  *
331
- * **Not a traversal fix, and it must not be described as one.** Nothing in
332
- * this kernel or in the `proof` CLI joins an `event_id` into a path: the CLI's
333
- * only join is `join(dirPath, name)` where `name` came from `readdir`, so the
334
- * id is DERIVED from a real directory entry rather than used to reach one.
335
- * Claiming otherwise would misrepresent a hardening as a vulnerability fix.
270
+ * Refused:
271
+ * charset anything outside letters, digits, dot, underscore and dash
272
+ * `.` and `..` path entries, not names
273
+ * trailing dot Windows strips it, so `a.` and `a` collide and one record's
274
+ * bytes would overwrite another's, a mismatch no tamper caused
275
+ * device names `CON`, `PRN`, `AUX`, `NUL`, `COM0-9`, `LPT0-9`, with or
276
+ * without an extension: Windows swallows the write
277
+ * length over 200, leaving room in a 255-byte path component
336
278
  *
337
- * It is an **honesty guard**, and it points one way only. The documented pack
338
- * layout is `logs/<event_id>.json`, so a downstream consumer WILL join these,
339
- * and an id that cannot name a file on the holder's filesystem is an id whose
340
- * bytes will never arrive. Saying so — `unreadable`, and therefore
341
- * `incomplete` — is the truth about that record. Refusing an id can only move
342
- * a verdict toward UNKNOWN, never toward verified, which is what makes
343
- * tightening this safe: the failure mode of a rule that is too strict is a
344
- * conservative verdict, and the failure mode of one that is too lax is a
345
- * record silently graded on bytes that were never there.
346
- *
347
- * ── WHAT IS REFUSED, AND WHY EACH ONE ───────────────────────────────────────
348
- *
349
- * charset — anything outside letters, digits, dot, underscore, dash.
350
- * No separator of any platform survives it.
351
- * `.` and `..` — not names but path entries. Under the documented `.json`
352
- * convention they are harmless (`logs/..json` is a file);
353
- * under a consumer that writes `logs/<event_id>` or uses
354
- * the id as a directory, `..` IS the parent. The kernel
355
- * does not know the convention, so it refuses the two ids
356
- * that are hazardous under any of them.
357
- * trailing dot — Windows strips it, so `a.` and `a` are one file. Two
358
- * distinct records collapsing onto one path means one
359
- * record's bytes overwrite another's, and the verifier then
360
- * reports a MISMATCH — an accusation manufactured by a
361
- * filesystem rather than by a tamper.
362
- * device names — `CON`, `PRN`, `AUX`, `NUL`, `COM0-9`, `LPT0-9`, with or
363
- * without an extension. On Windows `CON.json` is still the
364
- * console device: the write appears to succeed and nothing
365
- * is on disk.
366
- * length — a POSIX path component is 255 bytes. The charset is
367
- * ASCII, so characters are bytes; 200 leaves the documented
368
- * `.json` and room for a prefix a consumer adds, and sits
369
- * far above any real id (a UUID is 36).
370
- *
371
- * ── WHAT IS DELIBERATELY *NOT* REFUSED ──────────────────────────────────────
372
- *
373
- * **Case collisions.** `A` and `a` are distinct ids that are one file on
374
- * macOS and Windows. They stay legal, because banning uppercase would refuse
375
- * ordinary ids for a hazard the kernel cannot see: this module takes a content
376
- * MAP, which is case-sensitive and has already resolved the question. The
377
- * collision happens in the transport, and `accountDisclosure` is explicitly a
378
- * convenience rather than the only way to build an account — a consumer
379
- * reading a DIRECTORY builds its own and is the one that can detect it.
380
- * Pinned as a decision, not left to be rediscovered.
279
+ * Case collisions (`A` and `a`) stay legal: the content map is case-sensitive,
280
+ * and only a consumer reading a directory can see the collision.
381
281
  */
382
282
  const SAFE_EVENT_ID = /^[A-Za-z0-9._-]+$/;
383
283
  const MAX_EVENT_ID_LENGTH = 200;
384
284
  const WINDOWS_DEVICE_NAME = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i;
385
285
 
386
- /** Why this id cannot name a file, or null if it can. Prose, for a surface. */
286
+ /** Why this id cannot name a file, or null if it can. */
387
287
  function unsafeEventIdReason(eventId: string): string | null {
388
288
  if (!SAFE_EVENT_ID.test(eventId)) {
389
289
  return "only letters, digits, dot, underscore and dash are admitted";
@@ -404,33 +304,17 @@ function unsafeEventIdReason(eventId: string): string | null {
404
304
  }
405
305
 
406
306
  /**
407
- * Account for the disclosed bytes a pack handed us against the records its
408
- * bundle presents.
307
+ * Accounts for the disclosed bytes in `content` against the records the bundle
308
+ * presents. `content` is the map `verifyEvidenceBundle` takes; prefer a `Map`,
309
+ * since on a plain object an `event_id` of `__proto__` never lands. A zero-byte
310
+ * entry is present, not missing, so a truncated log cannot read as unknown.
409
311
  *
410
- * `content` is the same map `verifyEvidenceBundle` takes — a plain object or a
411
- * `Map` — so a caller builds it once and uses it for both. Prefer the `Map`:
412
- * an `event_id` is producer-chosen, and on a plain object `__proto__` is not a
413
- * key but a prototype setter, so bytes written under that id never land. A
414
- * ZERO-BYTE entry is present, not missing: bytes were supplied, the check ran,
415
- * and the mismatch the verifier reports stands. A truncated log must not be
416
- * able to launder itself into "unknown".
417
- *
418
- * A CONVENIENCE, NOT THE ONLY WAY TO BUILD AN ACCOUNT — and the difference
419
- * matters for one field. `extra` here can only ever name keys the content map
420
- * carries, so a transport whose surplus is not representable as a map entry
421
- * cannot report it through this function: a pack directory holding
422
- * `logs/notes.txt`, or a second file for a record already accounted for, is
423
- * invisible to a map built by iterating the bundle's own events. A consumer
424
- * reading a DIRECTORY should build `DisclosureAccount` itself from the real
425
- * listing and pass that to `gradePack` — the rule takes an account, never a
426
- * bundle, precisely so the transport can say things the map cannot.
312
+ * `extra` can only name map keys. A consumer reading a directory must build
313
+ * `DisclosureAccount` from the real listing to report surplus files.
427
314
  */
428
315
  export function accountDisclosure(
429
316
  bundle: unknown,
430
- // `SideContentMap`, which is what `hasSideContent` and `sideContentKeys`
431
- // below already take: an entry holding a string that is not hex IS an entry,
432
- // and typing it out of this signature made the one map a caller builds
433
- // unusable here (proof#147). A widening, so no caller moves.
317
+ // Any side-content entry counts, including a string that is not hex.
434
318
  content: SideContentMap,
435
319
  ): DisclosureAccount {
436
320
  const expected: string[] = [];
@@ -447,25 +331,13 @@ export function accountDisclosure(
447
331
  expected.push(eventId);
448
332
  const unsafe = unsafeEventIdReason(eventId);
449
333
  if (unsafe !== null) {
450
- // The prefix is stable so a surface can group these; the suffix says
451
- // which rule refused it, because "not a safe file name" alone leaves a
452
- // holder with no idea what to change.
334
+ // A stable prefix to group on, and a suffix naming the rule that refused it.
453
335
  unreadable.push({ eventId, reason: `event_id is not a safe file name: ${unsafe}` });
454
336
  continue;
455
337
  }
456
- // An OWN entry, NOT `content[eventId] === undefined`. Every key on
457
- // Object.prototype — `constructor`, `toString`, `valueOf`, `__proto__` —
458
- // passes SAFE_EVENT_ID (see the regex above: it admits letters, digits,
459
- // dot, underscore and dash, so every one of them is a legal id here) and
460
- // resolves to an inherited value, so an indexed lookup reported a record as
461
- // PRESENT when nothing at all was disclosed for it. That is the module's
462
- // own founding defect (absent evidence read as good evidence) reappearing
463
- // one layer down: the verdict stayed `incomplete` because the verifier
464
- // still had no bytes to bind, but with ZERO causes — a surface saying
465
- // "incomplete" and unable to say why. `__proto__` is worse still: assigning
466
- // it on a plain object either silently drops the write or replaces the
467
- // object's prototype outright, so bytes a holder genuinely supplied never
468
- // reach the map, and the indexed read then claimed they had.
338
+ // An own entry, not an indexed read: `constructor`, `__proto__` and other
339
+ // Object.prototype keys pass SAFE_EVENT_ID and would read as present when
340
+ // nothing was disclosed.
469
341
  if (!hasSideContent(content, eventId)) missing.push(eventId);
470
342
  else present.push(eventId);
471
343
  }
@@ -477,33 +349,25 @@ export function accountDisclosure(
477
349
  }
478
350
 
479
351
  /**
480
- * A JSON number read by its value: an integer within 2**53 - 1, or null.
481
- * `Number.isSafeInteger`, mirrored by `_js_safe_integer` in
482
- * `src/sanning_proof/chain.py`, so `2.0` is 2 in both kernels and
483
- * `9007199254740993`, which this parser rounds, is unreadable in both.
352
+ * A JSON number read by its value: an integer within 2**53 - 1, or null. So
353
+ * `2.0` is 2, and `9007199254740993`, which the parser rounds, is unreadable.
484
354
  */
485
355
  export function safeInteger(value: unknown): number | null {
486
- // `+ 0` folds -0 into 0, which Python's `int(-0.0)` already is.
356
+ // `+ 0` folds -0 into 0.
487
357
  return typeof value === "number" && Number.isSafeInteger(value) ? value + 0 : null;
488
358
  }
489
359
 
490
- /** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
360
+ /** The `leaf_count` a checkpoint's signed record commits to, or null if unreadable. */
491
361
  export function signedLeafCount(checkpoint: { event_bytes?: unknown } | null | undefined): number | null {
492
362
  const hex = checkpoint?.event_bytes;
493
363
  if (typeof hex !== "string" || hex.length === 0) return null;
494
364
  try {
495
- // `jcsParse`, matching `signed_leaf_count` in `src/sanning_proof/grading.py`.
496
- // The pair used to agree by pointing two LENIENT decoders at one input, and
497
- // that held for invalid UTF-8 and nowhere else: `TextDecoder` EATS a
498
- // leading byte-order mark where Python's `errors="replace"` keeps it, so
499
- // `BOM + JCS(checkpoint)` yielded a signed leaf count here and unreadable
500
- // there, and that count decides a completeness note (proof#140 gate 5).
365
+ // The strict signed-bytes reader: a leading byte-order mark is refused, not
366
+ // eaten, since this count decides a verdict.
501
367
  const record = jcsParse(hexToBytes(hex)) as {
502
368
  event?: { leaf_count?: unknown };
503
369
  };
504
- // Read BY VALUE, the model JCS signs: a record spelling `3.0`, `3e0` or
505
- // `30e-1` commits to 3, and since A38 (proof#197) this count decides a
506
- // verdict, not only a note. Past 2**53 - 1 it is unreadable, as in Python.
370
+ // Read by value, the model JCS signs: `3.0`, `3e0` and `30e-1` all commit to 3.
507
371
  return safeInteger(record?.event?.leaf_count);
508
372
  } catch {
509
373
  return null;
@@ -511,16 +375,10 @@ export function signedLeafCount(checkpoint: { event_bytes?: unknown } | null | u
511
375
  }
512
376
 
513
377
  /**
514
- * Omission — the failure a per-record check structurally cannot see.
515
- *
516
- * Drop records from a pack and every SURVIVOR still verifies perfectly:
517
- * signatures, inclusion proofs, content hashes, all green. The number that
518
- * exposes it is already in the pack and already signed — each checkpoint's
519
- * record commits to the `leaf_count` of the tree it anchored. This compares
520
- * that against how many records the pack presents for that checkpoint.
521
- *
522
- * A checkpoint whose signed count cannot be read is `unreadable`: a
523
- * completeness check we could not run is UNKNOWN, never a pass.
378
+ * Omission, which a per-record check cannot see: drop records and every
379
+ * survivor still verifies. Compares each checkpoint's signed `leaf_count` with
380
+ * the records the pack presents for it. An unreadable count is `unreadable`:
381
+ * a check we could not run is unknown, never a pass.
524
382
  */
525
383
  export function completenessNotes(bundle: unknown): CompletenessNote[] {
526
384
  const events = eventsOf(bundle);
@@ -529,19 +387,13 @@ export function completenessNotes(bundle: unknown): CompletenessNote[] {
529
387
  const key = joinKeyOf(cp);
530
388
  const checkpointRef = key ?? "(no checkpoint_ref)";
531
389
  const signed = signedLeafCount(cp);
532
- // No readable key means we cannot count what was presented against it, so
533
- // completeness is UNKNOWN — the same answer as an unreadable leaf_count,
534
- // and never a shortfall.
390
+ // No readable key: completeness is unknown, never a shortfall.
535
391
  if (signed === null || key === null) {
536
392
  notes.push({ checkpointRef, signed: null, presented: null, kind: "unreadable" });
537
393
  continue;
538
394
  }
539
- // Compared case-folded. The verifier recomputes `checkpoint_ref` and
540
- // accepts it in any case (evidence-bundle.md §5.1 names lowercase hex as
541
- // the form, and the kernel lowercases before comparing), so a pack whose
542
- // checkpoint spells its ref in upper case still verifies. Grading it
543
- // case-sensitively reported every record as omitted: a completeness
544
- // accusation manufactured by letter case. The ref shown is the pack's own.
395
+ // Case-folded, because the verifier accepts `checkpoint_ref` in any case;
396
+ // otherwise an uppercase ref would report every record as omitted.
545
397
  const want = checkpointMatchKey(cp);
546
398
  const presented = events.filter((e) => inclusionMatchKey(e?.inclusion) === want).length;
547
399
  if (presented < signed) notes.push({ checkpointRef, signed, presented, kind: "omitted" });
@@ -550,22 +402,19 @@ export function completenessNotes(bundle: unknown): CompletenessNote[] {
550
402
  return notes;
551
403
  }
552
404
 
553
- // ---- The rule ----------------------------------------------------------------
554
-
555
405
  /**
556
- * Every reason this pack is not `verified`, in the order a reader should hear
557
- * them: what is missing from the whole, then what is missing from the parts.
406
+ * Every reason this pack is not `verified`, in reading order: what the pack
407
+ * claimed, then what is missing from the whole, then from the parts.
558
408
  */
559
409
  export function gradeCauses(
560
410
  account: DisclosureAccount,
561
411
  notes: readonly CompletenessNote[],
562
412
  declaration?: DeclarationCheck | null,
563
413
  withheld: readonly string[] = [],
414
+ events: GradeInput["events"] = [],
564
415
  ): PackGradeCause[] {
565
- // The `event_id`s WITHHELD BY THEIR DECLARATION (rule version 6) are taken
566
- // out of `expected` and `missing` before the disclosure causes are computed,
567
- // so a pack whose every record its declaration withholds earns neither
568
- // `no_disclosure` nor `missing_disclosure`.
416
+ // Records withheld by their declaration are not expected, so a pack that
417
+ // withholds every record earns no disclosure cause.
569
418
  if (withheld.length > 0) {
570
419
  const gone = new Set(withheld);
571
420
  account = {
@@ -576,11 +425,8 @@ export function gradeCauses(
576
425
  }
577
426
  const causes: PackGradeCause[] = [];
578
427
 
579
- // What the pack CLAIMED to hold comes first, because it frames everything
580
- // below it: a reader told "four records are missing from what this pack says
581
- // it contains" reads the per-record findings differently. Every deviation
582
- // gets its own cause (one defect, one line), so a pack that deviates in
583
- // four ways can never be reported as one vague shortfall.
428
+ // Declaration deviations first, since they frame the rest. One cause per
429
+ // deviation, so several are never reported as one vague shortfall.
584
430
  for (const deviation of declaration?.deviations ?? []) {
585
431
  causes.push({
586
432
  kind: "declaration_deviation",
@@ -590,9 +436,8 @@ export function gradeCauses(
590
436
  });
591
437
  }
592
438
 
593
- // Then what the pack and the UNSIGNED content beside it do not agree on
594
- // (proof#147). A cause, so the pack cannot read clean over it; NOT a
595
- // deviation, so it never reaches `failed`.
439
+ // A mismatch with unsigned side content: a cause, so the pack cannot read
440
+ // clean, but not a deviation, so it never reaches `failed`.
596
441
  for (const finding of declaration?.contentFindings ?? []) {
597
442
  causes.push({
598
443
  kind: "declaration_content_dependent",
@@ -602,6 +447,26 @@ export function gradeCauses(
602
447
  });
603
448
  }
604
449
 
450
+ // Then every reason of each agent record that does not read verified (agent-events.md
451
+ // section 10). A reason that reads incomplete is `agent_record_incomplete`; every other
452
+ // reason is `agent_record_nonconforming`. A finding is never a cause: it is reported
453
+ // beside the record and moves nothing.
454
+ for (const e of events) {
455
+ const verdict = e.agent?.verdict;
456
+ if (verdict !== "failed" && verdict !== "incomplete") continue;
457
+ const absent = [...(e.agent?.absent ?? [])];
458
+ for (const r of e.agent?.reasons ?? []) {
459
+ const listed = r.reason === "listed_record_absent" ? absent.shift() : undefined;
460
+ causes.push({
461
+ kind: INCOMPLETE_REASONS.has(r.reason) ? "agent_record_incomplete" : "agent_record_nonconforming",
462
+ ...(e.eventId === undefined ? {} : { firstEventId: e.eventId }),
463
+ reason: r.reason,
464
+ at: r.at,
465
+ ...(listed === undefined ? {} : { listedEventId: listed }),
466
+ });
467
+ }
468
+ }
469
+
605
470
  for (const note of notes) {
606
471
  if (note.kind === "omitted") {
607
472
  causes.push({
@@ -624,10 +489,7 @@ export function gradeCauses(
624
489
  }
625
490
  }
626
491
 
627
- // "Nothing at all was disclosed" is reported once, as itself. It is the
628
- // regression that rendered a green banner over zero hashed bytes, and a
629
- // reader deserves to be told the pack disclosed nothing rather than counting
630
- // N absences.
492
+ // A pack that disclosed nothing is told as that, once, rather than as N absences.
631
493
  if (account.expected.length > 0 && account.present.length === 0 && account.unreadable.length === 0) {
632
494
  causes.push({ kind: "no_disclosure", count: account.expected.length, of: account.expected.length });
633
495
  } else if (account.missing.length > 0) {
@@ -655,90 +517,34 @@ export function gradeCauses(
655
517
  }
656
518
 
657
519
  /**
658
- * The kernel headline this rule is willing to repeat, as an ALLOW-LIST.
520
+ * The verdict a surface may print, and the counts beside it.
659
521
  *
660
- * `GradeInput.status` is deliberately `string` so a surface can grade a result
661
- * from a verifier this kernel did not write. The cost of that was a hole: the
662
- * status used to be cast straight to `PackVerdict`, so `gradePack` would hand
663
- * back whatever it was given — `"mixed"` and `"not_found"` (legal, if
664
- * unexpected), but equally `"banana"`, `""` and `"SAFE"`. A surface that trusts
665
- * the type and renders the verdict verbatim would then print the one word
666
- * `evidence-bundle.md` §6 forbids, over a pack this rule never graded.
667
- *
668
- * So: the five words of the §3 vocabulary that a bundle status can pass up to
669
- * a pack pass through, and ANYTHING ELSE is
670
- * `incomplete`. A verdict we cannot interpret is a check we could not run, and
671
- * the answer to that is UNKNOWN — never a pass, and never an accusation.
672
- * (`failed` and `malformed` are handled before this is reached.)
673
- */
674
- const PASS_THROUGH: ReadonlySet<string> = new Set([
675
- "verified",
676
- "partial",
677
- "pending",
678
- "not_found",
679
- "mixed",
680
- ]);
681
-
682
- function passThrough(status: string | null | undefined): PackVerdict {
683
- return typeof status === "string" && PASS_THROUGH.has(status)
684
- ? (status as PackVerdict)
685
- : "incomplete";
686
- }
687
-
688
- /**
689
- * The verdict a surface may print, and the counts it may print beside it.
690
- *
691
- * The invariant, stated so a mutation test can kill it: `verified` is reachable
692
- * ONLY when every record's supplied bytes bound to its own commitment, or
693
- * (rule version 6) the record is withheld by its declaration, and nothing is
522
+ * Invariant: `verified` only when every record's bytes bound to its own
523
+ * commitment or the record is withheld by its declaration, and nothing is
694
524
  * missing, unreadable, surplus or omitted.
695
525
  *
696
- * Precedence: malformed > failed > incomplete > the kernel's own headline.
697
- * A hard verdict wins — "the bytes are absent" is never reported as failure
698
- * (§5.1: undetermined, not failed), and a proven mismatch is never softened
699
- * into absence. The last branch passes the kernel's headline through unchanged:
700
- * this rule DOWNGRADES a verdict, it never invents one.
701
- *
702
- * ── the declaration (optional fourth argument) ──────────────────────────────
703
- *
704
- * `declaration` is what `checkDeclaration` in `./declaration.ts` made of the
705
- * pack's own signed statement of what it holds (`evidence-bundle.md` §5.4). A
706
- * deviation from it is a PROVEN mismatch between what the pack claimed and
707
- * what it carries, so it FAILS the pack and each deviation becomes its own
708
- * cause. It must never soften into absence: that is how a trimmed hand-over
709
- * reads as an innocent gap.
526
+ * Every problem (the bundle's, each cause, the declaration's) maps to one claim
527
+ * and one state (`claims.ts`) and the verdict is read off the claims (rule
528
+ * version 9): `failed` where a claim is disproven, else `incomplete` where one
529
+ * is not proven, else `verified`. Absent bytes are never a disproof (section
530
+ * 5.1), and a proven mismatch is never softened into absence. An agent record's
531
+ * findings move nothing.
710
532
  *
711
- * A check that came back `partial` splits in THREE (proof#147), and the first
712
- * of the three is not about the declaration's readability at all. The other
713
- * two split on whether the body carried a `declaration` member.
714
- *
715
- * content-dependent the pack and the UNSIGNED content beside it do not
716
- * line up (proof#147). Each finding is its own cause
717
- * and the pack is `incomplete`. It must never reach
718
- * `failed`: whoever assembles a case fills in the
719
- * side-content map, so an accuser holding an intact
720
- * pack could otherwise put its holder into `failed`
721
- * with no key at all, by dropping one file or adding
722
- * one. `failed` is left to what a signature covers.
723
- * no declaration at all nothing moves. Every pack built before §5.4 is
724
- * this case, and re-grading them would move pinned
725
- * exit codes for artifacts already in customers'
726
- * hands.
727
- * present and unreadable the pack is `incomplete`. `declaration_version` is
728
- * chosen by the same party that assembles the pack
729
- * and has no accept-set, so a holder who trims a
730
- * record and then writes one unreadable field can
731
- * re-seal the pack with its own key and hand a
732
- * reader a clean verdict over evidence of the trim
733
- * sitting inside the signed body. UNKNOWN is the
734
- * only honest answer to a claim the reader cannot
735
- * read, and it adds no cause: `PackGrade.declaration`
736
- * says why.
737
- *
738
- * `verified` adds nothing and moves nothing.
739
- *
740
- * Omitting the argument entirely is the pre-§5.4 behavior, unchanged, and
741
- * `PackGrade.declaration` is then absent.
533
+ * `declaration` is `checkDeclaration`'s result for the pack's signed statement
534
+ * of what it holds (§5.4):
535
+ * deviation a proven mismatch: the pack fails, one cause each
536
+ * content-dependent the pack and its unsigned side content disagree:
537
+ * `incomplete`, never `failed`, since whoever builds
538
+ * the side-content map could otherwise fail an intact
539
+ * pack without a key
540
+ * no declaration nothing moves
541
+ * present and unreadable `incomplete`, with no cause and one problem,
542
+ * `pack_declaration_unreadable` (or
543
+ * `declaration_not_checked` for a chain claim this
544
+ * call was given no reading for): the pack's holder
545
+ * picks `declaration_version`, so an unreadable one
546
+ * could otherwise hide a trimmed record
547
+ * Omitting the argument leaves `PackGrade.declaration` absent.
742
548
  */
743
549
  export function gradePack(
744
550
  result: GradeInput,
@@ -749,9 +555,8 @@ export function gradePack(
749
555
  const events = Array.isArray(result?.events) ? result.events : [];
750
556
  const recordsVerified = events.filter((e) => e.ok && e.contentOk === true).length;
751
557
  const recordsFailed = events.filter((e) => e.ok === false || e.contentOk === false).length;
752
- // WITHHELD BY ITS DECLARATION (rule version 6, 5.4.5 v1.20, A22, D5). All
753
- // four conditions, each on its own. `declaredWithheld` is empty for an
754
- // absent, unreadable or malformed declaration.
558
+ // Withheld by its declaration (§5.4.5) only when all four conditions hold.
559
+ // `declaredWithheld` is empty for an absent, unreadable or malformed declaration.
755
560
  const declaredWithheld = new Set(declaration?.declaredWithheld ?? []);
756
561
  const withheldEventIds = events
757
562
  .filter(
@@ -765,19 +570,21 @@ export function gradePack(
765
570
  .map((e) => e.eventId as string);
766
571
  const recordsWithheld = withheldEventIds.length;
767
572
  const recordsUndetermined = events.length - recordsVerified - recordsFailed - recordsWithheld;
768
- const causes = gradeCauses(account, notes, declaration, withheldEventIds);
573
+ const causes = gradeCauses(account, notes, declaration, withheldEventIds, events);
769
574
 
770
- let verdict: PackVerdict;
771
- if (result?.status === "malformed") verdict = "malformed";
772
- else if (result?.status === "failed" || recordsFailed > 0) verdict = "failed";
773
- else if (declaration?.verdict === "failed") verdict = "failed";
774
- else if (recordsUndetermined > 0 || causes.length > 0) verdict = "incomplete";
775
- else if (declaration?.present === true && declaration.verdict === "partial")
776
- verdict = "incomplete";
777
- else verdict = passThrough(result?.status);
575
+ const problems = packProblems(result, events, account, causes, declaration, withheldEventIds);
576
+ if (recordsUndetermined > 0 && !problems.some((p) => p.claim === "unchanged")) {
577
+ // Fail closed: a record whose bytes were not bound and that no cause names still
578
+ // holds the pack below verified.
579
+ problems.push(problem("records_not_bound", [[0, null]]));
580
+ }
581
+ const outcome = verdictOf(claimsOf(problems));
582
+ const verdict = outcome.status;
778
583
 
779
584
  const grade: PackGrade = {
780
585
  verdict,
586
+ ...(outcome.reason_kind === undefined ? {} : { reasonKind: outcome.reason_kind }),
587
+ problems,
781
588
  recordsVerified,
782
589
  recordsFailed,
783
590
  recordsUndetermined,
@@ -789,41 +596,100 @@ export function gradePack(
789
596
  return grade;
790
597
  }
791
598
 
792
- // ---- Presentation (optional) -------------------------------------------------
599
+ /**
600
+ * Every problem of one pack, read on its own (pack position 0): the bundle's, then each
601
+ * cause's, then the declaration's.
602
+ */
603
+ function packProblems(
604
+ result: GradeInput,
605
+ events: GradeInput["events"],
606
+ account: DisclosureAccount,
607
+ causes: readonly PackGradeCause[],
608
+ declaration: DeclarationCheck | null | undefined,
609
+ withheld: readonly string[],
610
+ ): Problem[] {
611
+ const out = bundleProblems(result, 0);
612
+ const ids = events.map((e) => e.eventId ?? null);
613
+ const records = (...eventIds: (string | null | undefined)[]): ProblemItem[] => {
614
+ const wanted = eventIds.map((e) => e ?? null);
615
+ const found: ProblemItem[] = [];
616
+ ids.forEach((e, i) => {
617
+ if (wanted.includes(e)) found.push([0, i]);
618
+ });
619
+ return found.length > 0 ? found : [[0, null]];
620
+ };
621
+ const gone = new Set(withheld);
622
+ for (const c of causes) {
623
+ if (c.kind === "agent_record_nonconforming" || c.kind === "agent_record_incomplete") {
624
+ // A record the closing record lists and the pack does not hold is the item, named by
625
+ // its own event_id, so the line counts the records that are not held.
626
+ out.push(
627
+ problem(c.kind, c.listedEventId !== undefined ? [[0, null, c.listedEventId]] : records(c.firstEventId), {
628
+ rule: c.reason ?? null,
629
+ }),
630
+ );
631
+ } else if (c.kind === "records_omitted") {
632
+ out.push(problem(c.kind, [[0, null]], { count: c.count, of: c.signed, presented: c.presented }));
633
+ } else if (c.kind === "no_disclosure") {
634
+ out.push(problem(c.kind, records(...account.expected), { count: c.count }));
635
+ } else if (c.kind === "missing_disclosure") {
636
+ const missing = account.missing.filter((e) => !gone.has(e));
637
+ out.push(problem(c.kind, records(...missing), { count: c.count, of: c.of }));
638
+ } else if (c.kind === "unreadable_disclosure" || c.kind === "declaration_content_dependent") {
639
+ out.push(problem(c.kind, records(c.firstEventId)));
640
+ } else if (c.kind === "unaccounted_disclosure") {
641
+ out.push(problem(c.kind, [[0, null]], { count: c.count }));
642
+ } else if (c.kind === "declaration_deviation" && c.deviation === "declaration_malformed") {
643
+ // A declaration in a version this reader knows, signed with the pack, that does not
644
+ // parse: the producer broke its own format, and nothing was proven altered.
645
+ out.push(problem("declaration_nonconforming", [[0, null]]));
646
+ } else {
647
+ out.push(problem(c.kind, [[0, null]]));
648
+ }
649
+ }
650
+ if (declaration != null && declaration.present) {
651
+ if (declaration.verdict === "failed" && declaration.deviations.length === 0) {
652
+ out.push(problem("pack_declaration_deviates", [[0, null]]));
653
+ } else if (declaration.verdict === "incomplete" && declaration.contentFindings.length === 0) {
654
+ out.push(
655
+ problem(
656
+ declaration.version == null ? "pack_declaration_unreadable" : "declaration_not_checked",
657
+ [[0, null]],
658
+ ),
659
+ );
660
+ }
661
+ }
662
+ return out;
663
+ }
793
664
 
794
665
  /**
795
- * The house wording for one cause.
796
- *
797
- * Offered, not imposed: a surface with its own voice reads the fields on
798
- * `PackGradeCause` instead. What must not vary between surfaces is the verdict
799
- * and the counts, and those come from `gradePack`.
800
- *
801
- * The `subject` names what the pack is from the reader's position — "this pack"
802
- * on a page someone dropped a file onto, "the pack" in a CLI report.
666
+ * The house wording for one cause. Optional: a surface may read the fields
667
+ * instead; the verdict and counts are what must not vary. `subject` is "this
668
+ * pack" on a page and "the pack" in a CLI report.
803
669
  */
804
670
  export function renderCause(cause: PackGradeCause, subject = "this pack"): string {
805
671
  switch (cause.kind) {
806
672
  case "records_omitted":
807
673
  return (
808
674
  `checkpoint ${cause.checkpointRef} commits to ${cause.signed} record(s); ${subject} presents ` +
809
- `${cause.presented} — ${cause.count} record(s) OMITTED`
675
+ `${cause.presented}: ${cause.count} record(s) omitted`
810
676
  );
811
677
  case "records_surplus":
812
678
  return (
813
679
  `checkpoint ${cause.checkpointRef} commits to ${cause.signed} record(s); ${subject} presents ` +
814
- `${cause.presented} — more records than the signed tree accounts for`
680
+ `${cause.presented}; more records than the signed tree accounts for`
815
681
  );
816
682
  case "leaf_count_unreadable":
817
- return `checkpoint ${cause.checkpointRef} has no readable signed leaf_count — completeness is UNKNOWN`;
683
+ return `checkpoint ${cause.checkpointRef} has no readable signed leaf_count; completeness is unknown`;
818
684
  case "no_disclosure":
819
685
  return (
820
- `no event bytes were disclosed — content binding is UNKNOWN for all ${cause.count} ` +
686
+ `no event bytes were disclosed; content binding is unknown for all ${cause.count} ` +
821
687
  `record(s), not verified`
822
688
  );
823
689
  case "missing_disclosure":
824
690
  return (
825
- `${cause.count} of ${cause.of} record(s) disclose no bytes (first: ${cause.firstEventId}) — ` +
826
- `content binding is UNKNOWN, not verified`
691
+ `${cause.count} of ${cause.of} record(s) disclose no bytes (first: ${cause.firstEventId}); ` +
692
+ `content binding is unknown, not verified`
827
693
  );
828
694
  case "unreadable_disclosure":
829
695
  return `the disclosure for ${cause.firstEventId} is unreadable: ${cause.reason}`;
@@ -832,16 +698,16 @@ export function renderCause(cause: PackGradeCause, subject = "this pack"): strin
832
698
  `${cause.count} disclosed file(s) are not accounted for by any record in this bundle ` +
833
699
  `(first: ${cause.firstEventId})`
834
700
  );
835
- // Rendered once, by `renderDeviation` in `./declaration.ts`, and carried
836
- // here on `reason`. Re-deriving the sentence from the cause fields would
837
- // put the declaration's wording in two places, and one of the two would be
838
- // edited alone.
701
+ // Worded once, by `renderDeviation`, and carried on `reason`.
702
+ case "agent_record_nonconforming":
703
+ return `the agent record ${cause.firstEventId} does not conform to its vocabulary: ${cause.reason} at ${cause.at}`;
704
+ case "agent_record_incomplete":
705
+ return `the agent record ${cause.firstEventId} could not be fully read: ${cause.reason} at ${cause.at}`;
839
706
  case "declaration_deviation":
840
707
  return `this pack deviates from its own declaration: ${cause.reason}`;
841
708
  case "declaration_content_dependent":
842
- // Named as what the reader could not establish, never as what the
843
- // holder did: the bytes it turns on are unsigned and whoever assembled
844
- // the case chose them (proof#147).
709
+ // Worded as what the reader could not establish, never as what the
710
+ // holder did: the side content is unsigned.
845
711
  return (
846
712
  `${subject} and the unsigned content handed in beside it do not line up: ` +
847
713
  `${cause.reason}`