@sanning/proof 0.9.2 → 0.11.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.
package/src/grading.ts CHANGED
@@ -68,7 +68,18 @@
68
68
  // implementation is wrong.
69
69
 
70
70
  import { hexToBytes } from "./crypto.js";
71
- import { hasSideContent, sideContentKeys } from "./side-content.js";
71
+ import type {
72
+ DeclarationCheck,
73
+ DeclarationContentKind,
74
+ DeclarationDeviationKind,
75
+ DeclarationVerdict,
76
+ } from "./declaration.js";
77
+ import { renderContentFinding, renderDeviation } from "./declaration.js";
78
+ 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).
82
+ import { jcsParse } from "./verifier.js";
72
83
 
73
84
  // ---- The verdict a surface may print ----------------------------------------
74
85
 
@@ -140,7 +151,14 @@ export type PackGradeCauseKind =
140
151
  | "no-disclosure"
141
152
  | "missing-disclosure"
142
153
  | "unreadable-disclosure"
143
- | "unaccounted-disclosure";
154
+ | "unaccounted-disclosure"
155
+ | "declaration-deviation"
156
+ /**
157
+ * The pack and the UNSIGNED content handed in beside it do not line up
158
+ * (proof#147). A cause, so the pack cannot read clean over it and a reader
159
+ * is told which record; NOT a deviation, so it never reaches `failed`.
160
+ */
161
+ | "declaration-content-dependent";
144
162
 
145
163
  /**
146
164
  * One reason a pack is not `verified`, as data.
@@ -166,6 +184,12 @@ export interface PackGradeCause {
166
184
  firstEventId?: string;
167
185
  /** Why one supplied disclosure could not be used. */
168
186
  reason?: string;
187
+ /**
188
+ * Which deviation from the pack's own declaration, for
189
+ * `declaration-deviation`, or which content-dependent finding, for
190
+ * `declaration-content-dependent`.
191
+ */
192
+ deviation?: DeclarationDeviationKind | DeclarationContentKind;
169
193
  }
170
194
 
171
195
  export interface PackGrade {
@@ -175,6 +199,12 @@ export interface PackGrade {
175
199
  recordsFailed: number;
176
200
  recordsUndetermined: number;
177
201
  causes: PackGradeCause[];
202
+ /**
203
+ * The declaration check's own verdict, carried so a consumer that reads only
204
+ * this value can tell a declared pack from an undeclared one. Absent when no
205
+ * check was passed, which is the pre-§5.4 call.
206
+ */
207
+ declaration?: DeclarationVerdict;
178
208
  }
179
209
 
180
210
  // ---- Reading a bundle --------------------------------------------------------
@@ -185,7 +215,7 @@ interface BundleLike {
185
215
  envelope?: { event_id?: unknown };
186
216
  inclusion?: { checkpoint_ref?: unknown; checkpoint_tx_id?: unknown };
187
217
  }[];
188
- checkpoints?: { checkpoint_ref?: unknown; tx_id?: unknown; record_bytes?: unknown }[];
218
+ checkpoints?: { checkpoint_ref?: unknown; tx_id?: unknown; event_bytes?: unknown }[];
189
219
  };
190
220
  }
191
221
 
@@ -225,6 +255,22 @@ function inclusionKeyOf(
225
255
  return (hasV2 ? incl.checkpoint_ref : incl.checkpoint_tx_id) as string;
226
256
  }
227
257
 
