@sanning/proof 0.11.1 → 0.13.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 (51) hide show
  1. package/README.md +43 -8
  2. package/dist/agent-proof.d.ts +2 -2
  3. package/dist/agent-proof.js +4 -4
  4. package/dist/case-file.d.ts +126 -0
  5. package/dist/case-file.js +349 -0
  6. package/dist/case.d.ts +199 -43
  7. package/dist/case.js +522 -146
  8. package/dist/cli.d.ts +132 -1
  9. package/dist/cli.js +1140 -96
  10. package/dist/crypto.d.ts +1 -1
  11. package/dist/crypto.js +5 -1
  12. package/dist/declaration.d.ts +58 -52
  13. package/dist/declaration.js +256 -33
  14. package/dist/evidence.d.ts +2 -0
  15. package/dist/evidence.js +134 -19
  16. package/dist/exit-codes.d.ts +17 -0
  17. package/dist/exit-codes.js +50 -0
  18. package/dist/grading.d.ts +28 -13
  19. package/dist/grading.js +90 -31
  20. package/dist/handoff.d.ts +16 -1
  21. package/dist/handoff.js +91 -16
  22. package/dist/index.d.ts +10 -7
  23. package/dist/index.js +15 -7
  24. package/dist/message-parts.d.ts +11 -9
  25. package/dist/message-parts.js +11 -9
  26. package/dist/types.d.ts +1 -0
  27. package/dist/verifier.d.ts +9 -2
  28. package/dist/verifier.js +28 -12
  29. package/dist/zip-reader.d.ts +50 -0
  30. package/dist/zip-reader.js +220 -0
  31. package/package.json +2 -2
  32. package/src/agent-proof.ts +4 -4
  33. package/src/case-file.ts +419 -0
  34. package/src/case.ts +651 -156
  35. package/src/cli.ts +1325 -100
  36. package/src/crypto.ts +5 -1
  37. package/src/declaration.ts +248 -52
  38. package/src/evidence.ts +142 -21
  39. package/src/exit-codes.ts +54 -0
  40. package/src/grading.ts +109 -52
  41. package/src/handoff.ts +108 -17
  42. package/src/index.ts +60 -3
  43. package/src/message-parts.ts +11 -9
  44. package/src/types.ts +5 -0
  45. package/src/verifier.ts +35 -13
  46. package/src/zip-reader.ts +229 -0
  47. package/vectors/README.md +56 -3
  48. package/vectors/case-file-index.json +668 -0
  49. package/vectors/case-split.json +1102 -0
  50. package/vectors/pack-grading.json +68 -4
  51. package/vectors/stamp-on-chain.json +3 -3
package/dist/case.js CHANGED
@@ -166,7 +166,9 @@
166
166
  * gets, since that reads wrapper keys.
167
167
  *
168
168
  * Not exits: a record whose envelope verifies and whose content does not
