@sanning/proof 0.12.0 → 0.14.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 (59) hide show
  1. package/README.md +25 -12
  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 +353 -0
  6. package/dist/case.d.ts +217 -26
  7. package/dist/case.js +535 -124
  8. package/dist/cli.d.ts +64 -8
  9. package/dist/cli.js +799 -219
  10. package/dist/compose.js +6 -1
  11. package/dist/crypto.d.ts +1 -1
  12. package/dist/crypto.js +5 -1
  13. package/dist/declaration.d.ts +58 -52
  14. package/dist/declaration.js +256 -33
  15. package/dist/evidence.d.ts +38 -0
  16. package/dist/evidence.js +238 -22
  17. package/dist/exit-codes.d.ts +17 -0
  18. package/dist/exit-codes.js +50 -0
  19. package/dist/file-match.d.ts +105 -0
  20. package/dist/file-match.js +374 -0
  21. package/dist/grading.d.ts +28 -13
  22. package/dist/grading.js +90 -31
  23. package/dist/index.d.ts +14 -7
  24. package/dist/index.js +20 -6
  25. package/dist/message-parts.d.ts +18 -10
  26. package/dist/message-parts.js +25 -15
  27. package/dist/types.d.ts +1 -0
  28. package/dist/verifier.d.ts +63 -4
  29. package/dist/verifier.js +223 -15
  30. package/dist/witness-cache.d.ts +85 -0
  31. package/dist/witness-cache.js +264 -0
  32. package/dist/zip-reader.d.ts +50 -0
  33. package/dist/zip-reader.js +220 -0
  34. package/package.json +2 -2
  35. package/src/agent-proof.ts +4 -4
  36. package/src/case-file.ts +422 -0
  37. package/src/case.ts +671 -131
  38. package/src/cli.ts +932 -217
  39. package/src/compose.ts +8 -1
  40. package/src/crypto.ts +5 -1
  41. package/src/declaration.ts +248 -52
  42. package/src/evidence.ts +257 -23
  43. package/src/exit-codes.ts +54 -0
  44. package/src/file-match.ts +461 -0
  45. package/src/grading.ts +109 -52
  46. package/src/index.ts +101 -2
  47. package/src/message-parts.ts +25 -15
  48. package/src/types.ts +5 -0
  49. package/src/verifier.ts +225 -16
  50. package/src/witness-cache.ts +306 -0
  51. package/src/zip-reader.ts +229 -0
  52. package/vectors/README.md +66 -8
  53. package/vectors/case-file-index.json +668 -0
  54. package/vectors/case-split.json +218 -9
  55. package/vectors/file-match.json +1808 -0
  56. package/vectors/jcs-reader.json +82 -3
  57. package/vectors/pack-grading.json +68 -4
  58. package/vectors/stamp-on-chain.json +20 -5
  59. package/vectors/witness-cache.json +1304 -0
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
@@ -207,7 +209,9 @@
207
209
  * verdict, exactly as `counterpart_absent` and `not_a_handoff_record` are
208
210
  * findings beside a verdict in 4.1.
209
211
  *
210
- * 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).
211
215
  *
212
216
  * WHAT THIS READER DOES NOT CLAIM
213
217
  * ===============================
@@ -237,9 +241,11 @@
237
241
  * sequence. Narrowing any of it here with `handoffs[]` is precisely the move
238
242
  * that would reopen proof#137's door.
239
243
  */
240
- import { checkDeclaration, declarationClaims, packRecords, } from "./declaration.js";
244
+ import { DECLARATION_VERSION, DECLARATION_VERSION_V2, checkDeclaration, declarationClaims, packRecords, } from "./declaration.js";
241
245
  import { NOT_A_HANDOFF_FINDING, normalizedSigningKey, readHandoffs, recordIsMalformed, } from "./handoff.js";
242
- import { verifyEvidenceBundle } from "./evidence.js";
246
+ import { coSignaturesPresent, verifyEvidenceBundle } from "./evidence.js";
247
+ import { accountDisclosure, completenessNotes, gradePack, } from "./grading.js";
248
+ import { renderWitnessTime } from "./witness-cache.js";
243
249
  // ---- The vocabulary ---------------------------------------------------------
244
250
  /**
245
251
  * The verdicts a case can reach. Every one is already in use in
@@ -578,17 +584,26 @@ export const PACK_KEY_UNREADABLE = "pack_key_unreadable";
578
584
  */
579
585
  export const PACK_DOES_NOT_VERIFY = "pack_does_not_verify";
580
586
  /**
581
- * The pack's wrapper HOLDS and its own bundle verdict, from
582
- * `verifyEvidenceBundle` run here offline over the pack and the content handed
583
- * in beside it, is not `verified`. The exception carries that verdict
584
- * (`status`) and the verifier's reasons (`reasons`: its bundle errors, then its
585
- * stamp errors, then its checkpoint errors, each as the verifier wrote it), and
586
- * it CONTRIBUTES that verdict, so the case verdict never reads above its
587
- * weakest pack. Gate 3 on proof#140: two unwitnessed (`partial`) packs read as
588
- * a `verified` case with no exception. It does not say who is at fault:
589
- * content handed in beside a pack is not signed by its holder.
587
+ * The pack's wrapper HOLDS and its CASE GRADE is not `verified`
588
+ * (evidence-bundle 5.5.1 step 1 v1.20, A23; it replaces
589
+ * `pack_bundle_not_verified`, which carried the bundle status alone). The case
590
+ * grade is the pack grade of 5.4.5, taken twice: the SIGNED grade over the run
591
+ * with the side map empty, and the FULL grade over the run with it. The signed
592
+ * grade decides where it is `failed` or `malformed`; a failure only the full
593
+ * grade reaches is `incomplete`, because it rests on bytes nobody signed;
594
+ * otherwise the full grade. A body the verifier could not read is `malformed`,
595
+ * a body that is not one of the two event-carrying bodies takes the signed
596
+ * run's status, and a verifier that graded a different number of records than
597
+ * the pack holds takes the signed status, or `malformed` where that status is
598
+ * `verified`.
599
+ *
600
+ * The exception carries `status`, the case grade; `causes`, the 5.4.5 cause
601
+ * kinds of the grade that decided it (none in the first three branches); and
602
+ * `reasons`, the verifier's reasons from the run with the side map empty. It
603
+ * CONTRIBUTES the grade, and `incomplete` for `pending`, `not_found` or
604
+ * `mixed`, so the case verdict never reads above its weakest pack.
590
605
  */
