@sanning/proof 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 --------------------------------------------------------
@@ -372,7 +402,11 @@ function unsafeEventIdReason(eventId: string): string | null {
372
402
  */
373
403
  export function accountDisclosure(
374
404
  bundle: unknown,
375
- 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,
376
410
  ): DisclosureAccount {
377
411
  const expected: string[] = [];
378
412
  const present: string[] = [];
@@ -422,7 +456,13 @@ export function signedLeafCount(checkpoint: { event_bytes?: unknown } | null | u
422
456
  const hex = checkpoint?.event_bytes;
423
457
  if (typeof hex !== "string" || hex.length === 0) return null;
424
458
  try {
425
- 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 {
426
466
  event?: { leaf_count?: unknown };
427
467
  };
428
468
  const n = record?.event?.leaf_count;
@@ -481,9 +521,36 @@ export function completenessNotes(bundle: unknown): CompletenessNote[] {
481
521
  export function gradeCauses(
482
522
  account: DisclosureAccount,
483
523
  notes: readonly CompletenessNote[],
524
+ declaration?: DeclarationCheck | null,
484
525
  ): PackGradeCause[] {
485
526
  const causes: PackGradeCause[] = [];
486
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
+
487
554
  for (const note of notes) {
488
555
  if (note.kind === "omitted") {
489
556
  causes.push({
@@ -578,25 +645,78 @@ function passThrough(status: string | null | undefined): PackVerdict {
578
645
  * (§5.1: undetermined, not failed), and a proven mismatch is never softened
579
646
  * into absence. The last branch passes the kernel's headline through unchanged:
580
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.
581
689
  */
582
690
  export function gradePack(
583
691
  result: GradeInput,
584
692
  account: DisclosureAccount,
585
693
  notes: readonly CompletenessNote[],
694
+ declaration?: DeclarationCheck | null,
586
695
  ): PackGrade {
587
696
  const events = Array.isArray(result?.events) ? result.events : [];
588
697
  const recordsVerified = events.filter((e) => e.ok && e.contentOk === true).length;
589
698
  const recordsFailed = events.filter((e) => e.ok === false || e.contentOk === false).length;
590
699
  const recordsUndetermined = events.length - recordsVerified - recordsFailed;
591
- const causes = gradeCauses(account, notes);
700
+ const causes = gradeCauses(account, notes, declaration);
592
701
 
593
702
  let verdict: PackVerdict;
594
703
  if (result?.status === "malformed") verdict = "malformed";
595
704
  else if (result?.status === "failed" || recordsFailed > 0) verdict = "failed";
705
+ else if (declaration?.verdict === "failed") verdict = "failed";
596
706
  else if (recordsUndetermined > 0 || causes.length > 0) verdict = "incomplete";
707
+ else if (declaration?.present === true && declaration.verdict === "partial")
708
+ verdict = "incomplete";
597
709
  else verdict = passThrough(result?.status);
598
710
 
599
- 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;
600
720
  }
601
721
 
602
722
  // ---- Presentation (optional) -------------------------------------------------
@@ -642,5 +762,19 @@ export function renderCause(cause: PackGradeCause, subject = "this pack"): strin
642
762
  `${cause.count} disclosed file(s) are not accounted for by any record in this bundle ` +
643
763
  `(first: ${cause.firstEventId})`
644
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
+ );
645
779
  }
646
780
  }