169
- * match its commitment is `RECORD_CONTENT_MISMATCH`, and one that fails
169
+ * match its commitment is `RECORD_CONTENT_MISMATCH` where that content is
170
+ * inside the signed body and `RECORD_CONTENT_DEPENDENT` where it was handed
171
+ * in beside the pack (proof#159), and one that fails
170
172
  * another check inside its pack is `RECORD_DOES_NOT_VERIFY_IN_ITS_PACK`.
171
173
  * Both are named and both STAY in the pile, so neither unsigned content
172
174
  * handed in beside a pack nor a broken inclusion proof removes a record
@@ -185,7 +187,8 @@
185
187
  * (F2).
186
188
  * 10. **`counterpart_absent`** (`continue`). Skips the ambiguity and pair
187
189
  * reporting, and there is nothing to skip: 4.1 emits that finding only
188
- * for an EMPTY candidate list. Which of the three counterpart kinds it
190
+ * where no candidate is left to read, an EMPTY candidate list or one whose
191
+ * every candidate another record accounts for (4.1 v1.17, A20). Which of the three counterpart kinds it
189
192
  * gets reads the keys of the packs that verified, counted before exit 4,
190
193
  * and whether any pack took exit 2 or 3.
191
194
  *
@@ -206,7 +209,9 @@
206
209
  * verdict, exactly as `counterpart_absent` and `not_a_handoff_record` are
207
210
  * findings beside a verdict in 4.1.
208
211
  *
209
- * A case with more than one outcome is `mixed` (anchor#198, criterion 4).
212
+ * A case with any `failed` contribution is `failed`: a proven failure keeps
213
+ * the headline (5.5.4 v1.16, A17, proof#158). Otherwise a case with more than
214
+ * one outcome is `mixed` (anchor#198, criterion 4).
210
215
  *
211
216
  * WHAT THIS READER DOES NOT CLAIM
212
217
  * ===============================
@@ -225,20 +230,21 @@
225
230
  * with that verdict and its reasons, so the case never reads above its
226
231
  * weakest pack. On-chain re-fetch and `gradePack` are a caller's to run.
227
232
  *
228
- * THE COST OF 4.1's "GRADE EVERY CANDIDATE", INHERITED HERE AND STATED RATHER
229
- * THAN LEFT TO BE FOUND (proof#134, ratified by Phil 2026-09-22). A reader
230
- * whose pile holds two or more records of the complementary type signed by one
231
- * counterparty key cannot reach `verified`: the other exchanges' records are
232
- * candidates too, they commit to other messages, and they fail. One agent
233
- * sending two messages to the same counterparty inside one case lands there,
234
- * and it surfaces as `COUNTERPART_AMBIGUOUS` beside the failure. An
235
- * alternating exchange (A sends, B replies) does NOT, because a reply is
236
- * signed by the other key and is the other type. Narrowing that here with
237
- * `handoffs[]` is precisely the move that would reopen proof#137's door.
233
+ * THE COST OF 4.1's "GRADE EVERY CANDIDATE", AND WHAT IS LEFT OF IT (4.1
234
+ * v1.17, amendment A20, which reopens proof#134). Up to v1.16 a pile holding
235
+ * two or more records of the complementary type signed by one counterparty key
236
+ * could not reach `verified`. 4.1 now sets such a candidate aside where a
237
+ * record the subject's own key signed pairs with it verified at another
238
+ * sequence, so two messages in one direction, and the demo's A to B, B to A,
239
+ * A to B, read clean. What is left surfaces as `COUNTERPART_AMBIGUOUS` beside
240
+ * the failure: fan-in, fan-out, and the same pair in two cases at the same
241
+ * sequence. Narrowing any of it here with `handoffs[]` is precisely the move
242
+ * that would reopen proof#137's door.
238
243
  */
239
- import { checkDeclaration, declarationClaims, packRecords, } from "./declaration.js";
244
+ import { DECLARATION_VERSION, DECLARATION_VERSION_V2, checkDeclaration, declarationClaims, packRecords, } from "./declaration.js";
240
245
  import { NOT_A_HANDOFF_FINDING, normalizedSigningKey, readHandoffs, recordIsMalformed, } from "./handoff.js";
241
246
  import { verifyEvidenceBundle } from "./evidence.js";
247
+ import { accountDisclosure, completenessNotes, gradePack, } from "./grading.js";
242
248
  // ---- The vocabulary ---------------------------------------------------------
243
249
  /**
244
250
  * The verdicts a case can reach. Every one is already in use in
@@ -309,7 +315,9 @@ export const CASE_QUARANTINED_PROFILES = ["sanning.mlflow/v1"];
309
315
  /**
310
316
  * A conformant hand-off record with no counterpart among the records this
311
317
  * reader read, where every pack in the case verified under a key this reader
312
- * can read and NO pack in the case is signed by the key the record names.
318
+ * can read and NO pack in the case is signed by the key the record names,
319
+ * or where every candidate it had was accounted for by another record under
320
+ * A20 (the exception then carries `count`, and names no pack).
313
321
  * **An exception, never a failure** (anchor#198, criterion 3).
314
322
  *
315
323
  * That is ALL it says. It does not say the counterparty's account is absent,
@@ -395,9 +403,11 @@ export const COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS = "counterpart_absent_
395
403
  */
396
404
  export const COUNTERPART_ABSENT_SIGNER_UNKNOWN = "counterpart_absent_signer_unknown";
397
405
  /**
398
- * More than one candidate counterpart (4.1, and proof#134's stated cost). The
399
- * reading is the worst of them and the ambiguity is named: a reader permitted
400
- * to take one match can be handed an honest decoy beside a tampered record.
406
+ * More than one candidate counterpart that nothing accounts for (4.1 v1.17,
407
+ * A20). The reading is the worst of them and the ambiguity is named: a reader
408
+ * permitted to take one match can be handed an honest decoy beside a tampered
409
+ * record. `count` is how many of the candidates are live, which is the number
410
+ * the ambiguity is about.
401
411
  */
402
412
  export const COUNTERPART_AMBIGUOUS = "counterpart_ambiguous";
403
413
  /**
@@ -573,17 +583,26 @@ export const PACK_KEY_UNREADABLE = "pack_key_unreadable";
573
583
  */
574
584
  export const PACK_DOES_NOT_VERIFY = "pack_does_not_verify";
575
585
  /**
576
- * The pack's wrapper HOLDS and its own bundle verdict, from
577
- * `verifyEvidenceBundle` run here offline over the pack and the content handed
578
- * in beside it, is not `verified`. The exception carries that verdict
579
- * (`status`) and the verifier's reasons (`reasons`: its bundle errors, then its
580
- * stamp errors, then its checkpoint errors, each as the verifier wrote it), and
581
- * it CONTRIBUTES that verdict, so the case verdict never reads above its
582
- * weakest pack. Gate 3 on proof#140: two unwitnessed (`partial`) packs read as
583
- * a `verified` case with no exception. It does not say who is at fault:
584
- * content handed in beside a pack is not signed by its holder.
586
+ * The pack's wrapper HOLDS and its CASE GRADE is not `verified`
587
+ * (evidence-bundle 5.5.1 step 1 v1.20, A23; it replaces
588
+ * `pack_bundle_not_verified`, which carried the bundle status alone). The case
589
+ * grade is the pack grade of 5.4.5, taken twice: the SIGNED grade over the run
590
+ * with the side map empty, and the FULL grade over the run with it. The signed
591
+ * grade decides where it is `failed` or `malformed`; a failure only the full
592
+ * grade reaches is `incomplete`, because it rests on bytes nobody signed;
593
+ * otherwise the full grade. A body the verifier could not read is `malformed`,
594
+ * a body that is not one of the two event-carrying bodies takes the signed
595
+ * run's status, and a verifier that graded a different number of records than
596
+ * the pack holds takes the signed status, or `malformed` where that status is
597
+ * `verified`.
598
+ *
599
+ * The exception carries `status`, the case grade; `causes`, the 5.4.5 cause
600
+ * kinds of the grade that decided it (none in the first three branches); and
601
+ * `reasons`, the verifier's reasons from the run with the side map empty. It
602
+ * CONTRIBUTES the grade, and `incomplete` for `pending`, `not_found` or
603
+ * `mixed`, so the case verdict never reads above its weakest pack.
585
604
  */
586
- export const PACK_BUNDLE_NOT_VERIFIED = "pack_bundle_not_verified";
605
+ export const PACK_GRADE_NOT_VERIFIED = "pack_grade_not_verified";
587
606
  /**
588
607
  * A record in a pack whose wrapper holds, whose OWN envelope does not verify
589
608
  * (`EventResult.envelopeOk` is false). Not read by 4.1, and NAMED with the
@@ -604,12 +623,39 @@ export const RECORD_ENVELOPE_DOES_NOT_VERIFY = "record_envelope_does_not_verify"
604
623
  export const RECORD_DOES_NOT_VERIFY_IN_ITS_PACK = "record_does_not_verify_in_its_pack";
605
624
  /**
606
625
  * A record whose envelope verifies and whose disclosed content does not hash to
607
- * the `content_hash` it commits to (`EventResult.contentOk` is false). `failed`,
608
- * and the record STAYS in the pile: content beside a pack is unsigned and often
626
+ * the hash it commits to, its `content_hash` or a hand-off record's
627
+ * `message_hash` (evidence-bundle v1.19, A20) (`EventResult.contentOk` is false), with the
628
+ * side map EMPTY: the content is inside the signed body. The same mismatch from
629
+ * content handed in beside the pack is `RECORD_CONTENT_DEPENDENT` (proof#159).
630
+ * `failed`, and the record STAYS in the pile: content beside a pack is unsigned and often
609
631
  * supplied by whoever assembled the case, and gate 3 on proof#140 showed that
610
632
  * party erasing an honest verified pair by swapping it.
611
633
  */
612
634
  export const RECORD_CONTENT_MISMATCH = "record_content_mismatch";
635
+ /**
636
+ * The two readings proof#147 did not reach, given its shape (proof#159, ruled
637
+ * by Phil 2026-09-22, evidence-bundle.md 5.5.4 v1.16, A17). A pack whose
638
+ * bundle verdict is `failed` ONLY when read with the unsigned content handed
639
+ * in beside it, and `failed` no longer when read with that map empty, is
640
+ * `PACK_BUNDLE_CONTENT_DEPENDENT`; a record whose content mismatch comes from
641
+ * that map alone is `RECORD_CONTENT_DEPENDENT`. Both are `incomplete`: the
642
+ * reader established that the bytes it was handed are not the bytes the record
643
+ * commits to, and nothing about the party that signed the record, because
644
+ * whoever assembled the case chose those bytes with no key. Up to v1.15 both
645
+ * readings carried `failed`, and one flipped nibble of accuser-supplied content
646
+ * put a `failed` against an intact pack; under A17's headline rule that would
647
+ * have become the case's headline.
648
+ *
649
+ * The test that separates the two is the one proof#159 states for the inverse:
650
+ * every deviation a signature covers still reads `failed` with the side map
651
+ * EMPTY. So the pack is verified twice, with the map and without it, and what
652
+ * fails without it is `PACK_GRADE_NOT_VERIFIED`, `RECORD_CONTENT_MISMATCH`
653
+ * (content inside the signed body) or `RECORD_DOES_NOT_VERIFY_IN_ITS_PACK`,
654
+ * each still `failed`. Emptying the map is the most a party that signed
655
+ * nothing can do to starve the check, and it cannot remove any of those.
656
+ */
657
+ export const PACK_BUNDLE_CONTENT_DEPENDENT = "pack_bundle_content_dependent";
658
+ export const RECORD_CONTENT_DEPENDENT = "record_content_dependent";
613
659
  /**
614
660
  * A record signed under a ratified profile that is under quarantine
615
661
  * (`CASE_QUARANTINED_PROFILES`, pending DL-63). Not read, in any kernel.
@@ -619,11 +665,13 @@ export const RECORD_PROFILE_QUARANTINED = "record_profile_quarantined";
619
665
  /** The reader was handed no packs at all. */
620
666
  export const CASE_HOLDS_NO_PACKS = "case_holds_no_packs";
621
667
  /**
622
- * Every pack was read and not one hand-off record came out of any of them.
623
- * There is no hand-off to have a verdict about, and saying `verified` over that
624
- * would be the defect class in its purest form.
668
+ * The two NOTEs (5.5.3 v1.20): findings on the reading with no contribution,
669
+ * printed in the scope sentence so a clean verdict never hides either.
670
+ * `pack_undeclared` is a note only where the reading holds no hand-off record;
671
+ * where it holds one, it is the exception of that name.
625
672
  */
626
- export const CASE_HOLDS_NO_HANDOFF_RECORDS = "case_holds_no_handoff_records";
673
+ export const CONTENT_WITHHELD_BY_DECLARATION = "content_withheld_by_declaration";
674
+ export const CASE_NOTE_KINDS = [PACK_UNDECLARED, CONTENT_WITHHELD_BY_DECLARATION];
627
675
  /**
628
676
  * Every exception this reader can report, as a closed set. A surface may render
629
677
  * them and a test may assert over them; nothing outside this array is emitted.
@@ -647,13 +695,14 @@ export const CASE_EXCEPTION_KINDS = [
647
695
  PACK_HOLDS_NO_RECORDS,
648
696
  PACK_KEY_UNREADABLE,
649
697
  PACK_DOES_NOT_VERIFY,
650
- PACK_BUNDLE_NOT_VERIFIED,
698
+ PACK_GRADE_NOT_VERIFIED,
699
+ PACK_BUNDLE_CONTENT_DEPENDENT,
651
700
  RECORD_ENVELOPE_DOES_NOT_VERIFY,
652
701
  RECORD_DOES_NOT_VERIFY_IN_ITS_PACK,
653
702
  RECORD_CONTENT_MISMATCH,
703
+ RECORD_CONTENT_DEPENDENT,
654
704
  RECORD_PROFILE_QUARANTINED,
655
705
  CASE_HOLDS_NO_PACKS,
656
- CASE_HOLDS_NO_HANDOFF_RECORDS,
657
706
  ];
658
707
  /**
659
708
  * What each exception kind contributes to the case verdict. It is a TABLE and
@@ -666,10 +715,10 @@ export const CASE_EXCEPTION_KINDS = [
666
715
  * contributes a clean verdict, which is what makes the invariant in `verdictOf`
667
716
  * hold.
668
717
  *
669
- * `PACK_BUNDLE_NOT_VERIFIED` is the one row that is not a constant: it
670
- * contributes the pack's own bundle verdict, which is `partial`, `failed` or
671
- * `malformed` and never `verified` (the exception is emitted only when it is
672
- * not). `null` marks that row, and `readCase` fills it from `status`.
718
+ * `PACK_GRADE_NOT_VERIFIED` is the one row that is not a constant: it
719
+ * contributes the pack's case grade, which is never `verified` (the exception
720
+ * is emitted only when it is not). `null` marks that row, and `readCase` fills
721
+ * it from `status`.
673
722
  */
674
723
  const EXCEPTION_VERDICT = {
675
724
  [COUNTERPART_ABSENT]: "incomplete",
@@ -690,14 +739,26 @@ const EXCEPTION_VERDICT = {
690
739
  [PACK_HOLDS_NO_RECORDS]: "incomplete",
691
740
  [PACK_KEY_UNREADABLE]: "incomplete",
692
741
  [PACK_DOES_NOT_VERIFY]: "failed",
693
- [PACK_BUNDLE_NOT_VERIFIED]: null,
742
+ [PACK_GRADE_NOT_VERIFIED]: null,
743
+ [PACK_BUNDLE_CONTENT_DEPENDENT]: "incomplete",
694
744
  [RECORD_ENVELOPE_DOES_NOT_VERIFY]: "failed",
695
745
  [RECORD_DOES_NOT_VERIFY_IN_ITS_PACK]: "failed",
696
746
  [RECORD_CONTENT_MISMATCH]: "failed",
747
+ [RECORD_CONTENT_DEPENDENT]: "incomplete",
697
748
  [RECORD_PROFILE_QUARANTINED]: "incomplete",
698
749
  [CASE_HOLDS_NO_PACKS]: "partial",
699
- [CASE_HOLDS_NO_HANDOFF_RECORDS]: "partial",
700
750
  };
751
+ /**
752
+ * The case grades `PACK_GRADE_NOT_VERIFIED` contributes as themselves. Every
753
+ * other grade a pack can reach (`pending`, `not_found`, `mixed`, which a pack
754
+ * grade passes through and a case verdict never issues) is `incomplete`.
755
+ */
756
+ const GRADE_CONTRIBUTES = new Set([
757
+ "partial",
758
+ "incomplete",
759
+ "failed",
760
+ "malformed",
761
+ ]);
701
762
  // ---- The reading ------------------------------------------------------------
702
763
  const bodyTypeOf = (bundle) => typeof bundle === "object" && bundle !== null
703
764
  ? bundle["body_type"]
@@ -735,6 +796,23 @@ const wrapperHolds = (result) => result.signatureOk && result.bodyHashOk;
735
796
  * bundle-level error at all, and an exception that named the verdict and no
736
797
  * reason would send a reader looking for a defect in the pack as a whole.
737
798
  */
799
+ /**
800
+ * The reasons a reading carries only because of the side map, in the order the
801
+ * verifier wrote them: what the content-dependent kinds are about, and nothing
802
+ * a signature covers.
803
+ */
804
+ const onlyIn = (withMap, withoutMap) => withMap.filter((reason) => !withoutMap.includes(reason));
805
+ /**
806
+ * Whether a side-content map holds any key. Python's `if pack.content` is
807
+ * the twin: `None` and an empty map both mean the two verify runs are one run.
808
+ */
809
+ function sideMapHasKeys(content) {
810
+ if (content === null || content === undefined)
811
+ return false;
812
+ if (content instanceof Map)
813
+ return content.size > 0;
814
+ return Object.keys(content).length > 0;
815
+ }
738
816
  const bundleReasons = (result) => [
739
817
  ...result.errors,
740
818
  ...(result.stamps ?? []).flatMap((stamp) => stamp.errors),
@@ -770,8 +848,13 @@ function underQuarantine(specVersion) {
770
848
  /**
771
849
  * The case verdict, from the contributions alone.
772
850
  *
773
- * One outcome is that outcome; more than one is `mixed` (anchor#198,
774
- * criterion 4). `mixed` is in the 3 vocabulary and coins no word.
851
+ * **A proven failure keeps the headline** (`evidence-bundle.md` 5.5.4 v1.16,
852
+ * A17, proof#158): if any contribution is `failed`, the case is `failed`,
853
+ * whatever else contributes, and every other exception stays named beside it.
854
+ * Otherwise one outcome is that outcome, and more than one is `mixed`
855
+ * (anchor#198, criterion 4), so `mixed` never holds a `failed`. `malformed`
856
+ * does NOT keep the headline: it says a record cannot be read, not that a
857
+ * check ran and did not pass. `mixed` is in the 3 vocabulary and coins no word.
775
858
  *
776
859
  * THE INVARIANT, stated so a mutation test has something to kill: `verified` is
777
860
  * reachable ONLY when `exceptions` is empty. Every exception kind carries a
@@ -780,14 +863,25 @@ function underQuarantine(specVersion) {
780
863
  * The assertion is here anyway, because that is the property every one of the
781
864
  * seventeen findings on this program violated.
782
865
  */
783
- function verdictOf(exceptions, contributions) {
784
- // Unreachable through `readCase`, which names a case with no contributions
785
- // rather than returning one. Defended anyway: an empty aggregate reading as
786
- // `verified` is the defect class itself.
787
- if (contributions.length === 0)
788
- return "partial";
866
+ function verdictOf(exceptions, contributions, holdsPacks = true) {
867
+ // A reading that holds at least one pack and no contribution at all is
868
+ // `verified` (5.5.4 v1.20, A23): every pack in it graded `verified` and
869
+ // nothing in it was an exception. Every exception contributes, so no
870
+ // contribution means no exception. A reading with no packs always carries
871
+ // `case_holds_no_packs`, and is defended here anyway: an empty aggregate over
872
+ // nothing reading as `verified` is the defect class itself.
873
+ if (contributions.length === 0) {
874
+ if (exceptions.length > 0) {
875
+ throw new Error(`exceptions with no contribution: ${exceptions.map((e) => e.kind).join(", ")}`);
876
+ }
877
+ return holdsPacks ? "verified" : "partial";
878
+ }
789
879
  const outcomes = new Set(contributions.map(([, v]) => v));
790
- const verdict = outcomes.size === 1 ? [...outcomes][0] : "mixed";
880
+ const verdict = outcomes.has("failed")
881
+ ? "failed"
882
+ : outcomes.size === 1
883
+ ? [...outcomes][0]
884
+ : "mixed";
791
885
  if (verdict === "verified" && exceptions.length > 0) {
792
886
  throw new Error(`verified over a non-empty exception list: ${exceptions.map((e) => e.kind).join(", ")}`);
793
887
  }
@@ -821,23 +915,35 @@ function verdictOf(exceptions, contributions) {
821
915
  * see is the counterpart that is missing.
822
916
  */
823
917
  export async function readCase(packs) {
918
+ // A `null` slot is held for each pack that carries no declaration, and
919
+ // settled once the pile says whether the reading holds a hand-off record.
824
920
  const exceptions = [];
825
921
  const contributions = [];
826
922
  const note = (kind, fields = {}) => {
827
923
  exceptions.push({ kind, ...fields });
828
- // `null` is PACK_BUNDLE_NOT_VERIFIED: the pack's own verdict, never verified.
829
- const contribution = EXCEPTION_VERDICT[kind] ?? fields.status;
924
+ // `null` is PACK_GRADE_NOT_VERIFIED: the pack's case grade, never
925
+ // verified, and `incomplete` for a word a pack grade passes through and a
926
+ // case verdict never issues (5.5.1 step 1 v1.20).
927
+ let contribution = EXCEPTION_VERDICT[kind];
928
+ if (contribution === null) {
929
+ const status = fields.status;
930
+ contribution = GRADE_CONTRIBUTES.has(status) ? status : "incomplete";
931
+ }
830
932
  contributions.push([fields.eventId ?? fields.packRef ?? "case", contribution]);
831
933
  };
832
934
  if (packs.length === 0) {
833
935
  note(CASE_HOLDS_NO_PACKS);
936
+ const named = exceptions;
937
+ const contributed = contributions;
834
938
  return {
835
- verdict: verdictOf(exceptions, contributions),
836
- exceptions,
939
+ verdict: verdictOf(named, contributed, false),
940
+ exceptions: named,
837
941
  records: [],
838
- contributions,
942
+ contributions: contributed,
839
943
  handoffRecords: 0,
840
944
  onChainChecked: false,
945
+ notes: [],
946
+ packs: [],
841
947
  };
842
948
  }
843
949
  // Step 1 and 2, pack by pack, in the order the reader was given them.
@@ -879,6 +985,15 @@ export async function readCase(packs) {
879
985
  // below, from the pack envelope and never from the body, so no body type, no
880
986
  // disclosure choice and no record content decides whether it is read.
881
987
  const holderKeys = new Set();
988
+ // One reading per pack, in the order the reader was given them (5.5.4 v1.20).
989
+ const packReadings = [];
990
+ // The NOTEs (5.5.3 v1.20), with the pack position they are about.
991
+ const caseNotes = [];
992
+ // The role each pack's readable v2 declaration signs for each record, keyed
993
+ // by the pack's POSITION for the reason `claimsByPack` is.
994
+ const rolesByPack = new Map();
995
+ // The undeclared packs' slots, settled after the pile is read.
996
+ const undeclaredSlots = [];
882
997
  // How many packs this reader could not tie to a key: an unreadable wrapper
883
998
  // key, or a wrapper that does not hold. While it is not zero, "no pack in this
884
999
  // case is signed by the named key" is not established, so the innocent
@@ -894,6 +1009,13 @@ export async function readCase(packs) {
894
1009
  // of these spellings (proof#135) and the reading must not.
895
1010
  note(PACK_KEY_UNREADABLE, { packRef: pack.ref });
896
1011
  unattributed += 1;
1012
+ packReadings.push({
1013
+ ref: pack.ref,
1014
+ signingKey: null,
1015
+ grade: null,
1016
+ recordsWithheld: 0,
1017
+ declarationVersion: null,
1018
+ });
897
1019
  continue;
898
1020
  }
899
1021
  // The precondition `HeldRecord` states, met HERE rather than trusted to a
@@ -905,28 +1027,121 @@ export async function readCase(packs) {
905
1027
  content: (pack.content ?? undefined),
906
1028
  });
907
1029
  witnessChecked.push(verified.onChainChecked);
1030
+ // The same pack with the side map EMPTY (proof#159): what fails here is
1031
+ // what a signature covers, and what fails only above rests on bytes nobody
1032
+ // signed. Skipped where there is no map, because the two runs are then the
1033
+ // same run.
1034
+ const signed = sideMapHasKeys(pack.content)
1035
+ ? await verifyEvidenceBundle(pack.bundle, {})
1036
+ : verified;
908
1037
  // WHOSE PACK IS IT. The wrapper, and only the wrapper.
909
1038
  if (!wrapperHolds(verified)) {
910
1039
  note(PACK_DOES_NOT_VERIFY, { packRef: pack.ref });
911
1040
  unattributed += 1;
1041
+ packReadings.push({
1042
+ ref: pack.ref,
1043
+ signingKey: holder,
1044
+ grade: null,
1045
+ recordsWithheld: 0,
1046
+ declarationVersion: null,
1047
+ });
912
1048
  continue;
913
1049
  }
914
1050
  holderKeys.add(holder);
915
- // WHAT IS WRONG INSIDE IT, from here on. First the pack's own assurance,
916
- // which propagates: the case never reads above its weakest pack.
917
- if (verified.status !== "verified") {
918
- note(PACK_BUNDLE_NOT_VERIFIED, {
1051
+ const bodyType = bodyTypeOf(pack.bundle);
1052
+ const records = packRecords(pack.bundle, pack.content);
1053
+ // The per-record gradings below are joined to `records` BY INDEX, so a
1054
+ // verifier that graded a different number of records than the pack holds
1055
+ // leaves this loop with no grading to read. That is not hypothetical: a
1056
+ // pack whose `body_hash` does not cover its body reads `failed` with
1057
+ // `events` EMPTY while `packRecords` reads every record in it, and only
1058
+ // `wrapperHolds` above stands between that pack and this loop. A reader
1059
+ // must never crash on a pack a party can build, so the gap is graded rather
1060
+ // than indexed into, and the pack's records are dropped exactly as a
1061
+ // `malformed` pack's are (gate 4 on proof#140, finding 3).
1062
+ const ungraded = verified.events.length !== records.length || signed.events.length !== records.length;
1063
+ const knownBody = CASE_BODY_TYPES.includes(bodyType);
1064
+ // WHAT IS WRONG INSIDE IT, from here on. First the pack's own GRADE, which
1065
+ // propagates: the case never reads above its weakest pack (5.5.1 step 1
1066
+ // v1.20, A23; up to v1.19 it was the bundle status).
1067
+ let check = null;
1068
+ let full = null;
1069
+ let causes = [];
1070
+ let grade;
1071
+ if (signed.status === "malformed") {
1072
+ grade = "malformed";
1073
+ }
1074
+ else if (!knownBody) {
1075
+ grade = signed.status;
1076
+ }
1077
+ else if (ungraded) {
1078
+ grade = signed.status === "verified" ? "malformed" : signed.status;
1079
+ }
1080
+ else {
1081
+ check = checkDeclaration(pack.bundle, pack.content);
1082
+ const completeness = completenessNotes(pack.bundle);
1083
+ full = gradePack(verified, accountDisclosure(pack.bundle, pack.content ?? {}), completeness, check);
1084
+ const bareGrade = gradePack(signed, accountDisclosure(pack.bundle, {}), completeness, checkDeclaration(pack.bundle, null));
1085
+ // NO `failed` HERE RESTS ON BYTES NOBODY SIGNED. The signed grade decides
1086
+ // where it is `failed` or `malformed`; a failure only the side map
1087
+ // produced is `incomplete`; otherwise the full grade.
1088
+ if (bareGrade.verdict === "failed" || bareGrade.verdict === "malformed") {
1089
+ grade = bareGrade.verdict;
1090
+ causes = bareGrade.causes.map((c) => c.kind);
1091
+ }
1092
+ else if (full.verdict === "failed" || full.verdict === "malformed") {
1093
+ grade = "incomplete";
1094
+ causes = full.causes.map((c) => c.kind);
1095
+ }
1096
+ else {
1097
+ grade = full.verdict;
1098
+ causes = full.causes.map((c) => c.kind);
1099
+ }
1100
+ }
1101
+ if (grade !== "verified") {
1102
+ const reasons = bundleReasons(signed);
1103
+ if (knownBody && ungraded && signed.status !== "malformed") {
1104
+ // Said, because nothing else says it: the verifier's own reasons are
1105
+ // about the bundle, and this pack's records are dropped for a reason
1106
+ // of this reader's.
1107
+ reasons.push(`this verifier graded ${verified.events.length} of the ${records.length} ` +
1108
+ "record(s) the pack holds, so none of them is read");
1109
+ }
1110
+ note(PACK_GRADE_NOT_VERIFIED, {
919
1111
  packRef: pack.ref,
920
- status: verified.status,
921
- reasons: bundleReasons(verified),
1112
+ status: grade,
1113
+ causes,
1114
+ reasons,
1115
+ });
1116
+ }
1117
+ if (verified.status === "failed" && signed.status !== "failed") {
1118
+ note(PACK_BUNDLE_CONTENT_DEPENDENT, {
1119
+ packRef: pack.ref,
1120
+ reasons: onlyIn(bundleReasons(verified), bundleReasons(signed)),
1121
+ });
1122
+ }
1123
+ const recordsWithheld = full !== null ? full.recordsWithheld : 0;
1124
+ packReadings.push({
1125
+ ref: pack.ref,
1126
+ signingKey: holder,
1127
+ grade,
1128
+ recordsWithheld,
1129
+ declarationVersion: knownBody ? declarationVersionOf(pack.bundle) : null,
1130
+ });
1131
+ if (recordsWithheld > 0) {
1132
+ // A NOTE, with no contribution (5.5.3 v1.20): the holder signed that
1133
+ // these records' content does not travel, and the scope sentence counts
1134
+ // them, so a clean verdict never hides them.
1135
+ caseNotes.push({
1136
+ position,
1137
+ note: { kind: CONTENT_WITHHELD_BY_DECLARATION, packRef: pack.ref, count: recordsWithheld },
922
1138
  });
923
1139
  }
924
1140
  // The body is one the verifier could not read, so it graded no record, and
925
1141
  // nothing below has a grading to read. Named above.
926
- if (verified.status === "malformed")
1142
+ if (signed.status === "malformed")
927
1143
  continue;
928
- const bodyType = bodyTypeOf(pack.bundle);
929
- if (!CASE_BODY_TYPES.includes(bodyType)) {
1144
+ if (!knownBody) {
930
1145
  // `sanning.evidence.export/v2` lands here, and it is NOT unwrapped: its
931
1146
  // records sit under `body.source_bundle`, and reading them is a change to
932
1147
  // which packs this reader grades, so it is a decision of its own and not
@@ -936,58 +1151,39 @@ export async function readCase(packs) {
936
1151
  note(PACK_BODY_TYPE_UNKNOWN, { packRef: pack.ref });
937
1152
  continue;
938
1153
  }
939
- const records = packRecords(pack.bundle, pack.content);
940
- // The per-record gradings below are joined to `records` BY INDEX, so a
941
- // verifier that graded a different number of records than the pack holds
942
- // leaves this loop with no grading to read. That is not hypothetical: a
943
- // pack whose `body_hash` does not cover its body reads `failed` with
944
- // `events` EMPTY while `packRecords` reads every record in it, and only
945
- // `wrapperHolds` above stands between that pack and this loop. A reader
946
- // must never crash on a pack a party can build, so the gap is NAMED rather
947
- // than indexed into, and the pack's records are dropped exactly as a
948
- // `malformed` pack's are (gate 4 on proof#140, finding 3: under the
949
- // mutation that dropped `bodyHashOk` from the wrapper test, this kernel
950
- // threw `Cannot read properties of undefined (reading 'envelopeOk')` here
951
- // and nothing in the ladder said so).
952
- if (verified.events.length !== records.length) {
953
- note(PACK_BUNDLE_NOT_VERIFIED, {
954
- packRef: pack.ref,
955
- status: verified.status,
956
- reasons: [
957
- `this verifier graded ${verified.events.length} of the ` +
958
- `${records.length} record(s) the pack holds, so none of them is read`,
959
- ],
960
- });
1154
+ // Graded above (`malformed` where the verifier called it `verified`), and
1155
+ // none of its records are read.
1156
+ if (ungraded)
961
1157
  continue;
962
- }
963
1158
  if (records.length === 0) {
964
1159
  note(PACK_HOLDS_NO_RECORDS, { packRef: pack.ref });
965
1160
  continue;
966
1161
  }
967
- const check = checkDeclaration(pack.bundle, pack.content);
1162
+ const checked = check;
968
1163
  claimsByPack.set(position, declarationClaims(pack.bundle));
969
- if (check.contentFindings.length > 0) {
1164
+ rolesByPack.set(position, new Map((checked.roles ?? []).map((r) => [r.eventId, r.role])));
1165
+ if (checked.contentFindings.length > 0) {
970
1166
  // BESIDE the deviation exception and never instead of it: a pack can do
971
1167
  // both, over different records, and the two say different things. This
972
1168
  // one is `incomplete` because whoever assembled the case chose the bytes
973
1169
  // it turns on (proof#147).
974
1170
  note(PACK_DECLARATION_CONTENT_DEPENDENT, {
975
1171
  packRef: pack.ref,
976
- count: check.contentFindings.length,
1172
+ count: checked.contentFindings.length,
977
1173
  });
978
1174
  }
979
- if (check.verdict === "failed") {
1175
+ if (checked.verdict === "failed") {
980
1176
  note(PACK_DECLARATION_DEVIATES, {
981
1177
  packRef: pack.ref,
982
- count: check.deviations.length,
1178
+ count: checked.deviations.length,
983
1179
  });
984
1180
  }
985
- else if (check.contentFindings.length > 0) {
1181
+ else if (checked.contentFindings.length > 0) {
986
1182
  // Already named above. `partial` here is not "the reader cannot read the
987
1183
  // claim": the claim was read, and part of it rests on bytes nobody
988
1184
  // signed.
989
1185
  }
990
- else if (check.verdict === "verified") {
1186
+ else if (checked.verdict === "verified") {
991
1187
  // A pack that agrees with its own claim contributes NOTHING, rather than
992
1188
  // a `verified` of its own. The case verdict is over the hand-off
993
1189
  // readings, and criterion 4's "mixed" is about mixed HAND-OFF outcomes: a
@@ -996,7 +1192,7 @@ export async function readCase(packs) {
996
1192
  // read `mixed` and the failure would be the quieter half of a two-word
997
1193
  // answer. A pack-level PROBLEM still contributes, through its exception.
998
1194
  }
999
- else if (check.present) {
1195
+ else if (checked.present) {
1000
1196
  // `partial` with a declaration present is 5.4.3's "a claim this reader
1001
1197
  // cannot read", which `gradePack` already carries to `incomplete`. The
1002
1198
  // same posture here, for the same reason: the field has no accept-set and
@@ -1004,7 +1200,18 @@ export async function readCase(packs) {
1004
1200
  note(PACK_DECLARATION_UNREADABLE, { packRef: pack.ref });
1005
1201
  }
1006
1202
  else {
1007
- note(PACK_UNDECLARED, { packRef: pack.ref });
1203
+ // WHAT IT CONTRIBUTES TURNS ON THE WHOLE READING (5.5.1 step 2 v1.20,
1204
+ // A23, I4): `partial` where the reading holds a hand-off record, and a
1205
+ // NOTE where it holds none. That is not known until the pile is read, so
1206
+ // the slot is held HERE, in pack order, and settled below.
1207
+ undeclaredSlots.push({
1208
+ exceptionAt: exceptions.length,
1209
+ contributionAt: contributions.length,
1210
+ position,
1211
+ ref: pack.ref,
1212
+ });
1213
+ exceptions.push(null);
1214
+ contributions.push(null);
1008
1215
  }
1009
1216
  // Each record on its OWN envelope. `packRecords` and the verifier both walk
1010
1217
  // `body.events` in order and emit one entry per element, so the index is
@@ -1012,6 +1219,7 @@ export async function readCase(packs) {
1012
1219
  for (let index = 0; index < records.length; index += 1) {
1013
1220
  const record = records[index];
1014
1221
  const graded = verified.events[index];
1222
+ const bare = signed.events[index];
1015
1223
  if (underQuarantine(recordSpecVersion(pack.bundle, index))) {
1016
1224
  note(RECORD_PROFILE_QUARANTINED, { packRef: pack.ref, eventId: record.eventId });
1017
1225
  continue;
@@ -1038,20 +1246,31 @@ export async function readCase(packs) {
1038
1246
  });
1039
1247
  continue;
1040
1248
  }
1041
- // Named, and the record stays in the pile (see the kinds).
1042
- if (graded.contentOk === false) {
1249
+ // Named, and the record stays in the pile (see the kinds). A mismatch
1250
+ // the signed body carries on its own is `failed`; one that appears only
1251
+ // with the side map is `incomplete` (proof#159).
1252
+ if (bare.contentOk === false) {
1043
1253
  note(RECORD_CONTENT_MISMATCH, {
1044
1254
  packRef: pack.ref,
1045
1255
  eventId: record.eventId,
1046
- reasons: [...graded.errors],
1256
+ reasons: [...bare.errors],
1257
+ });
1258
+ }
1259
+ else if (graded.contentOk === false) {
1260
+ note(RECORD_CONTENT_DEPENDENT, {
1261
+ packRef: pack.ref,
1262
+ eventId: record.eventId,
1263
+ reasons: onlyIn(graded.errors, bare.errors),
1047
1264
  });
1048
1265
  }
1049
- if (!(graded.inclusionOk && graded.checkpointBound) ||
1050
- (!graded.ok && graded.contentOk !== false)) {
1266
+ // Every other check on the SIGNED reading, so side content can neither
1267
+ // add a failure here nor take one away.
1268
+ if (!(bare.inclusionOk && bare.checkpointBound) ||
1269
+ (!bare.ok && bare.contentOk !== false)) {
1051
1270
  note(RECORD_DOES_NOT_VERIFY_IN_ITS_PACK, {
1052
1271
  packRef: pack.ref,
1053
1272
  eventId: record.eventId,
1054
- reasons: [...graded.errors],
1273
+ reasons: [...bare.errors],
1055
1274
  });
1056
1275
  }
1057
1276
  held.push({
@@ -1080,6 +1299,7 @@ export async function readCase(packs) {
1080
1299
  findings: [...reading.findings],
1081
1300
  failedChecks: [...reading.failedChecks],
1082
1301
  counterparts: [...reading.candidates],
1302
+ role: rolesByPack.get(position)?.get(eventId) ?? null,
1083
1303
  });
1084
1304
  // proof#137's door, named. Read from the ONE reader of the declaration
1085
1305
  // (`declarationClaims`), never from a second parse here. Only a READABLE
@@ -1093,9 +1313,13 @@ export async function readCase(packs) {
1093
1313
  // `not_a_handoff_record` exit it was a check the audited party switched off
1094
1314
  // by respelling two fields, and behind the `malformed` exit it was one a
1095
1315
  // party switched off by breaking its own record.
1316
+ //
1317
+ // v1's ALONE (5.5.1 v1.20, A21). A v2 pack's claim check is rule 9, which
1318
+ // the declaration check already ran on the signed body.
1096
1319
  const claims = claimsByPack.get(position);
1097
1320
  if (claims !== undefined &&
1098
1321
  claims.readable &&
1322
+ claims.version === DECLARATION_VERSION &&
1099
1323
  !claims.claimedSides.includes(eventId)) {
1100
1324
  note(RECORD_UNCLAIMED, { packRef, eventId });
1101
1325
  }
@@ -1135,19 +1359,36 @@ export async function readCase(packs) {
1135
1359
  // that party's pack. Where some pack could not be tied to a key, "no
1136
1360
  // pack is signed by the named key" is not established and the reading
1137
1361
  // says it could not tell.
1138
- const kind = named !== null && holderKeys.has(named)
1139
- ? COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS
1140
- : unattributed > 0
1141
- ? COUNTERPART_ABSENT_SIGNER_UNKNOWN
1142
- : COUNTERPART_ABSENT;
1143
- note(kind, { packRef, eventId, counterpartyKey: named });
1362
+ //
1363
+ // EXCEPT where 4.1 set every candidate aside (v1.17, A20). There the
1364
+ // absence is a finding about the candidates the reader DID read, each
1365
+ // paired `verified` with another record the subject's own signer made,
1366
+ // and not a finding about any pack. 4.1 and evidence-bundle A16 name the
1367
+ // neutral kind for it, and the damning one would put the audited party's
1368
+ // own unfaithful record on the counterparty's pack as a statement
1369
+ // against it. `count` is how many candidates were set aside, which is
1370
+ // also what tells the rendered sentence which absence this is.
1371
+ const graded = reading.candidateReadings;
1372
+ const setAside = graded.length > 0 && graded.every((c) => c.accountedFor !== null)
1373
+ ? graded.length
1374
+ : 0;
1375
+ const kind = setAside > 0
1376
+ ? COUNTERPART_ABSENT
1377
+ : named !== null && holderKeys.has(named)
1378
+ ? COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS
1379
+ : unattributed > 0
1380
+ ? COUNTERPART_ABSENT_SIGNER_UNKNOWN
1381
+ : COUNTERPART_ABSENT;
1382
+ note(kind, setAside > 0
1383
+ ? { packRef, eventId, counterpartyKey: named, count: setAside }
1384
+ : { packRef, eventId, counterpartyKey: named });
1144
1385
  continue;
1145
1386
  }
1146
1387
  if (reading.findings.includes(COUNTERPART_AMBIGUOUS)) {
1147
1388
  note(COUNTERPART_AMBIGUOUS, {
1148
1389
  packRef,
1149
1390
  eventId,
1150
- count: reading.candidates.length,
1391
+ count: reading.candidateReadings.filter((c) => c.accountedFor === null).length,
1151
1392
  });
1152
1393
  }
1153
1394
  // ON `failedChecks` AND NOT ON THE VERDICT WORD. 4.1 keeps the UNION of
@@ -1168,17 +1409,54 @@ export async function readCase(packs) {
1168
1409
  contributions.push([`${packRef}/${eventId}`, "verified"]);
1169
1410
  }
1170
1411
  }
1171
- if (handoffRecords === 0)
1172
- note(CASE_HOLDS_NO_HANDOFF_RECORDS);
1412
+ // A reading with no hand-off record in it is not an exception (5.5.3 v1.20,
1413
+ // A23, D6). The undeclared packs, settled: the exception `pack_undeclared`,
1414
+ // contributing `partial`, in the slot it was given in pack order where the
1415
+ // reading holds a hand-off record; a NOTE with no contribution where it holds
1416
+ // none.
1417
+ for (const slot of undeclaredSlots) {
1418
+ if (handoffRecords > 0) {
1419
+ exceptions[slot.exceptionAt] = { kind: PACK_UNDECLARED, packRef: slot.ref };
1420
+ contributions[slot.contributionAt] = [slot.ref, EXCEPTION_VERDICT[PACK_UNDECLARED]];
1421
+ }
1422
+ else {
1423
+ caseNotes.push({ position: slot.position, note: { kind: PACK_UNDECLARED, packRef: slot.ref } });
1424
+ }
1425
+ }
1426
+ const named = exceptions.filter((e) => e !== null);
1427
+ const contributed = contributions.filter((c) => c !== null);
1428
+ // Pack order, stable. A pack carries at most one note: one with no
1429
+ // declaration withholds nothing by one.
1430
+ const notes = caseNotes
1431
+ .map((n, i) => ({ ...n, i }))
1432
+ .sort((a, b) => a.position - b.position || a.i - b.i)
1433
+ .map((n) => n.note);
1173
1434
  return {
1174
- verdict: verdictOf(exceptions, contributions),
1175
- exceptions,
1435
+ verdict: verdictOf(named, contributed),
1436
+ exceptions: named,
1176
1437
  records,
1177
- contributions,
1438
+ contributions: contributed,
1178
1439
  handoffRecords,
1179
1440
  onChainChecked: witnessChecked.length > 0 && witnessChecked.every((c) => c),
1441
+ notes,
1442
+ packs: packReadings,
1180
1443
  };
1181
1444
  }
1445
+ /**
1446
+ * `body.declaration.declaration_version` where it is one this reader knows,
1447
+ * and `null` otherwise.
1448
+ */
1449
+ function declarationVersionOf(bundle) {
1450
+ const at = (v, k) => typeof v === "object" && v !== null && !Array.isArray(v)
1451
+ ? v[k]
1452
+ : undefined;
1453
+ const declaration = at(at(bundle, "body"), "declaration");
1454
+ if (typeof declaration !== "object" || declaration === null || Array.isArray(declaration)) {
1455
+ return null;
1456
+ }
1457
+ const version = declaration["declaration_version"];
1458
+ return version === DECLARATION_VERSION || version === DECLARATION_VERSION_V2 ? version : null;
1459
+ }
1182
1460
  // ---- Presentation (optional) ------------------------------------------------
1183
1461
  /**
1184
1462
  * The house wording for one exception.
@@ -1192,6 +1470,14 @@ export function renderException(exception) {
1192
1470
  const where = `${exception.packRef}/${exception.eventId}`;
1193
1471
  switch (exception.kind) {
1194
1472
  case COUNTERPART_ABSENT:
1473
+ if (exception.count) {
1474
+ const which = exception.count === 1
1475
+ ? "its one candidate counterpart pairs"
1476
+ : `each of its ${exception.count} candidate counterparts pairs`;
1477
+ return (`${where} has no counterpart among the records this verifier read: ` +
1478
+ `${which} verified with another record signed by the key that ` +
1479
+ "signed this one, at a different sequence, and is set aside");
1480
+ }
1195
1481
  return (`${where} has no counterpart among the records this verifier read, ` +
1196
1482
  "and no pack in this case is signed by the key it names");
1197
1483
  case COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS:
@@ -1255,13 +1541,20 @@ export function renderException(exception) {
1255
1541
  return (`${exception.packRef} carries a wrapper signature over its body that ` +
1256
1542
  "does not verify, so none of its records are read and the key it " +
1257
1543
  "names is not counted as its signer");
1258
- case PACK_BUNDLE_NOT_VERIFIED: {
1259
- let head = `${exception.packRef} reads ${(exception.status ?? "").toUpperCase()} as ` +
1260
- "a bundle, together with any content handed in beside it";
1544
+ case PACK_GRADE_NOT_VERIFIED: {
1545
+ let head = `${exception.packRef} grades ${(exception.status ?? "").toUpperCase()} as a pack`;
1546
+ if (exception.causes !== undefined && exception.causes.length > 0) {
1547
+ head += ` (${exception.causes.join(", ")})`;
1548
+ }
1261
1549
  if (exception.status === "malformed")
1262
1550
  head += ", so none of its records are read";
1263
1551
  return head + because(exception.reasons);
1264
1552
  }
1553
+ case PACK_BUNDLE_CONTENT_DEPENDENT:
1554
+ return (`${exception.packRef} reads FAILED as a bundle only together with ` +
1555
+ "the unsigned content handed in beside it, which anyone could have " +
1556
+ "supplied, so whether that failure is the pack's is UNKNOWN" +
1557
+ because(exception.reasons));
1265
1558
  case RECORD_ENVELOPE_DOES_NOT_VERIFY:
1266
1559
  return (`${where} is in a pack whose wrapper verifies, and its own envelope ` +
1267
1560
  "does not verify, so it is not read as a hand-off record" +
@@ -1274,45 +1567,128 @@ export function renderException(exception) {
1274
1567
  return (`${where} is read with content that does not match what it commits ` +
1275
1568
  "to, and it is still read as a hand-off record" +
1276
1569
  because(exception.reasons));
1570
+ case RECORD_CONTENT_DEPENDENT:
1571
+ return (`${where} commits to content that the unsigned content handed in ` +
1572
+ "beside its pack does not match, and anyone could have supplied " +
1573
+ "that content, so what it says about the record is UNKNOWN; the " +
1574
+ "record is still read as a hand-off record" +
1575
+ because(exception.reasons));
1277
1576
  case RECORD_PROFILE_QUARANTINED:
1278
1577
  return (`${where} is signed under a profile this case reader does not read ` +
1279
- "while its admission is undecided (DL-63), so what it says is UNKNOWN");
1578
+ "while its admission is undecided, so what it says is UNKNOWN");
1280
1579
  case CASE_HOLDS_NO_PACKS:
1281
1580
  return "this case holds no packs";
1282
- case CASE_HOLDS_NO_HANDOFF_RECORDS:
1283
- return ("no record this verifier read in this case is a hand-off record, " +
1284
- "so no hand-off was verified");
1285
1581
  default:
1286
1582
  return exception.kind;
1287
1583
  }
1288
1584
  }
1585
+ /** The house wording for one NOTE. Offered, not imposed, as `renderException` is. */
1586
+ export function renderCaseNote(note) {
1587
+ switch (note.kind) {
1588
+ case CONTENT_WITHHELD_BY_DECLARATION:
1589
+ return (`${note.packRef} withholds the content of ${note.count} record(s) by ` +
1590
+ "its own signed declaration: their signatures and commitments were " +
1591
+ "checked, their content was not");
1592
+ case PACK_UNDECLARED:
1593
+ return `${note.packRef} does not say what it holds`;
1594
+ default:
1595
+ return note.kind;
1596
+ }
1597
+ }
1289
1598
  /**
1290
- * One line a surface can print for a whole case.
1599
+ * The scope sentence, printed directly under the headline (evidence-bundle
1600
+ * 5.5.6 v1.20, A26), byte for byte the text `render_case_summary` returns.
1601
+ *
1602
+ * The packs in the reading; the distinct keys, compared as keys, that signed
1603
+ * the wrapper of a pack whose wrapper held; the hand-off records; the
1604
+ * exceptions; and that the reading was made offline. Where any pack holds
1605
+ * records withheld by its declaration, their total; where any pack is noted
1606
+ * `pack_undeclared`, how many. Those two lines are part of what the headline
1607
+ * MEANS, and a surface MUST print them with it.
1291
1608
  *
1292
- * Both kernels print this sentence, so a reader who runs the Python command and
1293
- * the TypeScript one over one case archive reads one sentence rather than two
1294
- * that differ by a word.
1609
+ * What was not checked on the public record is no longer a clause here. It is
1610
+ * the witness line (`renderWitnessLine`), printed with every headline.
1295
1611
  */
1296
1612
  export function renderCaseSummary(reading) {
1297
- const n = reading.handoffRecords;
1298
- // What was NOT checked, in the same sentence as what was, and in the
1299
- // `verified` one above all (gate 4 on proof#140, finding 3). It is a
1300
- // statement of SCOPE and not a doubt: every pack here was verified offline,
1301
- // and no `anchor_ref` any of them names was fetched from a chain, because
1302
- // this reader makes no network call. Left unsaid, a clean case reads as
1303
- // though a witness had been checked, which nobody established. It is not an
1304
- // exception, and it must never become one: an empty exception list is the
1305
- // only shape a reader may read as clean, so a standing exception here would
1306
- // put `verified` out of reach for good.
1307
- const scope = reading.onChainChecked
1308
- ? ""
1309
- : "; no anchor_ref in it was re-fetched on chain, which this reader never does";
1310
- if (reading.verdict === "verified") {
1311
- return (`this case verifies across ${reading.records.length} record(s): ` +
1312
- `${n} hand-off record(s), every one matched, no exceptions` +
1313
- scope);
1613
+ const keys = new Set(reading.packs.filter((p) => p.grade !== null).map((p) => p.signingKey));
1614
+ const withheld = reading.packs.reduce((n, p) => n + p.recordsWithheld, 0);
1615
+ const undeclared = reading.notes.filter((n) => n.kind === PACK_UNDECLARED).length;
1616
+ const lines = [
1617
+ `${reading.packs.length} pack(s), ${keys.size} signing key(s), ` +
1618
+ `${reading.handoffRecords} hand-off record(s), ` +
1619
+ `${reading.exceptions.length} exception(s), read offline.`,
1620
+ ];
1621
+ if (withheld > 0) {
1622
+ lines.push(`${withheld} record(s)' content withheld by their holder: their ` +
1623
+ "signatures and commitments were checked, their content was not.");
1314
1624
  }
1315
- return (`this case reads ${reading.verdict.toUpperCase()} over ${n} hand-off ` +
1316
- `record(s), with ${reading.exceptions.length} exception(s)` +
1317
- scope);
1625
+ if (undeclared > 0)
1626
+ lines.push(`${undeclared} pack(s) do not say what they hold.`);
1627
+ return lines.join(" ");
1628
+ }
1629
+ // ---- The headline and the witness line (evidence-bundle 5.5.6 v1.20) -------
1630
+ /**
1631
+ * The one display title of each of the eight 3 words. A surface prints the
1632
+ * title, never the word, and never a title of its own (#173, A18). The wording
1633
+ * is Will's to change through the glossary (DL-71); what each MEANS is pinned
1634
+ * in the spec.
1635
+ */
1636
+ export const VERDICT_DISPLAY_TITLES = {
1637
+ verified: "Verified",
1638
+ partial: "Partial",
1639
+ pending: "Pending",
1640
+ incomplete: "Incomplete",
1641
+ failed: "Failed",
1642
+ malformed: "Not readable",
1643
+ not_found: "Not found",
1644
+ mixed: "Mixed",
1645
+ };
1646
+ /**
1647
+ * What a reader may rely on, for each of the six words a case reading reaches
1648
+ * (5.5.6 v1.20, A26), the same for every drop. `pending` and `not_found`
1649
+ * never head a case reading: they are witness states.
1650
+ */
1651
+ export const CASE_RELIANCE = {
1652
+ verified: "Every pack in this drop is genuine and unaltered and agrees with its " +
1653
+ "own signed declaration. Every hand-off with both sides here is " +
1654
+ "intact. Every other record is one its holder signed as a step. " +
1655
+ "Nothing here could not be checked. This was read offline, on this " +
1656
+ "device. It does not say these packs are everything the parties " +
1657
+ "recorded.",
1658
+ partial: "Nothing here was proven wrong, and a check could not be completed: a " +
1659
+ "pack names no witness, or a pack in a hand-off reading does not say " +
1660
+ "what it holds. The exceptions say which.",
1661
+ incomplete: "Nothing here was proven wrong, and part of it could not be checked: " +
1662
+ "the other side of a hand-off is not in this drop, bytes were not " +
1663
+ "handed over, or a claim could not be read. Each exception says what " +
1664
+ "is missing and, where a key is known, whose pack would supply it.",
1665
+ failed: "A check ran and did not pass. Do not rely on this drop until the " +
1666
+ "named failure is explained. The failure is named on the pack or " +
1667
+ "record it is about, and every other exception is listed beside it.",
1668
+ malformed: "Part of this drop could not be read as evidence, and nothing was " +
1669
+ "proven wrong. The exceptions name what could not be read.",
1670
+ mixed: "The readings here differ and none of them failed: some are verified " +
1671
+ "and some could not be checked. Read each pack's row and each " +
1672
+ "exception before relying on any of it.",
1673
+ };
1674
+ /** The three witness states, and how each is printed (5.5.6, D7). No other. */
1675
+ export const WITNESS_STATES = {
1676
+ confirmed: "Confirmed on the public record",
1677
+ not_checked: "Not checked",
1678
+ differs: "The public record holds different bytes",
1679
+ };
1680
+ /** The line printed where no lookup ran, which is every reading this kernel produces on its own. */
1681
+ export const WITNESS_NOT_LOOKED_UP = Object.freeze({
1682
+ state: "not_checked",
1683
+ reasons: ["no lookup was run"],
1684
+ services: [],
1685
+ });
1686
+ /** The witness line as both kernels print it. */
1687
+ export function renderWitnessLine(line) {
1688
+ let text = `Witness: ${WITNESS_STATES[line.state]}`;
1689
+ if (line.reasons.length > 0)
1690
+ text += `: ${line.reasons.join("; ")}`;
1691
+ if (line.services.length > 0)
1692
+ text += ` (answered: ${line.services.join(", ")})`;
1693
+ return text;
1318
1694
  }