258
+ // The key a checkpoint and an inclusion are MATCHED on. A `checkpoint_ref` is
259
+ // folded to lower case, because the verifier lowercases it before comparing.
260
+ // A retired `tx_id` / `checkpoint_tx_id` is compared exactly as written, as the
261
+ // retired reader did, even when it happens to look like hex.
262
+ function checkpointMatchKey(o: { checkpoint_ref?: unknown; tx_id?: unknown } | null | undefined): string | null {
263
+ const k = joinKeyOf(o);
264
+ return k !== null && typeof o?.checkpoint_ref === "string" ? k.toLowerCase() : k;
265
+ }
266
+
267
+ function inclusionMatchKey(
268
+ incl: { checkpoint_ref?: unknown; checkpoint_tx_id?: unknown } | null | undefined,
269
+ ): string | null {
270
+ const k = inclusionKeyOf(incl);
271
+ return k !== null && typeof incl?.checkpoint_ref === "string" ? k.toLowerCase() : k;
272
+ }
273
+
228
274
  const eventsOf = (bundle: unknown) => {
229
275
  const e = (bundle as BundleLike | null)?.body?.events;
230
276
  return Array.isArray(e) ? e : [];
@@ -356,7 +402,11 @@ function unsafeEventIdReason(eventId: string): string | null {
356
402
  */
357
403
  export function accountDisclosure(
358
404
  bundle: unknown,
359
- content: Readonly<Record<string, Uint8Array>> | ReadonlyMap<string, Uint8Array>,
405
+ // `SideContentMap`, which is what `hasSideContent` and `sideContentKeys`
406
+ // below already take: an entry holding a string that is not hex IS an entry,
407
+ // and typing it out of this signature made the one map a caller builds
408
+ // unusable here (proof#147). A widening, so no caller moves.
409
+ content: SideContentMap,
360
410
  ): DisclosureAccount {
361
411
  const expected: string[] = [];
362
412
  const present: string[] = [];
@@ -402,11 +452,17 @@ export function accountDisclosure(
402
452
  }
403
453
 
404
454
  /** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
405
- export function signedLeafCount(checkpoint: { record_bytes?: unknown } | null | undefined): number | null {
406
- const hex = checkpoint?.record_bytes;
455
+ export function signedLeafCount(checkpoint: { event_bytes?: unknown } | null | undefined): number | null {
456
+ const hex = checkpoint?.event_bytes;
407
457
  if (typeof hex !== "string" || hex.length === 0) return null;
408
458
  try {
409
- const record = JSON.parse(new TextDecoder().decode(hexToBytes(hex))) as {
459
+ // `jcsParse`, matching `signed_leaf_count` in `src/sanning_proof/grading.py`.
460
+ // The pair used to agree by pointing two LENIENT decoders at one input, and
461
+ // that held for invalid UTF-8 and nowhere else: `TextDecoder` EATS a
462
+ // leading byte-order mark where Python's `errors="replace"` keeps it, so
463
+ // `BOM + JCS(checkpoint)` yielded a signed leaf count here and unreadable
464
+ // there, and that count decides a completeness note (proof#140 gate 5).
465
+ const record = jcsParse(hexToBytes(hex)) as {
410
466
  event?: { leaf_count?: unknown };
411
467
  };
412
468
  const n = record?.event?.leaf_count;
@@ -442,7 +498,14 @@ export function completenessNotes(bundle: unknown): CompletenessNote[] {
442
498
  notes.push({ checkpointRef, signed: null, presented: null, kind: "unreadable" });
443
499
  continue;
444
500
  }
445
- const presented = events.filter((e) => inclusionKeyOf(e?.inclusion) === key).length;
501
+ // Compared case-folded. The verifier recomputes `checkpoint_ref` and
502
+ // accepts it in any case (evidence-bundle.md §5.1 names lowercase hex as
503
+ // the form, and the kernel lowercases before comparing), so a pack whose
504
+ // checkpoint spells its ref in upper case still verifies. Grading it
505
+ // case-sensitively reported every record as omitted: a completeness
506
+ // accusation manufactured by letter case. The ref shown is the pack's own.
507
+ const want = checkpointMatchKey(cp);
508
+ const presented = events.filter((e) => inclusionMatchKey(e?.inclusion) === want).length;
446
509
  if (presented < signed) notes.push({ checkpointRef, signed, presented, kind: "omitted" });
447
510
  else if (presented > signed) notes.push({ checkpointRef, signed, presented, kind: "surplus" });
448
511
  }
@@ -458,9 +521,36 @@ export function completenessNotes(bundle: unknown): CompletenessNote[] {
458
521
  export function gradeCauses(
459
522
  account: DisclosureAccount,
460
523
  notes: readonly CompletenessNote[],
524
+ declaration?: DeclarationCheck | null,
461
525
  ): PackGradeCause[] {
462
526
  const causes: PackGradeCause[] = [];
463
527
 
528
+ // What the pack CLAIMED to hold comes first, because it frames everything
529
+ // below it: a reader told "four records are missing from what this pack says
530
+ // it contains" reads the per-record findings differently. Every deviation
531
+ // gets its own cause (one defect, one line), so a pack that deviates in
532
+ // four ways can never be reported as one vague shortfall.
533
+ for (const deviation of declaration?.deviations ?? []) {
534
+ causes.push({
535
+ kind: "declaration-deviation",
536
+ deviation: deviation.kind,
537
+ ...(deviation.eventId === undefined ? {} : { firstEventId: deviation.eventId }),
538
+ reason: renderDeviation(deviation),
539
+ });
540
+ }
541
+
542
+ // Then what the pack and the UNSIGNED content beside it do not agree on
543
+ // (proof#147). A cause, so the pack cannot read clean over it; NOT a
544
+ // deviation, so it never reaches `failed`.
545
+ for (const finding of declaration?.contentFindings ?? []) {
546
+ causes.push({
547
+ kind: "declaration-content-dependent",
548
+ deviation: finding.kind,
549
+ ...(finding.eventId === undefined ? {} : { firstEventId: finding.eventId }),
550
+ reason: renderContentFinding(finding),
551
+ });
552
+ }
553
+
464
554
  for (const note of notes) {
465
555
  if (note.kind === "omitted") {
466
556
  causes.push({
@@ -555,25 +645,78 @@ function passThrough(status: string | null | undefined): PackVerdict {
555
645
  * (§5.1: undetermined, not failed), and a proven mismatch is never softened
556
646
  * into absence. The last branch passes the kernel's headline through unchanged:
557
647
  * this rule DOWNGRADES a verdict, it never invents one.
648
+ *
649
+ * ── the declaration (optional fourth argument) ──────────────────────────────
650
+ *
651
+ * `declaration` is what `checkDeclaration` in `./declaration.ts` made of the
652
+ * pack's own signed statement of what it holds (`evidence-bundle.md` §5.4). A
653
+ * deviation from it is a PROVEN mismatch between what the pack claimed and
654
+ * what it carries, so it FAILS the pack and each deviation becomes its own
655
+ * cause. It must never soften into absence: that is how a trimmed hand-over
656
+ * reads as an innocent gap.
657
+ *
658
+ * A check that came back `partial` splits in THREE (proof#147), and the first
659
+ * of the three is not about the declaration's readability at all. The other
660
+ * two split on whether the body carried a `declaration` member.
661
+ *
662
+ * content-dependent the pack and the UNSIGNED content beside it do not
663
+ * line up (proof#147). Each finding is its own cause
664
+ * and the pack is `incomplete`. It must never reach
665
+ * `failed`: whoever assembles a case fills in the
666
+ * side-content map, so an accuser holding an intact
667
+ * pack could otherwise put its holder into `failed`
668
+ * with no key at all, by dropping one file or adding
669
+ * one. `failed` is left to what a signature covers.
670
+ * no declaration at all nothing moves. Every pack built before §5.4 is
671
+ * this case, and re-grading them would move pinned
672
+ * exit codes for artifacts already in customers'
673
+ * hands.
674
+ * present and unreadable the pack is `incomplete`. `declaration_version` is
675
+ * chosen by the same party that assembles the pack
676
+ * and has no accept-set, so a holder who trims a
677
+ * record and then writes one unreadable field can
678
+ * re-seal the pack with its own key and hand a
679
+ * reader a clean verdict over evidence of the trim
680
+ * sitting inside the signed body. UNKNOWN is the
681
+ * only honest answer to a claim the reader cannot
682
+ * read, and it adds no cause: `PackGrade.declaration`
683
+ * says why.
684
+ *
685
+ * `verified` adds nothing and moves nothing.
686
+ *
687
+ * Omitting the argument entirely is the pre-§5.4 behavior, unchanged, and
688
+ * `PackGrade.declaration` is then absent.
558
689
  */
559
690
  export function gradePack(
560
691
  result: GradeInput,
561
692
  account: DisclosureAccount,
562
693
  notes: readonly CompletenessNote[],
694
+ declaration?: DeclarationCheck | null,
563
695
  ): PackGrade {
564
696
  const events = Array.isArray(result?.events) ? result.events : [];
565
697
  const recordsVerified = events.filter((e) => e.ok && e.contentOk === true).length;
566
698
  const recordsFailed = events.filter((e) => e.ok === false || e.contentOk === false).length;
567
699
  const recordsUndetermined = events.length - recordsVerified - recordsFailed;
568
- const causes = gradeCauses(account, notes);
700
+ const causes = gradeCauses(account, notes, declaration);
569
701
 
570
702
  let verdict: PackVerdict;
571
703
  if (result?.status === "malformed") verdict = "malformed";
572
704
  else if (result?.status === "failed" || recordsFailed > 0) verdict = "failed";
705
+ else if (declaration?.verdict === "failed") verdict = "failed";
573
706
  else if (recordsUndetermined > 0 || causes.length > 0) verdict = "incomplete";
707
+ else if (declaration?.present === true && declaration.verdict === "partial")
708
+ verdict = "incomplete";
574
709
  else verdict = passThrough(result?.status);
575
710
 
576
- return { verdict, recordsVerified, recordsFailed, recordsUndetermined, causes };
711
+ const grade: PackGrade = {
712
+ verdict,
713
+ recordsVerified,
714
+ recordsFailed,
715
+ recordsUndetermined,
716
+ causes,
717
+ };
718
+ if (declaration != null) grade.declaration = declaration.verdict;
719
+ return grade;
577
720
  }
578
721
 
579
722
  // ---- Presentation (optional) -------------------------------------------------
@@ -604,7 +747,7 @@ export function renderCause(cause: PackGradeCause, subject = "this pack"): strin
604
747
  return `checkpoint ${cause.checkpointRef} has no readable signed leaf_count — completeness is UNKNOWN`;
605
748
  case "no-disclosure":
606
749
  return (
607
- `no record bytes were disclosed — content binding is UNKNOWN for all ${cause.count} ` +
750
+ `no event bytes were disclosed — content binding is UNKNOWN for all ${cause.count} ` +
608
751
  `record(s), not verified`
609
752
  );
610
753
  case "missing-disclosure":
@@ -619,5 +762,19 @@ export function renderCause(cause: PackGradeCause, subject = "this pack"): strin
619
762
  `${cause.count} disclosed file(s) are not accounted for by any record in this bundle ` +
620
763
  `(first: ${cause.firstEventId})`
621
764
  );
765
+ // Rendered once, by `renderDeviation` in `./declaration.ts`, and carried
766
+ // here on `reason`. Re-deriving the sentence from the cause fields would
767
+ // put the declaration's wording in two places, and one of the two would be
768
+ // edited alone.
769
+ case "declaration-deviation":
770
+ return `this pack deviates from its own declaration: ${cause.reason}`;
771
+ case "declaration-content-dependent":
772
+ // Named as what the reader could not establish, never as what the
773
+ // holder did: the bytes it turns on are unsigned and whoever assembled
774
+ // the case chose them (proof#147).
775
+ return (
776
+ `${subject} and the unsigned content handed in beside it do not line up: ` +
777
+ `${cause.reason}`
778
+ );
622
779
  }
623
780
  }