@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/src/case.ts 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
  * ===============================
@@ -239,7 +243,11 @@
239
243
  */
240
244
 
241
245
  import {
246
+ type DeclarationCheck,
242
247
  type DeclarationClaims,
248
+ type DeclarationRole,
249
+ DECLARATION_VERSION,
250
+ DECLARATION_VERSION_V2,
243
251
  checkDeclaration,
244
252
  declarationClaims,
245
253
  packRecords,
@@ -252,10 +260,18 @@ import {
252
260
  readHandoffs,
253
261
  recordIsMalformed,
254
262
  } from "./handoff.js";
255
- import { verifyEvidenceBundle } from "./evidence.js";
263
+ import { coSignaturesPresent, verifyEvidenceBundle } from "./evidence.js";
256
264
  import type { EvidenceBundleResult } from "./evidence.js";
257
- import type { PackVerdict } from "./grading.js";
265
+ import {
266
+ type PackGrade,
267
+ type PackGradeCauseKind,
268
+ type PackVerdict,
269
+ accountDisclosure,
270
+ completenessNotes,
271
+ gradePack,
272
+ } from "./grading.js";
258
273
  import type { SideContentMap } from "./side-content.js";
274
+ import { renderWitnessTime } from "./witness-cache.js";
259
275
 
260
276
  // ---- The vocabulary ---------------------------------------------------------
261
277
 
@@ -623,17 +639,26 @@ export const PACK_KEY_UNREADABLE = "pack_key_unreadable";
623
639
  export const PACK_DOES_NOT_VERIFY = "pack_does_not_verify";
624
640
 
625
641
  /**
626
- * The pack's wrapper HOLDS and its own bundle verdict, from
627
- * `verifyEvidenceBundle` run here offline over the pack and the content handed
628
- * in beside it, is not `verified`. The exception carries that verdict
629
- * (`status`) and the verifier's reasons (`reasons`: its bundle errors, then its
630
- * stamp errors, then its checkpoint errors, each as the verifier wrote it), and
631
- * it CONTRIBUTES that verdict, so the case verdict never reads above its
632
- * weakest pack. Gate 3 on proof#140: two unwitnessed (`partial`) packs read as
633
- * a `verified` case with no exception. It does not say who is at fault:
634
- * content handed in beside a pack is not signed by its holder.
642
+ * The pack's wrapper HOLDS and its CASE GRADE is not `verified`
643
+ * (evidence-bundle 5.5.1 step 1 v1.20, A23; it replaces
644
+ * `pack_bundle_not_verified`, which carried the bundle status alone). The case
645
+ * grade is the pack grade of 5.4.5, taken twice: the SIGNED grade over the run
646
+ * with the side map empty, and the FULL grade over the run with it. The signed
647
+ * grade decides where it is `failed` or `malformed`; a failure only the full
648
+ * grade reaches is `incomplete`, because it rests on bytes nobody signed;
649
+ * otherwise the full grade. A body the verifier could not read is `malformed`,
650
+ * a body that is not one of the two event-carrying bodies takes the signed
651
+ * run's status, and a verifier that graded a different number of records than
652
+ * the pack holds takes the signed status, or `malformed` where that status is
653
+ * `verified`.
654
+ *
655
+ * The exception carries `status`, the case grade; `causes`, the 5.4.5 cause
656
+ * kinds of the grade that decided it (none in the first three branches); and
657
+ * `reasons`, the verifier's reasons from the run with the side map empty. It
658
+ * CONTRIBUTES the grade, and `incomplete` for `pending`, `not_found` or
659
+ * `mixed`, so the case verdict never reads above its weakest pack.
635
660
  */
636
- export const PACK_BUNDLE_NOT_VERIFIED = "pack_bundle_not_verified";
661
+ export const PACK_GRADE_NOT_VERIFIED = "pack_grade_not_verified";
637
662
 
638
663
  /**
639
664
  * A record in a pack whose wrapper holds, whose OWN envelope does not verify
@@ -657,13 +682,41 @@ export const RECORD_DOES_NOT_VERIFY_IN_ITS_PACK = "record_does_not_verify_in_its
657
682
 
658
683
  /**
659
684
  * A record whose envelope verifies and whose disclosed content does not hash to
660
- * the `content_hash` it commits to (`EventResult.contentOk` is false). `failed`,
661
- * and the record STAYS in the pile: content beside a pack is unsigned and often
685
+ * the hash it commits to, its `content_hash` or a hand-off record's
686
+ * `message_hash` (evidence-bundle v1.19, A20) (`EventResult.contentOk` is false), with the
687
+ * side map EMPTY: the content is inside the signed body. The same mismatch from
688
+ * content handed in beside the pack is `RECORD_CONTENT_DEPENDENT` (proof#159).
689
+ * `failed`, and the record STAYS in the pile: content beside a pack is unsigned and often
662
690
  * supplied by whoever assembled the case, and gate 3 on proof#140 showed that
663
691
  * party erasing an honest verified pair by swapping it.
664
692
  */
665
693
  export const RECORD_CONTENT_MISMATCH = "record_content_mismatch";
666
694
 
695
+ /**
696
+ * The two readings proof#147 did not reach, given its shape (proof#159, ruled
697
+ * by Phil 2026-09-22, evidence-bundle.md 5.5.4 v1.16, A17). A pack whose
698
+ * bundle verdict is `failed` ONLY when read with the unsigned content handed
699
+ * in beside it, and `failed` no longer when read with that map empty, is
700
+ * `PACK_BUNDLE_CONTENT_DEPENDENT`; a record whose content mismatch comes from
701
+ * that map alone is `RECORD_CONTENT_DEPENDENT`. Both are `incomplete`: the
702
+ * reader established that the bytes it was handed are not the bytes the record
703
+ * commits to, and nothing about the party that signed the record, because
704
+ * whoever assembled the case chose those bytes with no key. Up to v1.15 both
705
+ * readings carried `failed`, and one flipped nibble of accuser-supplied content
706
+ * put a `failed` against an intact pack; under A17's headline rule that would
707
+ * have become the case's headline.
708
+ *
709
+ * The test that separates the two is the one proof#159 states for the inverse:
710
+ * every deviation a signature covers still reads `failed` with the side map
711
+ * EMPTY. So the pack is verified twice, with the map and without it, and what
712
+ * fails without it is `PACK_GRADE_NOT_VERIFIED`, `RECORD_CONTENT_MISMATCH`
713
+ * (content inside the signed body) or `RECORD_DOES_NOT_VERIFY_IN_ITS_PACK`,
714
+ * each still `failed`. Emptying the map is the most a party that signed
715
+ * nothing can do to starve the check, and it cannot remove any of those.
716
+ */
717
+ export const PACK_BUNDLE_CONTENT_DEPENDENT = "pack_bundle_content_dependent";
718
+ export const RECORD_CONTENT_DEPENDENT = "record_content_dependent";
719
+
667
720
  /**
668
721
  * A record signed under a ratified profile that is under quarantine
669
722
  * (`CASE_QUARANTINED_PROFILES`, pending DL-63). Not read, in any kernel.
@@ -675,11 +728,26 @@ export const RECORD_PROFILE_QUARANTINED = "record_profile_quarantined";
675
728
  export const CASE_HOLDS_NO_PACKS = "case_holds_no_packs";
676
729
 
677
730
  /**
678
- * Every pack was read and not one hand-off record came out of any of them.
679
- * There is no hand-off to have a verdict about, and saying `verified` over that
680
- * would be the defect class in its purest form.
731
+ * The three NOTEs (5.5.3 v1.20, extended proof#148): findings on the reading
732
+ * with no contribution, printed in the scope sentence so a clean verdict
733
+ * never hides any of them. `pack_undeclared` is a note only where the
734
+ * reading holds no hand-off record; where it holds one, it is the exception
735
+ * of that name.
736
+ */
737
+ export const CONTENT_WITHHELD_BY_DECLARATION = "content_withheld_by_declaration";
738
+ /**
739
+ * A pack's wrapper carries a present `co_signatures` (evidence-bundle.md §2,
740
+ * decided by Phil 2026-09-24 on proof#148). The field verifies nothing and
741
+ * sits outside the wrapper's signed scope, so the case reading names it
742
+ * rather than presenting it as evidence, and it contributes no verdict.
681
743
  */
682
- export const CASE_HOLDS_NO_HANDOFF_RECORDS = "case_holds_no_handoff_records";
744
+ export const CO_SIGNATURES_PRESENT = "co_signatures_present";
745
+ export const CASE_NOTE_KINDS = [
746
+ PACK_UNDECLARED,
747
+ CONTENT_WITHHELD_BY_DECLARATION,
748
+ CO_SIGNATURES_PRESENT,
749
+ ] as const;
750
+ export type CaseNoteKind = (typeof CASE_NOTE_KINDS)[number];
683
751
 
684
752
  /**
685
753
  * Every exception this reader can report, as a closed set. A surface may render
@@ -704,13 +772,14 @@ export const CASE_EXCEPTION_KINDS = [
704
772
  PACK_HOLDS_NO_RECORDS,
705
773
  PACK_KEY_UNREADABLE,
706
774
  PACK_DOES_NOT_VERIFY,
707
- PACK_BUNDLE_NOT_VERIFIED,
775
+ PACK_GRADE_NOT_VERIFIED,
776
+ PACK_BUNDLE_CONTENT_DEPENDENT,
708
777
  RECORD_ENVELOPE_DOES_NOT_VERIFY,
709
778
  RECORD_DOES_NOT_VERIFY_IN_ITS_PACK,
710
779
  RECORD_CONTENT_MISMATCH,
780
+ RECORD_CONTENT_DEPENDENT,
711
781
  RECORD_PROFILE_QUARANTINED,
712
782
  CASE_HOLDS_NO_PACKS,
713
- CASE_HOLDS_NO_HANDOFF_RECORDS,
714
783
  ] as const;
715
784
 
716
785
  export type CaseExceptionKind = (typeof CASE_EXCEPTION_KINDS)[number];
@@ -726,10 +795,10 @@ export type CaseExceptionKind = (typeof CASE_EXCEPTION_KINDS)[number];
726
795
  * contributes a clean verdict, which is what makes the invariant in `verdictOf`
727
796
  * hold.
728
797
  *
729
- * `PACK_BUNDLE_NOT_VERIFIED` is the one row that is not a constant: it
730
- * contributes the pack's own bundle verdict, which is `partial`, `failed` or
731
- * `malformed` and never `verified` (the exception is emitted only when it is
732
- * not). `null` marks that row, and `readCase` fills it from `status`.
798
+ * `PACK_GRADE_NOT_VERIFIED` is the one row that is not a constant: it
799
+ * contributes the pack's case grade, which is never `verified` (the exception
800
+ * is emitted only when it is not). `null` marks that row, and `readCase` fills
801
+ * it from `status`.
733
802
  */
734
803
  const EXCEPTION_VERDICT: Record<CaseExceptionKind, CaseVerdict | null> = {
735
804
  [COUNTERPART_ABSENT]: "incomplete",
@@ -750,15 +819,28 @@ const EXCEPTION_VERDICT: Record<CaseExceptionKind, CaseVerdict | null> = {
750
819
  [PACK_HOLDS_NO_RECORDS]: "incomplete",
751
820
  [PACK_KEY_UNREADABLE]: "incomplete",
752
821
  [PACK_DOES_NOT_VERIFY]: "failed",
753
- [PACK_BUNDLE_NOT_VERIFIED]: null,
822
+ [PACK_GRADE_NOT_VERIFIED]: null,
823
+ [PACK_BUNDLE_CONTENT_DEPENDENT]: "incomplete",
754
824
  [RECORD_ENVELOPE_DOES_NOT_VERIFY]: "failed",
755
825
  [RECORD_DOES_NOT_VERIFY_IN_ITS_PACK]: "failed",
756
826
  [RECORD_CONTENT_MISMATCH]: "failed",
827
+ [RECORD_CONTENT_DEPENDENT]: "incomplete",
757
828
  [RECORD_PROFILE_QUARANTINED]: "incomplete",
758
829
  [CASE_HOLDS_NO_PACKS]: "partial",
759
- [CASE_HOLDS_NO_HANDOFF_RECORDS]: "partial",
760
830
  };
761
831
 
832
+ /**
833
+ * The case grades `PACK_GRADE_NOT_VERIFIED` contributes as themselves. Every
834
+ * other grade a pack can reach (`pending`, `not_found`, `mixed`, which a pack
835
+ * grade passes through and a case verdict never issues) is `incomplete`.
836
+ */
837
+ const GRADE_CONTRIBUTES: ReadonlySet<string> = new Set([
838
+ "partial",
839
+ "incomplete",
840
+ "failed",
841
+ "malformed",
842
+ ]);
843
+
762
844
  // ---- Inputs -----------------------------------------------------------------
763
845
 
764
846
  /**
@@ -804,11 +886,18 @@ export interface CaseException {
804
886
  * (v1.17, A20), and it is how many were set aside.
805
887
  */
806
888
  count?: number;
807
- /** The pack's own bundle verdict, for `PACK_BUNDLE_NOT_VERIFIED`. */
889
+ /** The pack's case grade, for `PACK_GRADE_NOT_VERIFIED`. */
808
890
  status?: CaseVerdict;
891
+ /**
892
+ * The 5.4.5 cause kinds of the grade that decided the case grade, for
893
+ * `PACK_GRADE_NOT_VERIFIED`, in the grade's order. Empty where the grade was
894
+ * not computed (an unreadable body, a body that is not one of the two
895
+ * event-carrying ones, a verifier that graded too few records).
896
+ */
897
+ causes?: PackGradeCauseKind[];
809
898
  /**
810
899
  * What the bundle verifier said, as it said it, for the kinds that carry a
811
- * verifier's grading: `PACK_BUNDLE_NOT_VERIFIED` and the three record kinds
900
+ * verifier's grading: `PACK_GRADE_NOT_VERIFIED` and the three record kinds
812
901
  * graded on the record's own envelope and content.
813
902
  */
814
903
  reasons?: string[];
@@ -830,6 +919,54 @@ export interface CaseRecordReading {
830
919
  findings: string[];
831
920
  failedChecks: string[];
832
921
  counterparts: string[];
922
+ /**
923
+ * The role the pack's v2 declaration signs for this record, `handoff` or
924
+ * `step`, or `null` where the pack's declaration is absent, unreadable or
925
+ * v1, or does not declare the record (5.5.1 v1.20).
926
+ */
927
+ role: DeclarationRole | null;
928
+ }
929
+
930
+ /**
931
+ * One NOTE on the reading (5.5.3 v1.20): something true a reader needs and
932
+ * nothing a reader could not tell, so it carries no contribution.
933
+ */
934
+ export interface CaseNote {
935
+ kind: CaseNoteKind;
936
+ /** Which pack, by the reader's own name for it. */
937
+ packRef: string;
938
+ /**
939
+ * For `CONTENT_WITHHELD_BY_DECLARATION`, how many of the pack's records its
940
+ * declaration withholds.
941
+ */
942
+ count?: number;
943
+ }
944
+
945
+ /**
946
+ * One pack as the reading read it, in the order the reader was given them
947
+ * (5.5.4 v1.20). A surface names a pack by `signingKey`, the key that signed
948
+ * its wrapper, and never by an organization (D10).
949
+ */
950
+ export interface CasePackReading {
951
+ ref: string;
952
+ /** The wrapper key, normalized, or `null` where this reader could not read it. */
953
+ signingKey: string | null;
954
+ /**
955
+ * The case grade (5.5.1 step 1), or `null` where the wrapper did not hold or
956
+ * the key could not be read.
957
+ */
958
+ grade: PackVerdict | null;
959
+ /**
960
+ * Records withheld by the pack's declaration (5.4.5): in scope for their
961
+ * signature and commitment only. Never counted as verified records.
962
+ */
963
+ recordsWithheld: number;
964
+ /**
965
+ * `sanning.declaration/v1`, `sanning.declaration/v2`, or `null` where the
966
+ * pack carries no declaration, one this reader does not know, or a body this
967
+ * reader does not read.
968
+ */
969
+ declarationVersion: string | null;
833
970
  }
834
971
 
835
972
  export interface CaseReading {
@@ -873,6 +1010,10 @@ export interface CaseReading {
873
1010
  * checked packs, which is false.
874
1011
  */
875
1012
  onChainChecked: boolean;
1013
+ /** The NOTEs, in pack order (5.5.3 v1.20). No contribution. */
1014
+ notes: CaseNote[];
1015
+ /** One reading per pack, in the order the reader was given them. */
1016
+ packs: CasePackReading[];
876
1017
  }
877
1018
 
878
1019
  // ---- The reading ------------------------------------------------------------
@@ -919,6 +1060,24 @@ const wrapperHolds = (result: EvidenceBundleResult): boolean =>
919
1060
  * bundle-level error at all, and an exception that named the verdict and no
920
1061
  * reason would send a reader looking for a defect in the pack as a whole.
921
1062
  */
1063
+ /**
1064
+ * The reasons a reading carries only because of the side map, in the order the
1065
+ * verifier wrote them: what the content-dependent kinds are about, and nothing
1066
+ * a signature covers.
1067
+ */
1068
+ const onlyIn = (withMap: readonly string[], withoutMap: readonly string[]): string[] =>
1069
+ withMap.filter((reason) => !withoutMap.includes(reason));
1070
+
1071
+ /**
1072
+ * Whether a side-content map holds any key. Python's `if pack.content` is
1073
+ * the twin: `None` and an empty map both mean the two verify runs are one run.
1074
+ */
1075
+ function sideMapHasKeys(content: CasePack["content"]): boolean {
1076
+ if (content === null || content === undefined) return false;
1077
+ if (content instanceof Map) return content.size > 0;
1078
+ return Object.keys(content as object).length > 0;
1079
+ }
1080
+
922
1081
  const bundleReasons = (result: EvidenceBundleResult): string[] => [
923
1082
  ...result.errors,
924
1083
  ...(result.stamps ?? []).flatMap((stamp) => stamp.errors),
@@ -955,8 +1114,13 @@ function underQuarantine(specVersion: unknown): boolean {
955
1114
  /**
956
1115
  * The case verdict, from the contributions alone.
957
1116
  *
958
- * One outcome is that outcome; more than one is `mixed` (anchor#198,
959
- * criterion 4). `mixed` is in the 3 vocabulary and coins no word.
1117
+ * **A proven failure keeps the headline** (`evidence-bundle.md` 5.5.4 v1.16,
1118
+ * A17, proof#158): if any contribution is `failed`, the case is `failed`,
1119
+ * whatever else contributes, and every other exception stays named beside it.
1120
+ * Otherwise one outcome is that outcome, and more than one is `mixed`
1121
+ * (anchor#198, criterion 4), so `mixed` never holds a `failed`. `malformed`
1122
+ * does NOT keep the headline: it says a record cannot be read, not that a
1123
+ * check ran and did not pass. `mixed` is in the 3 vocabulary and coins no word.
960
1124
  *
961
1125
  * THE INVARIANT, stated so a mutation test has something to kill: `verified` is
962
1126
  * reachable ONLY when `exceptions` is empty. Every exception kind carries a
@@ -968,13 +1132,26 @@ function underQuarantine(specVersion: unknown): boolean {
968
1132
  function verdictOf(
969
1133
  exceptions: readonly CaseException[],
970
1134
  contributions: readonly [string, CaseVerdict][],
1135
+ holdsPacks = true,
971
1136
  ): CaseVerdict {
972
- // Unreachable through `readCase`, which names a case with no contributions
973
- // rather than returning one. Defended anyway: an empty aggregate reading as
974
- // `verified` is the defect class itself.
975
- if (contributions.length === 0) return "partial";
1137
+ // A reading that holds at least one pack and no contribution at all is
1138
+ // `verified` (5.5.4 v1.20, A23): every pack in it graded `verified` and
1139
+ // nothing in it was an exception. Every exception contributes, so no
1140
+ // contribution means no exception. A reading with no packs always carries
1141
+ // `case_holds_no_packs`, and is defended here anyway: an empty aggregate over
1142
+ // nothing reading as `verified` is the defect class itself.
1143
+ if (contributions.length === 0) {
1144
+ if (exceptions.length > 0) {
1145
+ throw new Error(`exceptions with no contribution: ${exceptions.map((e) => e.kind).join(", ")}`);
1146
+ }
1147
+ return holdsPacks ? "verified" : "partial";
1148
+ }
976
1149
  const outcomes = new Set(contributions.map(([, v]) => v));
977
- const verdict: CaseVerdict = outcomes.size === 1 ? [...outcomes][0] : "mixed";
1150
+ const verdict: CaseVerdict = outcomes.has("failed")
1151
+ ? "failed"
1152
+ : outcomes.size === 1
1153
+ ? [...outcomes][0]
1154
+ : "mixed";
978
1155
  if (verdict === "verified" && exceptions.length > 0) {
979
1156
  throw new Error(
980
1157
  `verified over a non-empty exception list: ${exceptions.map((e) => e.kind).join(", ")}`,
@@ -1013,28 +1190,40 @@ function verdictOf(
1013
1190
  export async function readCase(
1014
1191
  packs: readonly CasePack[],
1015
1192
  ): Promise<CaseReading> {
1016
- const exceptions: CaseException[] = [];
1017
- const contributions: [string, CaseVerdict][] = [];
1193
+ // A `null` slot is held for each pack that carries no declaration, and
1194
+ // settled once the pile says whether the reading holds a hand-off record.
1195
+ const exceptions: (CaseException | null)[] = [];
1196
+ const contributions: ([string, CaseVerdict] | null)[] = [];
1018
1197
 
1019
1198
  const note = (
1020
1199
  kind: CaseExceptionKind,
1021
1200
  fields: Omit<CaseException, "kind"> = {},
1022
1201
  ) => {
1023
1202
  exceptions.push({ kind, ...fields });
1024
- // `null` is PACK_BUNDLE_NOT_VERIFIED: the pack's own verdict, never verified.
1025
- const contribution = EXCEPTION_VERDICT[kind] ?? (fields.status as CaseVerdict);
1203
+ // `null` is PACK_GRADE_NOT_VERIFIED: the pack's case grade, never
1204
+ // verified, and `incomplete` for a word a pack grade passes through and a
1205
+ // case verdict never issues (5.5.1 step 1 v1.20).
1206
+ let contribution = EXCEPTION_VERDICT[kind];
1207
+ if (contribution === null) {
1208
+ const status = fields.status as string;
1209
+ contribution = GRADE_CONTRIBUTES.has(status) ? (status as CaseVerdict) : "incomplete";
1210
+ }
1026
1211
  contributions.push([fields.eventId ?? fields.packRef ?? "case", contribution]);
1027
1212
  };
1028
1213
 
1029
1214
  if (packs.length === 0) {
1030
1215
  note(CASE_HOLDS_NO_PACKS);
1216
+ const named = exceptions as CaseException[];
1217
+ const contributed = contributions as [string, CaseVerdict][];
1031
1218
  return {
1032
- verdict: verdictOf(exceptions, contributions),
1033
- exceptions,
1219
+ verdict: verdictOf(named, contributed, false),
1220
+ exceptions: named,
1034
1221
  records: [],
1035
- contributions,
1222
+ contributions: contributed,
1036
1223
  handoffRecords: 0,
1037
1224
  onChainChecked: false,
1225
+ notes: [],
1226
+ packs: [],
1038
1227
  };
1039
1228
  }
1040
1229
 
@@ -1079,6 +1268,20 @@ export async function readCase(
1079
1268
  // below, from the pack envelope and never from the body, so no body type, no
1080
1269
  // disclosure choice and no record content decides whether it is read.
1081
1270
  const holderKeys = new Set<string>();
1271
+ // One reading per pack, in the order the reader was given them (5.5.4 v1.20).
1272
+ const packReadings: CasePackReading[] = [];
1273
+ // The NOTEs (5.5.3 v1.20), with the pack position they are about.
1274
+ const caseNotes: { position: number; note: CaseNote }[] = [];
1275
+ // The role each pack's readable v2 declaration signs for each record, keyed
1276
+ // by the pack's POSITION for the reason `claimsByPack` is.
1277
+ const rolesByPack = new Map<number, Map<string, DeclarationRole>>();
1278
+ // The undeclared packs' slots, settled after the pile is read.
1279
+ const undeclaredSlots: {
1280
+ exceptionAt: number;
1281
+ contributionAt: number;
1282
+ position: number;
1283
+ ref: string;
1284
+ }[] = [];
1082
1285
  // How many packs this reader could not tie to a key: an unreadable wrapper
1083
1286
  // key, or a wrapper that does not hold. While it is not zero, "no pack in this
1084
1287
  // case is signed by the named key" is not established, so the innocent
@@ -1095,6 +1298,13 @@ export async function readCase(
1095
1298
  // of these spellings (proof#135) and the reading must not.
1096
1299
  note(PACK_KEY_UNREADABLE, { packRef: pack.ref });
1097
1300
  unattributed += 1;
1301
+ packReadings.push({
1302
+ ref: pack.ref,
1303
+ signingKey: null,
1304
+ grade: null,
1305
+ recordsWithheld: 0,
1306
+ declarationVersion: null,
1307
+ });
1098
1308
  continue;
1099
1309
  }
1100
1310
 
@@ -1110,40 +1320,40 @@ export async function readCase(
1110
1320
  | undefined,
1111
1321
  });
1112
1322
  witnessChecked.push(verified.onChainChecked);
1323
+ // The same pack with the side map EMPTY (proof#159): what fails here is
1324
+ // what a signature covers, and what fails only above rests on bytes nobody
1325
+ // signed. Skipped where there is no map, because the two runs are then the
1326
+ // same run.
1327
+ const signed = sideMapHasKeys(pack.content)
1328
+ ? await verifyEvidenceBundle(pack.bundle, {})
1329
+ : verified;
1113
1330
 
1114
1331
  // WHOSE PACK IS IT. The wrapper, and only the wrapper.
1115
1332
  if (!wrapperHolds(verified)) {
1116
1333
  note(PACK_DOES_NOT_VERIFY, { packRef: pack.ref });
1117
1334
  unattributed += 1;
1335
+ packReadings.push({
1336
+ ref: pack.ref,
1337
+ signingKey: holder,
1338
+ grade: null,
1339
+ recordsWithheld: 0,
1340
+ declarationVersion: null,
1341
+ });
1118
1342
  continue;
1119
1343
  }
1120
1344
  holderKeys.add(holder);
1121
1345
 
1122
- // WHAT IS WRONG INSIDE IT, from here on. First the pack's own assurance,
1123
- // which propagates: the case never reads above its weakest pack.
1124
- if (verified.status !== "verified") {
1125
- note(PACK_BUNDLE_NOT_VERIFIED, {
1126
- packRef: pack.ref,
1127
- status: verified.status as CaseVerdict,
1128
- reasons: bundleReasons(verified),
1346
+ if (coSignaturesPresent(pack.bundle)) {
1347
+ // A NOTE, with no contribution (5.5.3 v1.20, proof#148): the wrapper's
1348
+ // co_signatures verifies nothing and sits outside its signed scope, so
1349
+ // the case reading names it rather than moving anything on its account.
1350
+ caseNotes.push({
1351
+ position,
1352
+ note: { kind: CO_SIGNATURES_PRESENT, packRef: pack.ref },
1129
1353
  });
1130
1354
  }
1131
- // The body is one the verifier could not read, so it graded no record, and
1132
- // nothing below has a grading to read. Named above.
1133
- if (verified.status === "malformed") continue;
1134
1355
 
1135
1356
  const bodyType = bodyTypeOf(pack.bundle);
1136
- if (!(CASE_BODY_TYPES as readonly unknown[]).includes(bodyType)) {
1137
- // `sanning.evidence.export/v2` lands here, and it is NOT unwrapped: its
1138
- // records sit under `body.source_bundle`, and reading them is a change to
1139
- // which packs this reader grades, so it is a decision of its own and not
1140
- // a repair (evidence-bundle.md 5.5.1 step 1 says so too). What this exit
1141
- // costs is that pack's RECORDS, and it is named every time. It no longer
1142
- // costs the pack's KEY: that was read above.
1143
- note(PACK_BODY_TYPE_UNKNOWN, { packRef: pack.ref });
1144
- continue;
1145
- }
1146
-
1147
1357
  const records = packRecords(pack.bundle, pack.content);
1148
1358
  // The per-record gradings below are joined to `records` BY INDEX, so a
1149
1359
  // verifier that graded a different number of records than the pack holds
@@ -1151,50 +1361,136 @@ export async function readCase(
1151
1361
  // pack whose `body_hash` does not cover its body reads `failed` with
1152
1362
  // `events` EMPTY while `packRecords` reads every record in it, and only
1153
1363
  // `wrapperHolds` above stands between that pack and this loop. A reader
1154
- // must never crash on a pack a party can build, so the gap is NAMED rather
1364
+ // must never crash on a pack a party can build, so the gap is graded rather
1155
1365
  // than indexed into, and the pack's records are dropped exactly as a
1156
- // `malformed` pack's are (gate 4 on proof#140, finding 3: under the
1157
- // mutation that dropped `bodyHashOk` from the wrapper test, this kernel
1158
- // threw `Cannot read properties of undefined (reading 'envelopeOk')` here
1159
- // and nothing in the ladder said so).
1160
- if (verified.events.length !== records.length) {
1161
- note(PACK_BUNDLE_NOT_VERIFIED, {
1366
+ // `malformed` pack's are (gate 4 on proof#140, finding 3).
1367
+ const ungraded =
1368
+ verified.events.length !== records.length || signed.events.length !== records.length;
1369
+ const knownBody = (CASE_BODY_TYPES as readonly unknown[]).includes(bodyType);
1370
+
1371
+ // WHAT IS WRONG INSIDE IT, from here on. First the pack's own GRADE, which
1372
+ // propagates: the case never reads above its weakest pack (5.5.1 step 1
1373
+ // v1.20, A23; up to v1.19 it was the bundle status).
1374
+ let check: DeclarationCheck | null = null;
1375
+ let full: PackGrade | null = null;
1376
+ let causes: PackGradeCauseKind[] = [];
1377
+ let grade: PackVerdict;
1378
+ if (signed.status === "malformed") {
1379
+ grade = "malformed";
1380
+ } else if (!knownBody) {
1381
+ grade = signed.status as PackVerdict;
1382
+ } else if (ungraded) {
1383
+ grade = signed.status === "verified" ? "malformed" : (signed.status as PackVerdict);
1384
+ } else {
1385
+ check = checkDeclaration(pack.bundle, pack.content);
1386
+ const completeness = completenessNotes(pack.bundle);
1387
+ full = gradePack(verified, accountDisclosure(pack.bundle, pack.content ?? {}), completeness, check);
1388
+ const bareGrade = gradePack(
1389
+ signed,
1390
+ accountDisclosure(pack.bundle, {}),
1391
+ completeness,
1392
+ checkDeclaration(pack.bundle, null),
1393
+ );
1394
+ // NO `failed` HERE RESTS ON BYTES NOBODY SIGNED. The signed grade decides
1395
+ // where it is `failed` or `malformed`; a failure only the side map
1396
+ // produced is `incomplete`; otherwise the full grade.
1397
+ if (bareGrade.verdict === "failed" || bareGrade.verdict === "malformed") {
1398
+ grade = bareGrade.verdict;
1399
+ causes = bareGrade.causes.map((c) => c.kind);
1400
+ } else if (full.verdict === "failed" || full.verdict === "malformed") {
1401
+ grade = "incomplete";
1402
+ causes = full.causes.map((c) => c.kind);
1403
+ } else {
1404
+ grade = full.verdict;
1405
+ causes = full.causes.map((c) => c.kind);
1406
+ }
1407
+ }
1408
+ if (grade !== "verified") {
1409
+ const reasons = bundleReasons(signed);
1410
+ if (knownBody && ungraded && signed.status !== "malformed") {
1411
+ // Said, because nothing else says it: the verifier's own reasons are
1412
+ // about the bundle, and this pack's records are dropped for a reason
1413
+ // of this reader's.
1414
+ reasons.push(
1415
+ `this verifier graded ${verified.events.length} of the ${records.length} ` +
1416
+ "record(s) the pack holds, so none of them is read",
1417
+ );
1418
+ }
1419
+ note(PACK_GRADE_NOT_VERIFIED, {
1420
+ packRef: pack.ref,
1421
+ status: grade as CaseVerdict,
1422
+ causes,
1423
+ reasons,
1424
+ });
1425
+ }
1426
+ if (verified.status === "failed" && signed.status !== "failed") {
1427
+ note(PACK_BUNDLE_CONTENT_DEPENDENT, {
1162
1428
  packRef: pack.ref,
1163
- status: verified.status as CaseVerdict,
1164
- reasons: [
1165
- `this verifier graded ${verified.events.length} of the ` +
1166
- `${records.length} record(s) the pack holds, so none of them is read`,
1167
- ],
1429
+ reasons: onlyIn(bundleReasons(verified), bundleReasons(signed)),
1168
1430
  });
1431
+ }
1432
+ const recordsWithheld = full !== null ? full.recordsWithheld : 0;
1433
+ packReadings.push({
1434
+ ref: pack.ref,
1435
+ signingKey: holder,
1436
+ grade,
1437
+ recordsWithheld,
1438
+ declarationVersion: knownBody ? declarationVersionOf(pack.bundle) : null,
1439
+ });
1440
+ if (recordsWithheld > 0) {
1441
+ // A NOTE, with no contribution (5.5.3 v1.20): the holder signed that
1442
+ // these records' content does not travel, and the scope sentence counts
1443
+ // them, so a clean verdict never hides them.
1444
+ caseNotes.push({
1445
+ position,
1446
+ note: { kind: CONTENT_WITHHELD_BY_DECLARATION, packRef: pack.ref, count: recordsWithheld },
1447
+ });
1448
+ }
1449
+ // The body is one the verifier could not read, so it graded no record, and
1450
+ // nothing below has a grading to read. Named above.
1451
+ if (signed.status === "malformed") continue;
1452
+
1453
+ if (!knownBody) {
1454
+ // `sanning.evidence.export/v2` lands here, and it is NOT unwrapped: its
1455
+ // records sit under `body.source_bundle`, and reading them is a change to
1456
+ // which packs this reader grades, so it is a decision of its own and not
1457
+ // a repair (evidence-bundle.md 5.5.1 step 1 says so too). What this exit
1458
+ // costs is that pack's RECORDS, and it is named every time. It no longer
1459
+ // costs the pack's KEY: that was read above.
1460
+ note(PACK_BODY_TYPE_UNKNOWN, { packRef: pack.ref });
1169
1461
  continue;
1170
1462
  }
1463
+ // Graded above (`malformed` where the verifier called it `verified`), and
1464
+ // none of its records are read.
1465
+ if (ungraded) continue;
1171
1466
  if (records.length === 0) {
1172
1467
  note(PACK_HOLDS_NO_RECORDS, { packRef: pack.ref });
1173
1468
  continue;
1174
1469
  }
1175
1470
 
1176
- const check = checkDeclaration(pack.bundle, pack.content);
1471
+ const checked = check as DeclarationCheck;
1177
1472
  claimsByPack.set(position, declarationClaims(pack.bundle));
1178
- if (check.contentFindings.length > 0) {
1473
+ rolesByPack.set(position, new Map((checked.roles ?? []).map((r) => [r.eventId, r.role])));
1474
+ if (checked.contentFindings.length > 0) {
1179
1475
  // BESIDE the deviation exception and never instead of it: a pack can do
1180
1476
  // both, over different records, and the two say different things. This
1181
1477
  // one is `incomplete` because whoever assembled the case chose the bytes
1182
1478
  // it turns on (proof#147).
1183
1479
  note(PACK_DECLARATION_CONTENT_DEPENDENT, {
1184
1480
  packRef: pack.ref,
1185
- count: check.contentFindings.length,
1481
+ count: checked.contentFindings.length,
1186
1482
  });
1187
1483
  }
1188
- if (check.verdict === "failed") {
1484
+ if (checked.verdict === "failed") {
1189
1485
  note(PACK_DECLARATION_DEVIATES, {
1190
1486
  packRef: pack.ref,
1191
- count: check.deviations.length,
1487
+ count: checked.deviations.length,
1192
1488
  });
1193
- } else if (check.contentFindings.length > 0) {
1489
+ } else if (checked.contentFindings.length > 0) {
1194
1490
  // Already named above. `partial` here is not "the reader cannot read the
1195
1491
  // claim": the claim was read, and part of it rests on bytes nobody
1196
1492
  // signed.
1197
- } else if (check.verdict === "verified") {
1493
+ } else if (checked.verdict === "verified") {
1198
1494
  // A pack that agrees with its own claim contributes NOTHING, rather than
1199
1495
  // a `verified` of its own. The case verdict is over the hand-off
1200
1496
  // readings, and criterion 4's "mixed" is about mixed HAND-OFF outcomes: a
@@ -1202,14 +1498,25 @@ export async function readCase(
1202
1498
  // every case, so one broken pair beside two honest declarations would
1203
1499
  // read `mixed` and the failure would be the quieter half of a two-word
1204
1500
  // answer. A pack-level PROBLEM still contributes, through its exception.
1205
- } else if (check.present) {
1501
+ } else if (checked.present) {
1206
1502
  // `partial` with a declaration present is 5.4.3's "a claim this reader
1207
1503
  // cannot read", which `gradePack` already carries to `incomplete`. The
1208
1504
  // same posture here, for the same reason: the field has no accept-set and
1209
1505
  // the holder writes it.
1210
1506
  note(PACK_DECLARATION_UNREADABLE, { packRef: pack.ref });
1211
1507
  } else {
1212
- note(PACK_UNDECLARED, { packRef: pack.ref });
1508
+ // WHAT IT CONTRIBUTES TURNS ON THE WHOLE READING (5.5.1 step 2 v1.20,
1509
+ // A23, I4): `partial` where the reading holds a hand-off record, and a
1510
+ // NOTE where it holds none. That is not known until the pile is read, so
1511
+ // the slot is held HERE, in pack order, and settled below.
1512
+ undeclaredSlots.push({
1513
+ exceptionAt: exceptions.length,
1514
+ contributionAt: contributions.length,
1515
+ position,
1516
+ ref: pack.ref,
1517
+ });
1518
+ exceptions.push(null);
1519
+ contributions.push(null);
1213
1520
  }
1214
1521
 
1215
1522
  // Each record on its OWN envelope. `packRecords` and the verifier both walk
@@ -1218,6 +1525,7 @@ export async function readCase(
1218
1525
  for (let index = 0; index < records.length; index += 1) {
1219
1526
  const record = records[index];
1220
1527
  const graded = verified.events[index];
1528
+ const bare = signed.events[index];
1221
1529
  if (underQuarantine(recordSpecVersion(pack.bundle, index))) {
1222
1530
  note(RECORD_PROFILE_QUARANTINED, { packRef: pack.ref, eventId: record.eventId });
1223
1531
  continue;
@@ -1244,22 +1552,32 @@ export async function readCase(
1244
1552
  });
1245
1553
  continue;
1246
1554
  }
1247
- // Named, and the record stays in the pile (see the kinds).
1248
- if (graded.contentOk === false) {
1555
+ // Named, and the record stays in the pile (see the kinds). A mismatch
1556
+ // the signed body carries on its own is `failed`; one that appears only
1557
+ // with the side map is `incomplete` (proof#159).
1558
+ if (bare.contentOk === false) {
1249
1559
  note(RECORD_CONTENT_MISMATCH, {
1250
1560
  packRef: pack.ref,
1251
1561
  eventId: record.eventId,
1252
- reasons: [...graded.errors],
1562
+ reasons: [...bare.errors],
1563
+ });
1564
+ } else if (graded.contentOk === false) {
1565
+ note(RECORD_CONTENT_DEPENDENT, {
1566
+ packRef: pack.ref,
1567
+ eventId: record.eventId,
1568
+ reasons: onlyIn(graded.errors, bare.errors),
1253
1569
  });
1254
1570
  }
1571
+ // Every other check on the SIGNED reading, so side content can neither
1572
+ // add a failure here nor take one away.
1255
1573
  if (
1256
- !(graded.inclusionOk && graded.checkpointBound) ||
1257
- (!graded.ok && graded.contentOk !== false)
1574
+ !(bare.inclusionOk && bare.checkpointBound) ||
1575
+ (!bare.ok && bare.contentOk !== false)
1258
1576
  ) {
1259
1577
  note(RECORD_DOES_NOT_VERIFY_IN_ITS_PACK, {
1260
1578
  packRef: pack.ref,
1261
1579
  eventId: record.eventId,
1262
- reasons: [...graded.errors],
1580
+ reasons: [...bare.errors],
1263
1581
  });
1264
1582
  }
1265
1583
  held.push({
@@ -1290,6 +1608,7 @@ export async function readCase(
1290
1608
  findings: [...reading.findings],
1291
1609
  failedChecks: [...reading.failedChecks],
1292
1610
  counterparts: [...reading.candidates],
1611
+ role: rolesByPack.get(position)?.get(eventId) ?? null,
1293
1612
  });
1294
1613
  // proof#137's door, named. Read from the ONE reader of the declaration
1295
1614
  // (`declarationClaims`), never from a second parse here. Only a READABLE
@@ -1303,10 +1622,14 @@ export async function readCase(
1303
1622
  // `not_a_handoff_record` exit it was a check the audited party switched off
1304
1623
  // by respelling two fields, and behind the `malformed` exit it was one a
1305
1624
  // party switched off by breaking its own record.
1625
+ //
1626
+ // v1's ALONE (5.5.1 v1.20, A21). A v2 pack's claim check is rule 9, which
1627
+ // the declaration check already ran on the signed body.
1306
1628
  const claims = claimsByPack.get(position);
1307
1629
  if (
1308
1630
  claims !== undefined &&
1309
1631
  claims.readable &&
1632
+ claims.version === DECLARATION_VERSION &&
1310
1633
  !claims.claimedSides.includes(eventId)
1311
1634
  ) {
1312
1635
  note(RECORD_UNCLAIMED, { packRef, eventId });
@@ -1408,18 +1731,57 @@ export async function readCase(
1408
1731
  }
1409
1732
  }
1410
1733
 
1411
- if (handoffRecords === 0) note(CASE_HOLDS_NO_HANDOFF_RECORDS);
1734
+ // A reading with no hand-off record in it is not an exception (5.5.3 v1.20,
1735
+ // A23, D6). The undeclared packs, settled: the exception `pack_undeclared`,
1736
+ // contributing `partial`, in the slot it was given in pack order where the
1737
+ // reading holds a hand-off record; a NOTE with no contribution where it holds
1738
+ // none.
1739
+ for (const slot of undeclaredSlots) {
1740
+ if (handoffRecords > 0) {
1741
+ exceptions[slot.exceptionAt] = { kind: PACK_UNDECLARED, packRef: slot.ref };
1742
+ contributions[slot.contributionAt] = [slot.ref, EXCEPTION_VERDICT[PACK_UNDECLARED] as CaseVerdict];
1743
+ } else {
1744
+ caseNotes.push({ position: slot.position, note: { kind: PACK_UNDECLARED, packRef: slot.ref } });
1745
+ }
1746
+ }
1747
+ const named = exceptions.filter((e): e is CaseException => e !== null);
1748
+ const contributed = contributions.filter((c): c is [string, CaseVerdict] => c !== null);
1749
+ // Pack order, stable. A pack carries at most one note: one with no
1750
+ // declaration withholds nothing by one.
1751
+ const notes = caseNotes
1752
+ .map((n, i) => ({ ...n, i }))
1753
+ .sort((a, b) => a.position - b.position || a.i - b.i)
1754
+ .map((n) => n.note);
1412
1755
 
1413
1756
  return {
1414
- verdict: verdictOf(exceptions, contributions),
1415
- exceptions,
1757
+ verdict: verdictOf(named, contributed),
1758
+ exceptions: named,
1416
1759
  records,
1417
- contributions,
1760
+ contributions: contributed,
1418
1761
  handoffRecords,
1419
1762
  onChainChecked: witnessChecked.length > 0 && witnessChecked.every((c) => c),
1763
+ notes,
1764
+ packs: packReadings,
1420
1765
  };
1421
1766
  }
1422
1767
 
1768
+ /**
1769
+ * `body.declaration.declaration_version` where it is one this reader knows,
1770
+ * and `null` otherwise.
1771
+ */
1772
+ function declarationVersionOf(bundle: unknown): string | null {
1773
+ const at = (v: unknown, k: string): unknown =>
1774
+ typeof v === "object" && v !== null && !Array.isArray(v)
1775
+ ? (v as Record<string, unknown>)[k]
1776
+ : undefined;
1777
+ const declaration = at(at(bundle, "body"), "declaration");
1778
+ if (typeof declaration !== "object" || declaration === null || Array.isArray(declaration)) {
1779
+ return null;
1780
+ }
1781
+ const version = (declaration as Record<string, unknown>)["declaration_version"];
1782
+ return version === DECLARATION_VERSION || version === DECLARATION_VERSION_V2 ? version : null;
1783
+ }
1784
+
1423
1785
  // ---- Presentation (optional) ------------------------------------------------
1424
1786
 
1425
1787
  /**
@@ -1536,13 +1898,21 @@ export function renderException(exception: CaseException): string {
1536
1898
  "does not verify, so none of its records are read and the key it " +
1537
1899
  "names is not counted as its signer"
1538
1900
  );
1539
- case PACK_BUNDLE_NOT_VERIFIED: {
1540
- let head =
1541
- `${exception.packRef} reads ${(exception.status ?? "").toUpperCase()} as ` +
1542
- "a bundle, together with any content handed in beside it";
1901
+ case PACK_GRADE_NOT_VERIFIED: {
1902
+ let head = `${exception.packRef} grades ${(exception.status ?? "").toUpperCase()} as a pack`;
1903
+ if (exception.causes !== undefined && exception.causes.length > 0) {
1904
+ head += ` (${exception.causes.join(", ")})`;
1905
+ }
1543
1906
  if (exception.status === "malformed") head += ", so none of its records are read";
1544
1907
  return head + because(exception.reasons);
1545
1908
  }
1909
+ case PACK_BUNDLE_CONTENT_DEPENDENT:
1910
+ return (
1911
+ `${exception.packRef} reads FAILED as a bundle only together with ` +
1912
+ "the unsigned content handed in beside it, which anyone could have " +
1913
+ "supplied, so whether that failure is the pack's is UNKNOWN" +
1914
+ because(exception.reasons)
1915
+ );
1546
1916
  case RECORD_ENVELOPE_DOES_NOT_VERIFY:
1547
1917
  return (
1548
1918
  `${where} is in a pack whose wrapper verifies, and its own envelope ` +
@@ -1561,6 +1931,14 @@ export function renderException(exception: CaseException): string {
1561
1931
  "to, and it is still read as a hand-off record" +
1562
1932
  because(exception.reasons)
1563
1933
  );
1934
+ case RECORD_CONTENT_DEPENDENT:
1935
+ return (
1936
+ `${where} commits to content that the unsigned content handed in ` +
1937
+ "beside its pack does not match, and anyone could have supplied " +
1938
+ "that content, so what it says about the record is UNKNOWN; the " +
1939
+ "record is still read as a hand-off record" +
1940
+ because(exception.reasons)
1941
+ );
1564
1942
  case RECORD_PROFILE_QUARANTINED:
1565
1943
  return (
1566
1944
  `${where} is signed under a profile this case reader does not read ` +
@@ -1568,47 +1946,209 @@ export function renderException(exception: CaseException): string {
1568
1946
  );
1569
1947
  case CASE_HOLDS_NO_PACKS:
1570
1948
  return "this case holds no packs";
1571
- case CASE_HOLDS_NO_HANDOFF_RECORDS:
1949
+ default:
1950
+ return exception.kind;
1951
+ }
1952
+ }
1953
+
1954
+ /** The house wording for one NOTE. Offered, not imposed, as `renderException` is. */
1955
+ export function renderCaseNote(note: CaseNote): string {
1956
+ switch (note.kind) {
1957
+ case CONTENT_WITHHELD_BY_DECLARATION:
1572
1958
  return (
1573
- "no record this verifier read in this case is a hand-off record, " +
1574
- "so no hand-off was verified"
1959
+ `${note.packRef} withholds the content of ${note.count} record(s) by ` +
1960
+ "its own signed declaration: their signatures and commitments were " +
1961
+ "checked, their content was not"
1962
+ );
1963
+ case PACK_UNDECLARED:
1964
+ return `${note.packRef} does not say what it holds`;
1965
+ case CO_SIGNATURES_PRESENT:
1966
+ return (
1967
+ `${note.packRef} carries unverified co-signatures in its wrapper; ` +
1968
+ "they are not part of the evidence"
1575
1969
  );
1576
1970
  default:
1577
- return exception.kind;
1971
+ return (note as CaseNote).kind;
1578
1972
  }
1579
1973
  }
1580
1974
 
1581
1975
  /**
1582
- * One line a surface can print for a whole case.
1583
- *
1584
- * Both kernels print this sentence, so a reader who runs the Python command and
1585
- * the TypeScript one over one case archive reads one sentence rather than two
1586
- * that differ by a word.
1976
+ * The scope sentence, printed directly under the headline (evidence-bundle
1977
+ * 5.5.6 v1.20, A26), byte for byte the text `render_case_summary` returns.
1978
+ *
1979
+ * The packs in the reading; the distinct keys, compared as keys, that signed
1980
+ * the wrapper of a pack whose wrapper held; the hand-off records; the
1981
+ * exceptions; and that the reading was made offline. Where any pack holds
1982
+ * records withheld by its declaration, their total; where any pack is noted
1983
+ * `pack_undeclared`, how many. Those two lines are part of what the headline
1984
+ * MEANS, and a surface MUST print them with it.
1985
+ *
1986
+ * What was not checked on the public record is no longer a clause here. It is
1987
+ * the witness line (`renderWitnessLine`), printed with every headline.
1587
1988
  */
1588
1989
  export function renderCaseSummary(reading: CaseReading): string {
1589
- const n = reading.handoffRecords;
1590
- // What was NOT checked, in the same sentence as what was, and in the
1591
- // `verified` one above all (gate 4 on proof#140, finding 3). It is a
1592
- // statement of SCOPE and not a doubt: every pack here was verified offline,
1593
- // and no `anchor_ref` any of them names was fetched from a chain, because
1594
- // this reader makes no network call. Left unsaid, a clean case reads as
1595
- // though a witness had been checked, which nobody established. It is not an
1596
- // exception, and it must never become one: an empty exception list is the
1597
- // only shape a reader may read as clean, so a standing exception here would
1598
- // put `verified` out of reach for good.
1599
- const scope = reading.onChainChecked
1600
- ? ""
1601
- : "; no anchor_ref in it was re-fetched on chain, which this reader never does";
1602
- if (reading.verdict === "verified") {
1990
+ const keys = new Set(reading.packs.filter((p) => p.grade !== null).map((p) => p.signingKey));
1991
+ const withheld = reading.packs.reduce((n, p) => n + p.recordsWithheld, 0);
1992
+ const undeclared = reading.notes.filter((n) => n.kind === PACK_UNDECLARED).length;
1993
+ const lines = [
1994
+ `${reading.packs.length} pack(s), ${keys.size} signing key(s), ` +
1995
+ `${reading.handoffRecords} hand-off record(s), ` +
1996
+ `${reading.exceptions.length} exception(s), read offline.`,
1997
+ ];
1998
+ if (withheld > 0) {
1999
+ lines.push(
2000
+ `${withheld} record(s)' content withheld by their holder: their ` +
2001
+ "signatures and commitments were checked, their content was not.",
2002
+ );
2003
+ }
2004
+ if (undeclared > 0) lines.push(`${undeclared} pack(s) do not say what they hold.`);
2005
+ return lines.join(" ");
2006
+ }
2007
+
2008
+ // ---- The headline and the witness line (evidence-bundle 5.5.6 v1.20) -------
2009
+
2010
+ /**
2011
+ * The one display title of each of the eight 3 words. A surface prints the
2012
+ * title, never the word, and never a title of its own (#173, A18). The wording
2013
+ * is Will's to change through the glossary (DL-71); what each MEANS is pinned
2014
+ * in the spec.
2015
+ */
2016
+ export const VERDICT_DISPLAY_TITLES: Readonly<Record<string, string>> = {
2017
+ verified: "Verified",
2018
+ partial: "Partial",
2019
+ pending: "Pending",
2020
+ incomplete: "Incomplete",
2021
+ failed: "Failed",
2022
+ malformed: "Not readable",
2023
+ not_found: "Not found",
2024
+ mixed: "Mixed",
2025
+ };
2026
+
2027
+ /**
2028
+ * What a reader may rely on, for each of the six words a case reading reaches
2029
+ * (5.5.6 v1.20, A26; the `verified` text at v1.28, A35), the same for every
2030
+ * drop. `pending` and `not_found` never head a case reading: they are
2031
+ * witness states. `verified` is the base sentence, printed alone where the
2032
+ * drop holds no hand-off record; a caller reads `caseReliance` for
2033
+ * `verified`, never this mapping directly.
2034
+ */
2035
+ export const CASE_RELIANCE: Readonly<Record<CaseVerdict, string>> = {
2036
+ verified:
2037
+ "Every pack in this drop is genuine, unaltered, and matches its own " +
2038
+ "signed declaration. Every record in it was signed by its holder's " +
2039
+ "key. This was read offline, on this device. It does not say this " +
2040
+ "drop holds everything that was recorded.",
2041
+ partial:
2042
+ "Nothing here was proven wrong, and a check could not be completed: a " +
2043
+ "pack names no witness, or a pack in a hand-off reading does not say " +
2044
+ "what it holds. The exceptions say which.",
2045
+ incomplete:
2046
+ "Nothing here was proven wrong, and part of it could not be checked: " +
2047
+ "the other side of a hand-off is not in this drop, bytes were not " +
2048
+ "handed over, or a claim could not be read. Each exception says what " +
2049
+ "is missing and, where a key is known, whose pack would supply it.",
2050
+ failed:
2051
+ "A check ran and did not pass. Do not rely on this drop until the " +
2052
+ "named failure is explained. The failure is named on the pack or " +
2053
+ "record it is about, and every other exception is listed beside it.",
2054
+ malformed:
2055
+ "Part of this drop could not be read as evidence, and nothing was " +
2056
+ "proven wrong. The exceptions name what could not be read.",
2057
+ mixed:
2058
+ "The readings here differ and none of them failed: some are verified " +
2059
+ "and some could not be checked. Read each pack's row and each " +
2060
+ "exception before relying on any of it.",
2061
+ };
2062
+
2063
+ /**
2064
+ * The clause a `verified` drop that holds a hand-off record gains, inserted
2065
+ * after `CASE_RELIANCE`'s first sentence (5.5.6, A35). Never printed alone,
2066
+ * and never where `handoffRecords` is zero: most drops hold one agent's
2067
+ * pack and no hand-off to say anything about.
2068
+ */
2069
+ export const CASE_RELIANCE_HANDOFF_CLAUSE =
2070
+ "Every hand-off with both sides in this drop is intact.";
2071
+
2072
+ /**
2073
+ * What a reader may rely on for one case reading (5.5.6 v1.28, A35), byte
2074
+ * for byte the text `case_reliance`'s Python sibling returns.
2075
+ *
2076
+ * For every word but `verified` this is `CASE_RELIANCE` unchanged. For
2077
+ * `verified`, the hand-off clause is inserted after the first sentence
2078
+ * where `handoffRecords` is not zero, and left out where it is zero: the
2079
+ * base sentence never claims a hand-off the drop does not hold.
2080
+ */
2081
+ export function caseReliance(verdict: CaseVerdict, handoffRecords: number): string {
2082
+ if (verdict === "verified" && handoffRecords > 0) {
1603
2083
  return (
1604
- `this case verifies across ${reading.records.length} record(s): ` +
1605
- `${n} hand-off record(s), every one matched, no exceptions` +
1606
- scope
2084
+ "Every pack in this drop is genuine, unaltered, and matches its own " +
2085
+ "signed declaration. Every hand-off with both sides in this drop is " +
2086
+ "intact. Every record in it was signed by its holder's key. This " +
2087
+ "was read offline, on this device. It does not say this drop holds " +
2088
+ "everything that was recorded."
1607
2089
  );
1608
2090
  }
1609
- return (
1610
- `this case reads ${reading.verdict.toUpperCase()} over ${n} hand-off ` +
1611
- `record(s), with ${reading.exceptions.length} exception(s)` +
1612
- scope
1613
- );
2091
+ return CASE_RELIANCE[verdict];
2092
+ }
2093
+
2094
+ /** The three witness states, and how each is printed (5.5.6, D7). No other. */
2095
+ export const WITNESS_STATES = {
2096
+ confirmed: "Confirmed on the public record",
2097
+ not_checked: "Not checked",
2098
+ differs: "The public record holds different bytes",
2099
+ } as const;
2100
+ export type WitnessState = keyof typeof WITNESS_STATES;
2101
+
2102
+ /**
2103
+ * The witness line: a lookup of the public record, reported beside the
2104
+ * headline and never in it (D7). It never raises or lowers the headline and
2105
+ * never changes its title. `reasons` says why a `not_checked` line did not
2106
+ * settle, in a fixed order; `services` names each service that answered, as
2107
+ * the URL the lookup used: named, never trusted (6).
2108
+ */
2109
+ export interface WitnessLine {
2110
+ state: WitnessState;
2111
+ reasons: string[];
2112
+ services: string[];
2113
+ /**
2114
+ * Witness times a pack caches that no lookup settled, as `[blockHeight,
2115
+ * blockTime]`, distinct and in time order (5.6.6, v1.25, A30). Printed labelled
2116
+ * not checked, and never as a time a reader may rely on. Absent reads as none.
2117
+ */
2118
+ cached?: [number, number][];
2119
+ /**
2120
+ * Witness times the public record returned where they matched a pack's
2121
+ * cache, distinct and in time order. The gateway's answer, named with the
2122
+ * service, never the cache's value. Absent reads as none.
2123
+ */
2124
+ recorded?: [number, number][];
2125
+ }
2126
+
2127
+ /** The line printed where no lookup ran, which is every reading this kernel produces on its own. */
2128
+ export const WITNESS_NOT_LOOKED_UP: Readonly<WitnessLine> = Object.freeze({
2129
+ state: "not_checked",
2130
+ reasons: ["no lookup was run"],
2131
+ services: [],
2132
+ });
2133
+
2134
+ /** The witness line as both kernels print it. */
2135
+ /** How a witness time a pack caches is printed where nothing checked it (5.5.6, 5.6.6, v1.25, A30). */
2136
+ export const WITNESS_TIME_CACHED = "the pack dates the witness, not checked";
2137
+ /** How a witness time the public record returned is printed where it matched the cache. */
2138
+ export const WITNESS_TIME_RECORDED = "the public record dates the witness";
2139
+
2140
+ function witnessTimes(times: readonly [number, number][]): string {
2141
+ return times.map(([h, t]) => renderWitnessTime(h, t)).join(", ");
2142
+ }
2143
+
2144
+ export function renderWitnessLine(line: WitnessLine): string {
2145
+ let text = `Witness: ${WITNESS_STATES[line.state]}`;
2146
+ const parts = [...line.reasons];
2147
+ if (line.cached && line.cached.length > 0) parts.push(`${WITNESS_TIME_CACHED}: ${witnessTimes(line.cached)}`);
2148
+ if (line.recorded && line.recorded.length > 0) {
2149
+ parts.push(`${WITNESS_TIME_RECORDED}: ${witnessTimes(line.recorded)}`);
2150
+ }
2151
+ if (parts.length > 0) text += `: ${parts.join("; ")}`;
2152
+ if (line.services.length > 0) text += ` (answered: ${line.services.join(", ")})`;
2153
+ return text;
1614
2154
  }