591
- export const PACK_BUNDLE_NOT_VERIFIED = "pack_bundle_not_verified";
606
+ export const PACK_GRADE_NOT_VERIFIED = "pack_grade_not_verified";
592
607
  /**
593
608
  * A record in a pack whose wrapper holds, whose OWN envelope does not verify
594
609
  * (`EventResult.envelopeOk` is false). Not read by 4.1, and NAMED with the
@@ -609,12 +624,39 @@ export const RECORD_ENVELOPE_DOES_NOT_VERIFY = "record_envelope_does_not_verify"
609
624
  export const RECORD_DOES_NOT_VERIFY_IN_ITS_PACK = "record_does_not_verify_in_its_pack";
610
625
  /**
611
626
  * A record whose envelope verifies and whose disclosed content does not hash to
612
- * the `content_hash` it commits to (`EventResult.contentOk` is false). `failed`,
613
- * and the record STAYS in the pile: content beside a pack is unsigned and often
627
+ * the hash it commits to, its `content_hash` or a hand-off record's
628
+ * `message_hash` (evidence-bundle v1.19, A20) (`EventResult.contentOk` is false), with the
629
+ * side map EMPTY: the content is inside the signed body. The same mismatch from
630
+ * content handed in beside the pack is `RECORD_CONTENT_DEPENDENT` (proof#159).
631
+ * `failed`, and the record STAYS in the pile: content beside a pack is unsigned and often
614
632
  * supplied by whoever assembled the case, and gate 3 on proof#140 showed that
615
633
  * party erasing an honest verified pair by swapping it.
616
634
  */
617
635
  export const RECORD_CONTENT_MISMATCH = "record_content_mismatch";
636
+ /**
637
+ * The two readings proof#147 did not reach, given its shape (proof#159, ruled
638
+ * by Phil 2026-09-22, evidence-bundle.md 5.5.4 v1.16, A17). A pack whose
639
+ * bundle verdict is `failed` ONLY when read with the unsigned content handed
640
+ * in beside it, and `failed` no longer when read with that map empty, is
641
+ * `PACK_BUNDLE_CONTENT_DEPENDENT`; a record whose content mismatch comes from
642
+ * that map alone is `RECORD_CONTENT_DEPENDENT`. Both are `incomplete`: the
643
+ * reader established that the bytes it was handed are not the bytes the record
644
+ * commits to, and nothing about the party that signed the record, because
645
+ * whoever assembled the case chose those bytes with no key. Up to v1.15 both
646
+ * readings carried `failed`, and one flipped nibble of accuser-supplied content
647
+ * put a `failed` against an intact pack; under A17's headline rule that would
648
+ * have become the case's headline.
649
+ *
650
+ * The test that separates the two is the one proof#159 states for the inverse:
651
+ * every deviation a signature covers still reads `failed` with the side map
652
+ * EMPTY. So the pack is verified twice, with the map and without it, and what
653
+ * fails without it is `PACK_GRADE_NOT_VERIFIED`, `RECORD_CONTENT_MISMATCH`
654
+ * (content inside the signed body) or `RECORD_DOES_NOT_VERIFY_IN_ITS_PACK`,
655
+ * each still `failed`. Emptying the map is the most a party that signed
656
+ * nothing can do to starve the check, and it cannot remove any of those.
657
+ */
658
+ export const PACK_BUNDLE_CONTENT_DEPENDENT = "pack_bundle_content_dependent";
659
+ export const RECORD_CONTENT_DEPENDENT = "record_content_dependent";
618
660
  /**
619
661
  * A record signed under a ratified profile that is under quarantine
620
662
  * (`CASE_QUARANTINED_PROFILES`, pending DL-63). Not read, in any kernel.
@@ -624,11 +666,25 @@ export const RECORD_PROFILE_QUARANTINED = "record_profile_quarantined";
624
666
  /** The reader was handed no packs at all. */
625
667
  export const CASE_HOLDS_NO_PACKS = "case_holds_no_packs";
626
668
  /**
627
- * Every pack was read and not one hand-off record came out of any of them.
628
- * There is no hand-off to have a verdict about, and saying `verified` over that
629
- * would be the defect class in its purest form.
669
+ * The three NOTEs (5.5.3 v1.20, extended proof#148): findings on the reading
670
+ * with no contribution, printed in the scope sentence so a clean verdict
671
+ * never hides any of them. `pack_undeclared` is a note only where the
672
+ * reading holds no hand-off record; where it holds one, it is the exception
673
+ * of that name.
630
674
  */
631
- export const CASE_HOLDS_NO_HANDOFF_RECORDS = "case_holds_no_handoff_records";
675
+ export const CONTENT_WITHHELD_BY_DECLARATION = "content_withheld_by_declaration";
676
+ /**
677
+ * A pack's wrapper carries a present `co_signatures` (evidence-bundle.md §2,
678
+ * decided by Phil 2026-09-24 on proof#148). The field verifies nothing and
679
+ * sits outside the wrapper's signed scope, so the case reading names it
680
+ * rather than presenting it as evidence, and it contributes no verdict.
681
+ */
682
+ export const CO_SIGNATURES_PRESENT = "co_signatures_present";
683
+ export const CASE_NOTE_KINDS = [
684
+ PACK_UNDECLARED,
685
+ CONTENT_WITHHELD_BY_DECLARATION,
686
+ CO_SIGNATURES_PRESENT,
687
+ ];
632
688
  /**
633
689
  * Every exception this reader can report, as a closed set. A surface may render
634
690
  * them and a test may assert over them; nothing outside this array is emitted.
@@ -652,13 +708,14 @@ export const CASE_EXCEPTION_KINDS = [
652
708
  PACK_HOLDS_NO_RECORDS,
653
709
  PACK_KEY_UNREADABLE,
654
710
  PACK_DOES_NOT_VERIFY,
655
- PACK_BUNDLE_NOT_VERIFIED,
711
+ PACK_GRADE_NOT_VERIFIED,
712
+ PACK_BUNDLE_CONTENT_DEPENDENT,
656
713
  RECORD_ENVELOPE_DOES_NOT_VERIFY,
657
714
  RECORD_DOES_NOT_VERIFY_IN_ITS_PACK,
658
715
  RECORD_CONTENT_MISMATCH,
716
+ RECORD_CONTENT_DEPENDENT,
659
717
  RECORD_PROFILE_QUARANTINED,
660
718
  CASE_HOLDS_NO_PACKS,
661
- CASE_HOLDS_NO_HANDOFF_RECORDS,
662
719
  ];
663
720
  /**
664
721
  * What each exception kind contributes to the case verdict. It is a TABLE and
@@ -671,10 +728,10 @@ export const CASE_EXCEPTION_KINDS = [
671
728
  * contributes a clean verdict, which is what makes the invariant in `verdictOf`
672
729
  * hold.
673
730
  *
674
- * `PACK_BUNDLE_NOT_VERIFIED` is the one row that is not a constant: it
675
- * contributes the pack's own bundle verdict, which is `partial`, `failed` or
676
- * `malformed` and never `verified` (the exception is emitted only when it is
677
- * not). `null` marks that row, and `readCase` fills it from `status`.
731
+ * `PACK_GRADE_NOT_VERIFIED` is the one row that is not a constant: it
732
+ * contributes the pack's case grade, which is never `verified` (the exception
733
+ * is emitted only when it is not). `null` marks that row, and `readCase` fills
734
+ * it from `status`.
678
735
  */
679
736
  const EXCEPTION_VERDICT = {
680
737
  [COUNTERPART_ABSENT]: "incomplete",
@@ -695,14 +752,26 @@ const EXCEPTION_VERDICT = {
695
752
  [PACK_HOLDS_NO_RECORDS]: "incomplete",
696
753
  [PACK_KEY_UNREADABLE]: "incomplete",
697
754
  [PACK_DOES_NOT_VERIFY]: "failed",
698
- [PACK_BUNDLE_NOT_VERIFIED]: null,
755
+ [PACK_GRADE_NOT_VERIFIED]: null,
756
+ [PACK_BUNDLE_CONTENT_DEPENDENT]: "incomplete",
699
757
  [RECORD_ENVELOPE_DOES_NOT_VERIFY]: "failed",
700
758
  [RECORD_DOES_NOT_VERIFY_IN_ITS_PACK]: "failed",
701
759
  [RECORD_CONTENT_MISMATCH]: "failed",
760
+ [RECORD_CONTENT_DEPENDENT]: "incomplete",
702
761
  [RECORD_PROFILE_QUARANTINED]: "incomplete",
703
762
  [CASE_HOLDS_NO_PACKS]: "partial",
704
- [CASE_HOLDS_NO_HANDOFF_RECORDS]: "partial",
705
763
  };
764
+ /**
765
+ * The case grades `PACK_GRADE_NOT_VERIFIED` contributes as themselves. Every
766
+ * other grade a pack can reach (`pending`, `not_found`, `mixed`, which a pack
767
+ * grade passes through and a case verdict never issues) is `incomplete`.
768
+ */
769
+ const GRADE_CONTRIBUTES = new Set([
770
+ "partial",
771
+ "incomplete",
772
+ "failed",
773
+ "malformed",
774
+ ]);
706
775
  // ---- The reading ------------------------------------------------------------
707
776
  const bodyTypeOf = (bundle) => typeof bundle === "object" && bundle !== null
708
777
  ? bundle["body_type"]
@@ -740,6 +809,23 @@ const wrapperHolds = (result) => result.signatureOk && result.bodyHashOk;
740
809
  * bundle-level error at all, and an exception that named the verdict and no
741
810
  * reason would send a reader looking for a defect in the pack as a whole.
742
811
  */
812
+ /**
813
+ * The reasons a reading carries only because of the side map, in the order the
814
+ * verifier wrote them: what the content-dependent kinds are about, and nothing
815
+ * a signature covers.
816
+ */
817
+ const onlyIn = (withMap, withoutMap) => withMap.filter((reason) => !withoutMap.includes(reason));
818
+ /**
819
+ * Whether a side-content map holds any key. Python's `if pack.content` is
820
+ * the twin: `None` and an empty map both mean the two verify runs are one run.
821
+ */
822
+ function sideMapHasKeys(content) {
823
+ if (content === null || content === undefined)
824
+ return false;
825
+ if (content instanceof Map)
826
+ return content.size > 0;
827
+ return Object.keys(content).length > 0;
828
+ }
743
829
  const bundleReasons = (result) => [
744
830
  ...result.errors,
745
831
  ...(result.stamps ?? []).flatMap((stamp) => stamp.errors),
@@ -775,8 +861,13 @@ function underQuarantine(specVersion) {
775
861
  /**
776
862
  * The case verdict, from the contributions alone.
777
863
  *
778
- * One outcome is that outcome; more than one is `mixed` (anchor#198,
779
- * criterion 4). `mixed` is in the 3 vocabulary and coins no word.
864
+ * **A proven failure keeps the headline** (`evidence-bundle.md` 5.5.4 v1.16,
865
+ * A17, proof#158): if any contribution is `failed`, the case is `failed`,
866
+ * whatever else contributes, and every other exception stays named beside it.
867
+ * Otherwise one outcome is that outcome, and more than one is `mixed`
868
+ * (anchor#198, criterion 4), so `mixed` never holds a `failed`. `malformed`
869
+ * does NOT keep the headline: it says a record cannot be read, not that a
870
+ * check ran and did not pass. `mixed` is in the 3 vocabulary and coins no word.
780
871
  *
781
872
  * THE INVARIANT, stated so a mutation test has something to kill: `verified` is
782
873
  * reachable ONLY when `exceptions` is empty. Every exception kind carries a
@@ -785,14 +876,25 @@ function underQuarantine(specVersion) {
785
876
  * The assertion is here anyway, because that is the property every one of the
786
877
  * seventeen findings on this program violated.
787
878
  */
788
- function verdictOf(exceptions, contributions) {
789
- // Unreachable through `readCase`, which names a case with no contributions
790
- // rather than returning one. Defended anyway: an empty aggregate reading as
791
- // `verified` is the defect class itself.
792
- if (contributions.length === 0)
793
- return "partial";
879
+ function verdictOf(exceptions, contributions, holdsPacks = true) {
880
+ // A reading that holds at least one pack and no contribution at all is
881
+ // `verified` (5.5.4 v1.20, A23): every pack in it graded `verified` and
882
+ // nothing in it was an exception. Every exception contributes, so no
883
+ // contribution means no exception. A reading with no packs always carries
884
+ // `case_holds_no_packs`, and is defended here anyway: an empty aggregate over
885
+ // nothing reading as `verified` is the defect class itself.
886
+ if (contributions.length === 0) {
887
+ if (exceptions.length > 0) {
888
+ throw new Error(`exceptions with no contribution: ${exceptions.map((e) => e.kind).join(", ")}`);
889
+ }
890
+ return holdsPacks ? "verified" : "partial";
891
+ }
794
892
  const outcomes = new Set(contributions.map(([, v]) => v));
795
- const verdict = outcomes.size === 1 ? [...outcomes][0] : "mixed";
893
+ const verdict = outcomes.has("failed")
894
+ ? "failed"
895
+ : outcomes.size === 1
896
+ ? [...outcomes][0]
897
+ : "mixed";
796
898
  if (verdict === "verified" && exceptions.length > 0) {
797
899
  throw new Error(`verified over a non-empty exception list: ${exceptions.map((e) => e.kind).join(", ")}`);
798
900
  }
@@ -826,23 +928,35 @@ function verdictOf(exceptions, contributions) {
826
928
  * see is the counterpart that is missing.
827
929
  */
828
930
  export async function readCase(packs) {
931
+ // A `null` slot is held for each pack that carries no declaration, and
932
+ // settled once the pile says whether the reading holds a hand-off record.
829
933
  const exceptions = [];
830
934
  const contributions = [];
831
935
  const note = (kind, fields = {}) => {
832
936
  exceptions.push({ kind, ...fields });
833
- // `null` is PACK_BUNDLE_NOT_VERIFIED: the pack's own verdict, never verified.
834
- const contribution = EXCEPTION_VERDICT[kind] ?? fields.status;
937
+ // `null` is PACK_GRADE_NOT_VERIFIED: the pack's case grade, never
938
+ // verified, and `incomplete` for a word a pack grade passes through and a
939
+ // case verdict never issues (5.5.1 step 1 v1.20).
940
+ let contribution = EXCEPTION_VERDICT[kind];
941
+ if (contribution === null) {
942
+ const status = fields.status;
943
+ contribution = GRADE_CONTRIBUTES.has(status) ? status : "incomplete";
944
+ }
835
945
  contributions.push([fields.eventId ?? fields.packRef ?? "case", contribution]);
836
946
  };
837
947
  if (packs.length === 0) {
838
948
  note(CASE_HOLDS_NO_PACKS);
949
+ const named = exceptions;
950
+ const contributed = contributions;
839
951
  return {
840
- verdict: verdictOf(exceptions, contributions),
841
- exceptions,
952
+ verdict: verdictOf(named, contributed, false),
953
+ exceptions: named,
842
954
  records: [],
843
- contributions,
955
+ contributions: contributed,
844
956
  handoffRecords: 0,
845
957
  onChainChecked: false,
958
+ notes: [],
959
+ packs: [],
846
960
  };
847
961
  }
848
962
  // Step 1 and 2, pack by pack, in the order the reader was given them.
@@ -884,6 +998,15 @@ export async function readCase(packs) {
884
998
  // below, from the pack envelope and never from the body, so no body type, no
885
999
  // disclosure choice and no record content decides whether it is read.
886
1000
  const holderKeys = new Set();
1001
+ // One reading per pack, in the order the reader was given them (5.5.4 v1.20).
1002
+ const packReadings = [];
1003
+ // The NOTEs (5.5.3 v1.20), with the pack position they are about.
1004
+ const caseNotes = [];
1005
+ // The role each pack's readable v2 declaration signs for each record, keyed
1006
+ // by the pack's POSITION for the reason `claimsByPack` is.
1007
+ const rolesByPack = new Map();
1008
+ // The undeclared packs' slots, settled after the pile is read.
1009
+ const undeclaredSlots = [];
887
1010
  // How many packs this reader could not tie to a key: an unreadable wrapper
888
1011
  // key, or a wrapper that does not hold. While it is not zero, "no pack in this
889
1012
  // case is signed by the named key" is not established, so the innocent
@@ -899,6 +1022,13 @@ export async function readCase(packs) {
899
1022
  // of these spellings (proof#135) and the reading must not.
900
1023
  note(PACK_KEY_UNREADABLE, { packRef: pack.ref });
901
1024
  unattributed += 1;
1025
+ packReadings.push({
1026
+ ref: pack.ref,
1027
+ signingKey: null,
1028
+ grade: null,
1029
+ recordsWithheld: 0,
1030
+ declarationVersion: null,
1031
+ });
902
1032
  continue;
903
1033
  }
904
1034
  // The precondition `HeldRecord` states, met HERE rather than trusted to a
@@ -910,28 +1040,130 @@ export async function readCase(packs) {
910
1040
  content: (pack.content ?? undefined),
911
1041
  });
912
1042
  witnessChecked.push(verified.onChainChecked);
1043
+ // The same pack with the side map EMPTY (proof#159): what fails here is
1044
+ // what a signature covers, and what fails only above rests on bytes nobody
1045
+ // signed. Skipped where there is no map, because the two runs are then the
1046
+ // same run.
1047
+ const signed = sideMapHasKeys(pack.content)
1048
+ ? await verifyEvidenceBundle(pack.bundle, {})
1049
+ : verified;
913
1050
  // WHOSE PACK IS IT. The wrapper, and only the wrapper.
914
1051
  if (!wrapperHolds(verified)) {
915
1052
  note(PACK_DOES_NOT_VERIFY, { packRef: pack.ref });
916
1053
  unattributed += 1;
1054
+ packReadings.push({
1055
+ ref: pack.ref,
1056
+ signingKey: holder,
1057
+ grade: null,
1058
+ recordsWithheld: 0,
1059
+ declarationVersion: null,
1060
+ });
917
1061
  continue;
918
1062
  }
919
1063
  holderKeys.add(holder);
920
- // WHAT IS WRONG INSIDE IT, from here on. First the pack's own assurance,
921
- // which propagates: the case never reads above its weakest pack.
922
- if (verified.status !== "verified") {
923
- note(PACK_BUNDLE_NOT_VERIFIED, {
1064
+ if (coSignaturesPresent(pack.bundle)) {
1065
+ // A NOTE, with no contribution (5.5.3 v1.20, proof#148): the wrapper's
1066
+ // co_signatures verifies nothing and sits outside its signed scope, so
1067
+ // the case reading names it rather than moving anything on its account.
1068
+ caseNotes.push({
1069
+ position,
1070
+ note: { kind: CO_SIGNATURES_PRESENT, packRef: pack.ref },
1071
+ });
1072
+ }
1073
+ const bodyType = bodyTypeOf(pack.bundle);
1074
+ const records = packRecords(pack.bundle, pack.content);
1075
+ // The per-record gradings below are joined to `records` BY INDEX, so a
1076
+ // verifier that graded a different number of records than the pack holds
1077
+ // leaves this loop with no grading to read. That is not hypothetical: a
1078
+ // pack whose `body_hash` does not cover its body reads `failed` with
1079
+ // `events` EMPTY while `packRecords` reads every record in it, and only
1080
+ // `wrapperHolds` above stands between that pack and this loop. A reader
1081
+ // must never crash on a pack a party can build, so the gap is graded rather
1082
+ // than indexed into, and the pack's records are dropped exactly as a
1083
+ // `malformed` pack's are (gate 4 on proof#140, finding 3).
1084
+ const ungraded = verified.events.length !== records.length || signed.events.length !== records.length;
1085
+ const knownBody = CASE_BODY_TYPES.includes(bodyType);
1086
+ // WHAT IS WRONG INSIDE IT, from here on. First the pack's own GRADE, which
1087
+ // propagates: the case never reads above its weakest pack (5.5.1 step 1
1088
+ // v1.20, A23; up to v1.19 it was the bundle status).
1089
+ let check = null;
1090
+ let full = null;
1091
+ let causes = [];
1092
+ let grade;
1093
+ if (signed.status === "malformed") {
1094
+ grade = "malformed";
1095
+ }
1096
+ else if (!knownBody) {
1097
+ grade = signed.status;
1098
+ }
1099
+ else if (ungraded) {
1100
+ grade = signed.status === "verified" ? "malformed" : signed.status;
1101
+ }
1102
+ else {
1103
+ check = checkDeclaration(pack.bundle, pack.content);
1104
+ const completeness = completenessNotes(pack.bundle);
1105
+ full = gradePack(verified, accountDisclosure(pack.bundle, pack.content ?? {}), completeness, check);
1106
+ const bareGrade = gradePack(signed, accountDisclosure(pack.bundle, {}), completeness, checkDeclaration(pack.bundle, null));
1107
+ // NO `failed` HERE RESTS ON BYTES NOBODY SIGNED. The signed grade decides
1108
+ // where it is `failed` or `malformed`; a failure only the side map
1109
+ // produced is `incomplete`; otherwise the full grade.
1110
+ if (bareGrade.verdict === "failed" || bareGrade.verdict === "malformed") {
1111
+ grade = bareGrade.verdict;
1112
+ causes = bareGrade.causes.map((c) => c.kind);
1113
+ }
1114
+ else if (full.verdict === "failed" || full.verdict === "malformed") {
1115
+ grade = "incomplete";
1116
+ causes = full.causes.map((c) => c.kind);
1117
+ }
1118
+ else {
1119
+ grade = full.verdict;
1120
+ causes = full.causes.map((c) => c.kind);
1121
+ }
1122
+ }
1123
+ if (grade !== "verified") {
1124
+ const reasons = bundleReasons(signed);
1125
+ if (knownBody && ungraded && signed.status !== "malformed") {
1126
+ // Said, because nothing else says it: the verifier's own reasons are
1127
+ // about the bundle, and this pack's records are dropped for a reason
1128
+ // of this reader's.
1129
+ reasons.push(`this verifier graded ${verified.events.length} of the ${records.length} ` +
1130
+ "record(s) the pack holds, so none of them is read");
1131
+ }
1132
+ note(PACK_GRADE_NOT_VERIFIED, {
924
1133
  packRef: pack.ref,
925
- status: verified.status,
926
- reasons: bundleReasons(verified),
1134
+ status: grade,
1135
+ causes,
1136
+ reasons,
1137
+ });
1138
+ }
1139
+ if (verified.status === "failed" && signed.status !== "failed") {
1140
+ note(PACK_BUNDLE_CONTENT_DEPENDENT, {
1141
+ packRef: pack.ref,
1142
+ reasons: onlyIn(bundleReasons(verified), bundleReasons(signed)),
1143
+ });
1144
+ }
1145
+ const recordsWithheld = full !== null ? full.recordsWithheld : 0;
1146
+ packReadings.push({
1147
+ ref: pack.ref,
1148
+ signingKey: holder,
1149
+ grade,
1150
+ recordsWithheld,
1151
+ declarationVersion: knownBody ? declarationVersionOf(pack.bundle) : null,
1152
+ });
1153
+ if (recordsWithheld > 0) {
1154
+ // A NOTE, with no contribution (5.5.3 v1.20): the holder signed that
1155
+ // these records' content does not travel, and the scope sentence counts
1156
+ // them, so a clean verdict never hides them.
1157
+ caseNotes.push({
1158
+ position,
1159
+ note: { kind: CONTENT_WITHHELD_BY_DECLARATION, packRef: pack.ref, count: recordsWithheld },
927
1160
  });
928
1161
  }
929
1162
  // The body is one the verifier could not read, so it graded no record, and
930
1163
  // nothing below has a grading to read. Named above.
931
- if (verified.status === "malformed")
1164
+ if (signed.status === "malformed")
932
1165
  continue;
933
- const bodyType = bodyTypeOf(pack.bundle);
934
- if (!CASE_BODY_TYPES.includes(bodyType)) {
1166
+ if (!knownBody) {
935
1167
  // `sanning.evidence.export/v2` lands here, and it is NOT unwrapped: its
936
1168
  // records sit under `body.source_bundle`, and reading them is a change to
937
1169
  // which packs this reader grades, so it is a decision of its own and not
@@ -941,58 +1173,39 @@ export async function readCase(packs) {
941
1173
  note(PACK_BODY_TYPE_UNKNOWN, { packRef: pack.ref });
942
1174
  continue;
943
1175
  }
944
- const records = packRecords(pack.bundle, pack.content);
945
- // The per-record gradings below are joined to `records` BY INDEX, so a
946
- // verifier that graded a different number of records than the pack holds
947
- // leaves this loop with no grading to read. That is not hypothetical: a
948
- // pack whose `body_hash` does not cover its body reads `failed` with
949
- // `events` EMPTY while `packRecords` reads every record in it, and only
950
- // `wrapperHolds` above stands between that pack and this loop. A reader
951
- // must never crash on a pack a party can build, so the gap is NAMED rather
952
- // than indexed into, and the pack's records are dropped exactly as a
953
- // `malformed` pack's are (gate 4 on proof#140, finding 3: under the
954
- // mutation that dropped `bodyHashOk` from the wrapper test, this kernel
955
- // threw `Cannot read properties of undefined (reading 'envelopeOk')` here
956
- // and nothing in the ladder said so).
957
- if (verified.events.length !== records.length) {
958
- note(PACK_BUNDLE_NOT_VERIFIED, {
959
- packRef: pack.ref,
960
- status: verified.status,
961
- reasons: [
962
- `this verifier graded ${verified.events.length} of the ` +
963
- `${records.length} record(s) the pack holds, so none of them is read`,
964
- ],
965
- });
1176
+ // Graded above (`malformed` where the verifier called it `verified`), and
1177
+ // none of its records are read.
1178
+ if (ungraded)
966
1179
  continue;
967
- }
968
1180
  if (records.length === 0) {
969
1181
  note(PACK_HOLDS_NO_RECORDS, { packRef: pack.ref });
970
1182
  continue;
971
1183
  }
972
- const check = checkDeclaration(pack.bundle, pack.content);
1184
+ const checked = check;
973
1185
  claimsByPack.set(position, declarationClaims(pack.bundle));
974
- if (check.contentFindings.length > 0) {
1186
+ rolesByPack.set(position, new Map((checked.roles ?? []).map((r) => [r.eventId, r.role])));
1187
+ if (checked.contentFindings.length > 0) {
975
1188
  // BESIDE the deviation exception and never instead of it: a pack can do
976
1189
  // both, over different records, and the two say different things. This
977
1190
  // one is `incomplete` because whoever assembled the case chose the bytes
978
1191
  // it turns on (proof#147).
979
1192
  note(PACK_DECLARATION_CONTENT_DEPENDENT, {
980
1193
  packRef: pack.ref,
981
- count: check.contentFindings.length,
1194
+ count: checked.contentFindings.length,
982
1195
  });
983
1196
  }
984
- if (check.verdict === "failed") {
1197
+ if (checked.verdict === "failed") {
985
1198
  note(PACK_DECLARATION_DEVIATES, {
986
1199
  packRef: pack.ref,
987
- count: check.deviations.length,
1200
+ count: checked.deviations.length,
988
1201
  });
989
1202
  }
990
- else if (check.contentFindings.length > 0) {
1203
+ else if (checked.contentFindings.length > 0) {
991
1204
  // Already named above. `partial` here is not "the reader cannot read the
992
1205
  // claim": the claim was read, and part of it rests on bytes nobody
993
1206
  // signed.
994
1207
  }
995
- else if (check.verdict === "verified") {
1208
+ else if (checked.verdict === "verified") {
996
1209
  // A pack that agrees with its own claim contributes NOTHING, rather than
997
1210
  // a `verified` of its own. The case verdict is over the hand-off
998
1211
  // readings, and criterion 4's "mixed" is about mixed HAND-OFF outcomes: a
@@ -1001,7 +1214,7 @@ export async function readCase(packs) {
1001
1214
  // read `mixed` and the failure would be the quieter half of a two-word
1002
1215
  // answer. A pack-level PROBLEM still contributes, through its exception.
1003
1216
  }
1004
- else if (check.present) {
1217
+ else if (checked.present) {
1005
1218
  // `partial` with a declaration present is 5.4.3's "a claim this reader
1006
1219
  // cannot read", which `gradePack` already carries to `incomplete`. The
1007
1220
  // same posture here, for the same reason: the field has no accept-set and
@@ -1009,7 +1222,18 @@ export async function readCase(packs) {
1009
1222
  note(PACK_DECLARATION_UNREADABLE, { packRef: pack.ref });
1010
1223
  }
1011
1224
  else {
1012
- note(PACK_UNDECLARED, { packRef: pack.ref });
1225
+ // WHAT IT CONTRIBUTES TURNS ON THE WHOLE READING (5.5.1 step 2 v1.20,
1226
+ // A23, I4): `partial` where the reading holds a hand-off record, and a
1227
+ // NOTE where it holds none. That is not known until the pile is read, so
1228
+ // the slot is held HERE, in pack order, and settled below.
1229
+ undeclaredSlots.push({
1230
+ exceptionAt: exceptions.length,
1231
+ contributionAt: contributions.length,
1232
+ position,
1233
+ ref: pack.ref,
1234
+ });
1235
+ exceptions.push(null);
1236
+ contributions.push(null);
1013
1237
  }
1014
1238
  // Each record on its OWN envelope. `packRecords` and the verifier both walk
1015
1239
  // `body.events` in order and emit one entry per element, so the index is
@@ -1017,6 +1241,7 @@ export async function readCase(packs) {
1017
1241
  for (let index = 0; index < records.length; index += 1) {
1018
1242
  const record = records[index];
1019
1243
  const graded = verified.events[index];
1244
+ const bare = signed.events[index];
1020
1245
  if (underQuarantine(recordSpecVersion(pack.bundle, index))) {
1021
1246
  note(RECORD_PROFILE_QUARANTINED, { packRef: pack.ref, eventId: record.eventId });
1022
1247
  continue;
@@ -1043,20 +1268,31 @@ export async function readCase(packs) {
1043
1268
  });
1044
1269
  continue;
1045
1270
  }
1046
- // Named, and the record stays in the pile (see the kinds).
1047
- if (graded.contentOk === false) {
1271
+ // Named, and the record stays in the pile (see the kinds). A mismatch
1272
+ // the signed body carries on its own is `failed`; one that appears only
1273
+ // with the side map is `incomplete` (proof#159).
1274
+ if (bare.contentOk === false) {
1048
1275
  note(RECORD_CONTENT_MISMATCH, {
1049
1276
  packRef: pack.ref,
1050
1277
  eventId: record.eventId,
1051
- reasons: [...graded.errors],
1278
+ reasons: [...bare.errors],
1052
1279
  });
1053
1280
  }
1054
- if (!(graded.inclusionOk && graded.checkpointBound) ||
1055
- (!graded.ok && graded.contentOk !== false)) {
1281
+ else if (graded.contentOk === false) {
1282
+ note(RECORD_CONTENT_DEPENDENT, {
1283
+ packRef: pack.ref,
1284
+ eventId: record.eventId,
1285
+ reasons: onlyIn(graded.errors, bare.errors),
1286
+ });
1287
+ }
1288
+ // Every other check on the SIGNED reading, so side content can neither
1289
+ // add a failure here nor take one away.
1290
+ if (!(bare.inclusionOk && bare.checkpointBound) ||
1291
+ (!bare.ok && bare.contentOk !== false)) {
1056
1292
  note(RECORD_DOES_NOT_VERIFY_IN_ITS_PACK, {
1057
1293
  packRef: pack.ref,
1058
1294
  eventId: record.eventId,
1059
- reasons: [...graded.errors],
1295
+ reasons: [...bare.errors],
1060
1296
  });
1061
1297
  }
1062
1298
  held.push({
@@ -1085,6 +1321,7 @@ export async function readCase(packs) {
1085
1321
  findings: [...reading.findings],
1086
1322
  failedChecks: [...reading.failedChecks],
1087
1323
  counterparts: [...reading.candidates],
1324
+ role: rolesByPack.get(position)?.get(eventId) ?? null,
1088
1325
  });
1089
1326
  // proof#137's door, named. Read from the ONE reader of the declaration
1090
1327
  // (`declarationClaims`), never from a second parse here. Only a READABLE
@@ -1098,9 +1335,13 @@ export async function readCase(packs) {
1098
1335
  // `not_a_handoff_record` exit it was a check the audited party switched off
1099
1336
  // by respelling two fields, and behind the `malformed` exit it was one a
1100
1337
  // party switched off by breaking its own record.
1338
+ //
1339
+ // v1's ALONE (5.5.1 v1.20, A21). A v2 pack's claim check is rule 9, which
1340
+ // the declaration check already ran on the signed body.
1101
1341
  const claims = claimsByPack.get(position);
1102
1342
  if (claims !== undefined &&
1103
1343
  claims.readable &&
1344
+ claims.version === DECLARATION_VERSION &&
1104
1345
  !claims.claimedSides.includes(eventId)) {
1105
1346
  note(RECORD_UNCLAIMED, { packRef, eventId });
1106
1347
  }
@@ -1190,17 +1431,54 @@ export async function readCase(packs) {
1190
1431
  contributions.push([`${packRef}/${eventId}`, "verified"]);
1191
1432
  }
1192
1433
  }
1193
- if (handoffRecords === 0)
1194
- note(CASE_HOLDS_NO_HANDOFF_RECORDS);
1434
+ // A reading with no hand-off record in it is not an exception (5.5.3 v1.20,
1435
+ // A23, D6). The undeclared packs, settled: the exception `pack_undeclared`,
1436
+ // contributing `partial`, in the slot it was given in pack order where the
1437
+ // reading holds a hand-off record; a NOTE with no contribution where it holds
1438
+ // none.
1439
+ for (const slot of undeclaredSlots) {
1440
+ if (handoffRecords > 0) {
1441
+ exceptions[slot.exceptionAt] = { kind: PACK_UNDECLARED, packRef: slot.ref };
1442
+ contributions[slot.contributionAt] = [slot.ref, EXCEPTION_VERDICT[PACK_UNDECLARED]];
1443
+ }
1444
+ else {
1445
+ caseNotes.push({ position: slot.position, note: { kind: PACK_UNDECLARED, packRef: slot.ref } });
1446
+ }
1447
+ }
1448
+ const named = exceptions.filter((e) => e !== null);
1449
+ const contributed = contributions.filter((c) => c !== null);
1450
+ // Pack order, stable. A pack carries at most one note: one with no
1451
+ // declaration withholds nothing by one.
1452
+ const notes = caseNotes
1453
+ .map((n, i) => ({ ...n, i }))
1454
+ .sort((a, b) => a.position - b.position || a.i - b.i)
1455
+ .map((n) => n.note);
1195
1456
  return {
1196
- verdict: verdictOf(exceptions, contributions),
1197
- exceptions,
1457
+ verdict: verdictOf(named, contributed),
1458
+ exceptions: named,
1198
1459
  records,
1199
- contributions,
1460
+ contributions: contributed,
1200
1461
  handoffRecords,
1201
1462
  onChainChecked: witnessChecked.length > 0 && witnessChecked.every((c) => c),
1463
+ notes,
1464
+ packs: packReadings,
1202
1465
  };
1203
1466
  }
1467
+ /**
1468
+ * `body.declaration.declaration_version` where it is one this reader knows,
1469
+ * and `null` otherwise.
1470
+ */
1471
+ function declarationVersionOf(bundle) {
1472
+ const at = (v, k) => typeof v === "object" && v !== null && !Array.isArray(v)
1473
+ ? v[k]
1474
+ : undefined;
1475
+ const declaration = at(at(bundle, "body"), "declaration");
1476
+ if (typeof declaration !== "object" || declaration === null || Array.isArray(declaration)) {
1477
+ return null;
1478
+ }
1479
+ const version = declaration["declaration_version"];
1480
+ return version === DECLARATION_VERSION || version === DECLARATION_VERSION_V2 ? version : null;
1481
+ }
1204
1482
  // ---- Presentation (optional) ------------------------------------------------
1205
1483
  /**
1206
1484
  * The house wording for one exception.
@@ -1285,13 +1563,20 @@ export function renderException(exception) {
1285
1563
  return (`${exception.packRef} carries a wrapper signature over its body that ` +
1286
1564
  "does not verify, so none of its records are read and the key it " +
1287
1565
  "names is not counted as its signer");
1288
- case PACK_BUNDLE_NOT_VERIFIED: {
1289
- let head = `${exception.packRef} reads ${(exception.status ?? "").toUpperCase()} as ` +
1290
- "a bundle, together with any content handed in beside it";
1566
+ case PACK_GRADE_NOT_VERIFIED: {
1567
+ let head = `${exception.packRef} grades ${(exception.status ?? "").toUpperCase()} as a pack`;
1568
+ if (exception.causes !== undefined && exception.causes.length > 0) {
1569
+ head += ` (${exception.causes.join(", ")})`;
1570
+ }
1291
1571
  if (exception.status === "malformed")
1292
1572
  head += ", so none of its records are read";
1293
1573
  return head + because(exception.reasons);
1294
1574
  }
1575
+ case PACK_BUNDLE_CONTENT_DEPENDENT:
1576
+ return (`${exception.packRef} reads FAILED as a bundle only together with ` +
1577
+ "the unsigned content handed in beside it, which anyone could have " +
1578
+ "supplied, so whether that failure is the pack's is UNKNOWN" +
1579
+ because(exception.reasons));
1295
1580
  case RECORD_ENVELOPE_DOES_NOT_VERIFY:
1296
1581
  return (`${where} is in a pack whose wrapper verifies, and its own envelope ` +
1297
1582
  "does not verify, so it is not read as a hand-off record" +
@@ -1304,45 +1589,171 @@ export function renderException(exception) {
1304
1589
  return (`${where} is read with content that does not match what it commits ` +
1305
1590
  "to, and it is still read as a hand-off record" +
1306
1591
  because(exception.reasons));
1592
+ case RECORD_CONTENT_DEPENDENT:
1593
+ return (`${where} commits to content that the unsigned content handed in ` +
1594
+ "beside its pack does not match, and anyone could have supplied " +
1595
+ "that content, so what it says about the record is UNKNOWN; the " +
1596
+ "record is still read as a hand-off record" +
1597
+ because(exception.reasons));
1307
1598
  case RECORD_PROFILE_QUARANTINED:
1308
1599
  return (`${where} is signed under a profile this case reader does not read ` +
1309
1600
  "while its admission is undecided, so what it says is UNKNOWN");
1310
1601
  case CASE_HOLDS_NO_PACKS:
1311
1602
  return "this case holds no packs";
1312
- case CASE_HOLDS_NO_HANDOFF_RECORDS:
1313
- return ("no record this verifier read in this case is a hand-off record, " +
1314
- "so no hand-off was verified");
1315
1603
  default:
1316
1604
  return exception.kind;
1317
1605
  }
1318
1606
  }
1607
+ /** The house wording for one NOTE. Offered, not imposed, as `renderException` is. */
1608
+ export function renderCaseNote(note) {
1609
+ switch (note.kind) {
1610
+ case CONTENT_WITHHELD_BY_DECLARATION:
1611
+ return (`${note.packRef} withholds the content of ${note.count} record(s) by ` +
1612
+ "its own signed declaration: their signatures and commitments were " +
1613
+ "checked, their content was not");
1614
+ case PACK_UNDECLARED:
1615
+ return `${note.packRef} does not say what it holds`;
1616
+ case CO_SIGNATURES_PRESENT:
1617
+ return (`${note.packRef} carries unverified co-signatures in its wrapper; ` +
1618
+ "they are not part of the evidence");
1619
+ default:
1620
+ return note.kind;
1621
+ }
1622
+ }
1319
1623
  /**
1320
- * One line a surface can print for a whole case.
1624
+ * The scope sentence, printed directly under the headline (evidence-bundle
1625
+ * 5.5.6 v1.20, A26), byte for byte the text `render_case_summary` returns.
1626
+ *
1627
+ * The packs in the reading; the distinct keys, compared as keys, that signed
1628
+ * the wrapper of a pack whose wrapper held; the hand-off records; the
1629
+ * exceptions; and that the reading was made offline. Where any pack holds
1630
+ * records withheld by its declaration, their total; where any pack is noted
1631
+ * `pack_undeclared`, how many. Those two lines are part of what the headline
1632
+ * MEANS, and a surface MUST print them with it.
1321
1633
  *
1322
- * Both kernels print this sentence, so a reader who runs the Python command and
1323
- * the TypeScript one over one case archive reads one sentence rather than two
1324
- * that differ by a word.
1634
+ * What was not checked on the public record is no longer a clause here. It is
1635
+ * the witness line (`renderWitnessLine`), printed with every headline.
1325
1636
  */
1326
1637
  export function renderCaseSummary(reading) {
1327
- const n = reading.handoffRecords;
1328
- // What was NOT checked, in the same sentence as what was, and in the
1329
- // `verified` one above all (gate 4 on proof#140, finding 3). It is a
1330
- // statement of SCOPE and not a doubt: every pack here was verified offline,
1331
- // and no `anchor_ref` any of them names was fetched from a chain, because
1332
- // this reader makes no network call. Left unsaid, a clean case reads as
1333
- // though a witness had been checked, which nobody established. It is not an
1334
- // exception, and it must never become one: an empty exception list is the
1335
- // only shape a reader may read as clean, so a standing exception here would
1336
- // put `verified` out of reach for good.
1337
- const scope = reading.onChainChecked
1338
- ? ""
1339
- : "; no anchor_ref in it was re-fetched on chain, which this reader never does";
1340
- if (reading.verdict === "verified") {
1341
- return (`this case verifies across ${reading.records.length} record(s): ` +
1342
- `${n} hand-off record(s), every one matched, no exceptions` +
1343
- scope);
1638
+ const keys = new Set(reading.packs.filter((p) => p.grade !== null).map((p) => p.signingKey));
1639
+ const withheld = reading.packs.reduce((n, p) => n + p.recordsWithheld, 0);
1640
+ const undeclared = reading.notes.filter((n) => n.kind === PACK_UNDECLARED).length;
1641
+ const lines = [
1642
+ `${reading.packs.length} pack(s), ${keys.size} signing key(s), ` +
1643
+ `${reading.handoffRecords} hand-off record(s), ` +
1644
+ `${reading.exceptions.length} exception(s), read offline.`,
1645
+ ];
1646
+ if (withheld > 0) {
1647
+ lines.push(`${withheld} record(s)' content withheld by their holder: their ` +
1648
+ "signatures and commitments were checked, their content was not.");
1649
+ }
1650
+ if (undeclared > 0)
1651
+ lines.push(`${undeclared} pack(s) do not say what they hold.`);
1652
+ return lines.join(" ");
1653
+ }
1654
+ // ---- The headline and the witness line (evidence-bundle 5.5.6 v1.20) -------
1655
+ /**
1656
+ * The one display title of each of the eight 3 words. A surface prints the
1657
+ * title, never the word, and never a title of its own (#173, A18). The wording
1658
+ * is Will's to change through the glossary (DL-71); what each MEANS is pinned
1659
+ * in the spec.
1660
+ */
1661
+ export const VERDICT_DISPLAY_TITLES = {
1662
+ verified: "Verified",
1663
+ partial: "Partial",
1664
+ pending: "Pending",
1665
+ incomplete: "Incomplete",
1666
+ failed: "Failed",
1667
+ malformed: "Not readable",
1668
+ not_found: "Not found",
1669
+ mixed: "Mixed",
1670
+ };
1671
+ /**
1672
+ * What a reader may rely on, for each of the six words a case reading reaches
1673
+ * (5.5.6 v1.20, A26; the `verified` text at v1.28, A35), the same for every
1674
+ * drop. `pending` and `not_found` never head a case reading: they are
1675
+ * witness states. `verified` is the base sentence, printed alone where the
1676
+ * drop holds no hand-off record; a caller reads `caseReliance` for
1677
+ * `verified`, never this mapping directly.
1678
+ */
1679
+ export const CASE_RELIANCE = {
1680
+ verified: "Every pack in this drop is genuine, unaltered, and matches its own " +
1681
+ "signed declaration. Every record in it was signed by its holder's " +
1682
+ "key. This was read offline, on this device. It does not say this " +
1683
+ "drop holds everything that was recorded.",
1684
+ partial: "Nothing here was proven wrong, and a check could not be completed: a " +
1685
+ "pack names no witness, or a pack in a hand-off reading does not say " +
1686
+ "what it holds. The exceptions say which.",
1687
+ incomplete: "Nothing here was proven wrong, and part of it could not be checked: " +
1688
+ "the other side of a hand-off is not in this drop, bytes were not " +
1689
+ "handed over, or a claim could not be read. Each exception says what " +
1690
+ "is missing and, where a key is known, whose pack would supply it.",
1691
+ failed: "A check ran and did not pass. Do not rely on this drop until the " +
1692
+ "named failure is explained. The failure is named on the pack or " +
1693
+ "record it is about, and every other exception is listed beside it.",
1694
+ malformed: "Part of this drop could not be read as evidence, and nothing was " +
1695
+ "proven wrong. The exceptions name what could not be read.",
1696
+ mixed: "The readings here differ and none of them failed: some are verified " +
1697
+ "and some could not be checked. Read each pack's row and each " +
1698
+ "exception before relying on any of it.",
1699
+ };
1700
+ /**
1701
+ * The clause a `verified` drop that holds a hand-off record gains, inserted
1702
+ * after `CASE_RELIANCE`'s first sentence (5.5.6, A35). Never printed alone,
1703
+ * and never where `handoffRecords` is zero: most drops hold one agent's
1704
+ * pack and no hand-off to say anything about.
1705
+ */
1706
+ export const CASE_RELIANCE_HANDOFF_CLAUSE = "Every hand-off with both sides in this drop is intact.";
1707
+ /**
1708
+ * What a reader may rely on for one case reading (5.5.6 v1.28, A35), byte
1709
+ * for byte the text `case_reliance`'s Python sibling returns.
1710
+ *
1711
+ * For every word but `verified` this is `CASE_RELIANCE` unchanged. For
1712
+ * `verified`, the hand-off clause is inserted after the first sentence
1713
+ * where `handoffRecords` is not zero, and left out where it is zero: the
1714
+ * base sentence never claims a hand-off the drop does not hold.
1715
+ */
1716
+ export function caseReliance(verdict, handoffRecords) {
1717
+ if (verdict === "verified" && handoffRecords > 0) {
1718
+ return ("Every pack in this drop is genuine, unaltered, and matches its own " +
1719
+ "signed declaration. Every hand-off with both sides in this drop is " +
1720
+ "intact. Every record in it was signed by its holder's key. This " +
1721
+ "was read offline, on this device. It does not say this drop holds " +
1722
+ "everything that was recorded.");
1723
+ }
1724
+ return CASE_RELIANCE[verdict];
1725
+ }
1726
+ /** The three witness states, and how each is printed (5.5.6, D7). No other. */
1727
+ export const WITNESS_STATES = {
1728
+ confirmed: "Confirmed on the public record",
1729
+ not_checked: "Not checked",
1730
+ differs: "The public record holds different bytes",
1731
+ };
1732
+ /** The line printed where no lookup ran, which is every reading this kernel produces on its own. */
1733
+ export const WITNESS_NOT_LOOKED_UP = Object.freeze({
1734
+ state: "not_checked",
1735
+ reasons: ["no lookup was run"],
1736
+ services: [],
1737
+ });
1738
+ /** The witness line as both kernels print it. */
1739
+ /** How a witness time a pack caches is printed where nothing checked it (5.5.6, 5.6.6, v1.25, A30). */
1740
+ export const WITNESS_TIME_CACHED = "the pack dates the witness, not checked";
1741
+ /** How a witness time the public record returned is printed where it matched the cache. */
1742
+ export const WITNESS_TIME_RECORDED = "the public record dates the witness";
1743
+ function witnessTimes(times) {
1744
+ return times.map(([h, t]) => renderWitnessTime(h, t)).join(", ");
1745
+ }
1746
+ export function renderWitnessLine(line) {
1747
+ let text = `Witness: ${WITNESS_STATES[line.state]}`;
1748
+ const parts = [...line.reasons];
1749
+ if (line.cached && line.cached.length > 0)
1750
+ parts.push(`${WITNESS_TIME_CACHED}: ${witnessTimes(line.cached)}`);
1751
+ if (line.recorded && line.recorded.length > 0) {
1752
+ parts.push(`${WITNESS_TIME_RECORDED}: ${witnessTimes(line.recorded)}`);
1344
1753
  }
1345
- return (`this case reads ${reading.verdict.toUpperCase()} over ${n} hand-off ` +
1346
- `record(s), with ${reading.exceptions.length} exception(s)` +
1347
- scope);
1754
+ if (parts.length > 0)
1755
+ text += `: ${parts.join("; ")}`;
1756
+ if (line.services.length > 0)
1757
+ text += ` (answered: ${line.services.join(", ")})`;
1758
+ return text;
1348
1759
  }