@sanning/proof 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) 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 +349 -0
  6. package/dist/case.d.ts +173 -26
  7. package/dist/case.js +469 -123
  8. package/dist/cli.d.ts +38 -5
  9. package/dist/cli.js +557 -208
  10. package/dist/crypto.d.ts +1 -1
  11. package/dist/crypto.js +5 -1
  12. package/dist/declaration.d.ts +58 -52
  13. package/dist/declaration.js +256 -33
  14. package/dist/evidence.d.ts +2 -0
  15. package/dist/evidence.js +134 -19
  16. package/dist/exit-codes.d.ts +17 -0
  17. package/dist/exit-codes.js +50 -0
  18. package/dist/grading.d.ts +28 -13
  19. package/dist/grading.js +90 -31
  20. package/dist/index.d.ts +10 -7
  21. package/dist/index.js +13 -6
  22. package/dist/message-parts.d.ts +11 -9
  23. package/dist/message-parts.js +11 -9
  24. package/dist/types.d.ts +1 -0
  25. package/dist/verifier.d.ts +9 -2
  26. package/dist/verifier.js +28 -12
  27. package/dist/zip-reader.d.ts +50 -0
  28. package/dist/zip-reader.js +220 -0
  29. package/package.json +2 -2
  30. package/src/agent-proof.ts +4 -4
  31. package/src/case-file.ts +419 -0
  32. package/src/case.ts +586 -132
  33. package/src/cli.ts +651 -206
  34. package/src/crypto.ts +5 -1
  35. package/src/declaration.ts +248 -52
  36. package/src/evidence.ts +142 -21
  37. package/src/exit-codes.ts +54 -0
  38. package/src/grading.ts +109 -52
  39. package/src/index.ts +58 -2
  40. package/src/message-parts.ts +11 -9
  41. package/src/types.ts +5 -0
  42. package/src/verifier.ts +35 -13
  43. package/src/zip-reader.ts +229 -0
  44. package/vectors/README.md +23 -5
  45. package/vectors/case-file-index.json +668 -0
  46. package/vectors/case-split.json +218 -9
  47. package/vectors/pack-grading.json +68 -4
  48. package/vectors/stamp-on-chain.json +3 -3
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,
@@ -254,7 +262,14 @@ import {
254
262
  } from "./handoff.js";
255
263
  import { 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";
259
274
 
260
275
  // ---- The vocabulary ---------------------------------------------------------
@@ -623,17 +638,26 @@ export const PACK_KEY_UNREADABLE = "pack_key_unreadable";
623
638
  export const PACK_DOES_NOT_VERIFY = "pack_does_not_verify";
624
639
 
625
640
  /**
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.
641
+ * The pack's wrapper HOLDS and its CASE GRADE is not `verified`
642
+ * (evidence-bundle 5.5.1 step 1 v1.20, A23; it replaces
643
+ * `pack_bundle_not_verified`, which carried the bundle status alone). The case
644
+ * grade is the pack grade of 5.4.5, taken twice: the SIGNED grade over the run
645
+ * with the side map empty, and the FULL grade over the run with it. The signed
646
+ * grade decides where it is `failed` or `malformed`; a failure only the full
647
+ * grade reaches is `incomplete`, because it rests on bytes nobody signed;
648
+ * otherwise the full grade. A body the verifier could not read is `malformed`,
649
+ * a body that is not one of the two event-carrying bodies takes the signed
650
+ * run's status, and a verifier that graded a different number of records than
651
+ * the pack holds takes the signed status, or `malformed` where that status is
652
+ * `verified`.
653
+ *
654
+ * The exception carries `status`, the case grade; `causes`, the 5.4.5 cause
655
+ * kinds of the grade that decided it (none in the first three branches); and
656
+ * `reasons`, the verifier's reasons from the run with the side map empty. It
657
+ * CONTRIBUTES the grade, and `incomplete` for `pending`, `not_found` or
658
+ * `mixed`, so the case verdict never reads above its weakest pack.
635
659
  */
636
- export const PACK_BUNDLE_NOT_VERIFIED = "pack_bundle_not_verified";
660
+ export const PACK_GRADE_NOT_VERIFIED = "pack_grade_not_verified";
637
661
 
638
662
  /**
639
663
  * A record in a pack whose wrapper holds, whose OWN envelope does not verify
@@ -657,13 +681,41 @@ export const RECORD_DOES_NOT_VERIFY_IN_ITS_PACK = "record_does_not_verify_in_its
657
681
 
658
682
  /**
659
683
  * 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
684
+ * the hash it commits to, its `content_hash` or a hand-off record's
685
+ * `message_hash` (evidence-bundle v1.19, A20) (`EventResult.contentOk` is false), with the
686
+ * side map EMPTY: the content is inside the signed body. The same mismatch from
687
+ * content handed in beside the pack is `RECORD_CONTENT_DEPENDENT` (proof#159).
688
+ * `failed`, and the record STAYS in the pile: content beside a pack is unsigned and often
662
689
  * supplied by whoever assembled the case, and gate 3 on proof#140 showed that
663
690
  * party erasing an honest verified pair by swapping it.
664
691
  */
665
692
  export const RECORD_CONTENT_MISMATCH = "record_content_mismatch";
666
693
 
694
+ /**
695
+ * The two readings proof#147 did not reach, given its shape (proof#159, ruled
696
+ * by Phil 2026-09-22, evidence-bundle.md 5.5.4 v1.16, A17). A pack whose
697
+ * bundle verdict is `failed` ONLY when read with the unsigned content handed
698
+ * in beside it, and `failed` no longer when read with that map empty, is
699
+ * `PACK_BUNDLE_CONTENT_DEPENDENT`; a record whose content mismatch comes from
700
+ * that map alone is `RECORD_CONTENT_DEPENDENT`. Both are `incomplete`: the
701
+ * reader established that the bytes it was handed are not the bytes the record
702
+ * commits to, and nothing about the party that signed the record, because
703
+ * whoever assembled the case chose those bytes with no key. Up to v1.15 both
704
+ * readings carried `failed`, and one flipped nibble of accuser-supplied content
705
+ * put a `failed` against an intact pack; under A17's headline rule that would
706
+ * have become the case's headline.
707
+ *
708
+ * The test that separates the two is the one proof#159 states for the inverse:
709
+ * every deviation a signature covers still reads `failed` with the side map
710
+ * EMPTY. So the pack is verified twice, with the map and without it, and what
711
+ * fails without it is `PACK_GRADE_NOT_VERIFIED`, `RECORD_CONTENT_MISMATCH`
712
+ * (content inside the signed body) or `RECORD_DOES_NOT_VERIFY_IN_ITS_PACK`,
713
+ * each still `failed`. Emptying the map is the most a party that signed
714
+ * nothing can do to starve the check, and it cannot remove any of those.
715
+ */
716
+ export const PACK_BUNDLE_CONTENT_DEPENDENT = "pack_bundle_content_dependent";
717
+ export const RECORD_CONTENT_DEPENDENT = "record_content_dependent";
718
+
667
719
  /**
668
720
  * A record signed under a ratified profile that is under quarantine
669
721
  * (`CASE_QUARANTINED_PROFILES`, pending DL-63). Not read, in any kernel.
@@ -675,11 +727,14 @@ export const RECORD_PROFILE_QUARANTINED = "record_profile_quarantined";
675
727
  export const CASE_HOLDS_NO_PACKS = "case_holds_no_packs";
676
728
 
677
729
  /**
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.
730
+ * The two NOTEs (5.5.3 v1.20): findings on the reading with no contribution,
731
+ * printed in the scope sentence so a clean verdict never hides either.
732
+ * `pack_undeclared` is a note only where the reading holds no hand-off record;
733
+ * where it holds one, it is the exception of that name.
681
734
  */
682
- export const CASE_HOLDS_NO_HANDOFF_RECORDS = "case_holds_no_handoff_records";
735
+ export const CONTENT_WITHHELD_BY_DECLARATION = "content_withheld_by_declaration";
736
+ export const CASE_NOTE_KINDS = [PACK_UNDECLARED, CONTENT_WITHHELD_BY_DECLARATION] as const;
737
+ export type CaseNoteKind = (typeof CASE_NOTE_KINDS)[number];
683
738
 
684
739
  /**
685
740
  * Every exception this reader can report, as a closed set. A surface may render
@@ -704,13 +759,14 @@ export const CASE_EXCEPTION_KINDS = [
704
759
  PACK_HOLDS_NO_RECORDS,
705
760
  PACK_KEY_UNREADABLE,
706
761
  PACK_DOES_NOT_VERIFY,
707
- PACK_BUNDLE_NOT_VERIFIED,
762
+ PACK_GRADE_NOT_VERIFIED,
763
+ PACK_BUNDLE_CONTENT_DEPENDENT,
708
764
  RECORD_ENVELOPE_DOES_NOT_VERIFY,
709
765
  RECORD_DOES_NOT_VERIFY_IN_ITS_PACK,
710
766
  RECORD_CONTENT_MISMATCH,
767
+ RECORD_CONTENT_DEPENDENT,
711
768
  RECORD_PROFILE_QUARANTINED,
712
769
  CASE_HOLDS_NO_PACKS,
713
- CASE_HOLDS_NO_HANDOFF_RECORDS,
714
770
  ] as const;
715
771
 
716
772
  export type CaseExceptionKind = (typeof CASE_EXCEPTION_KINDS)[number];
@@ -726,10 +782,10 @@ export type CaseExceptionKind = (typeof CASE_EXCEPTION_KINDS)[number];
726
782
  * contributes a clean verdict, which is what makes the invariant in `verdictOf`
727
783
  * hold.
728
784
  *
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`.
785
+ * `PACK_GRADE_NOT_VERIFIED` is the one row that is not a constant: it
786
+ * contributes the pack's case grade, which is never `verified` (the exception
787
+ * is emitted only when it is not). `null` marks that row, and `readCase` fills
788
+ * it from `status`.
733
789
  */
734
790
  const EXCEPTION_VERDICT: Record<CaseExceptionKind, CaseVerdict | null> = {
735
791
  [COUNTERPART_ABSENT]: "incomplete",
@@ -750,15 +806,28 @@ const EXCEPTION_VERDICT: Record<CaseExceptionKind, CaseVerdict | null> = {
750
806
  [PACK_HOLDS_NO_RECORDS]: "incomplete",
751
807
  [PACK_KEY_UNREADABLE]: "incomplete",
752
808
  [PACK_DOES_NOT_VERIFY]: "failed",
753
- [PACK_BUNDLE_NOT_VERIFIED]: null,
809
+ [PACK_GRADE_NOT_VERIFIED]: null,
810
+ [PACK_BUNDLE_CONTENT_DEPENDENT]: "incomplete",
754
811
  [RECORD_ENVELOPE_DOES_NOT_VERIFY]: "failed",
755
812
  [RECORD_DOES_NOT_VERIFY_IN_ITS_PACK]: "failed",
756
813
  [RECORD_CONTENT_MISMATCH]: "failed",
814
+ [RECORD_CONTENT_DEPENDENT]: "incomplete",
757
815
  [RECORD_PROFILE_QUARANTINED]: "incomplete",
758
816
  [CASE_HOLDS_NO_PACKS]: "partial",
759
- [CASE_HOLDS_NO_HANDOFF_RECORDS]: "partial",
760
817
  };
761
818
 
819
+ /**
820
+ * The case grades `PACK_GRADE_NOT_VERIFIED` contributes as themselves. Every
821
+ * other grade a pack can reach (`pending`, `not_found`, `mixed`, which a pack
822
+ * grade passes through and a case verdict never issues) is `incomplete`.
823
+ */
824
+ const GRADE_CONTRIBUTES: ReadonlySet<string> = new Set([
825
+ "partial",
826
+ "incomplete",
827
+ "failed",
828
+ "malformed",
829
+ ]);
830
+
762
831
  // ---- Inputs -----------------------------------------------------------------
763
832
 
764
833
  /**
@@ -804,11 +873,18 @@ export interface CaseException {
804
873
  * (v1.17, A20), and it is how many were set aside.
805
874
  */
806
875
  count?: number;
807
- /** The pack's own bundle verdict, for `PACK_BUNDLE_NOT_VERIFIED`. */
876
+ /** The pack's case grade, for `PACK_GRADE_NOT_VERIFIED`. */
808
877
  status?: CaseVerdict;
878
+ /**
879
+ * The 5.4.5 cause kinds of the grade that decided the case grade, for
880
+ * `PACK_GRADE_NOT_VERIFIED`, in the grade's order. Empty where the grade was
881
+ * not computed (an unreadable body, a body that is not one of the two
882
+ * event-carrying ones, a verifier that graded too few records).
883
+ */
884
+ causes?: PackGradeCauseKind[];
809
885
  /**
810
886
  * 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
887
+ * verifier's grading: `PACK_GRADE_NOT_VERIFIED` and the three record kinds
812
888
  * graded on the record's own envelope and content.
813
889
  */
814
890
  reasons?: string[];
@@ -830,6 +906,54 @@ export interface CaseRecordReading {
830
906
  findings: string[];
831
907
  failedChecks: string[];
832
908
  counterparts: string[];
909
+ /**
910
+ * The role the pack's v2 declaration signs for this record, `handoff` or
911
+ * `step`, or `null` where the pack's declaration is absent, unreadable or
912
+ * v1, or does not declare the record (5.5.1 v1.20).
913
+ */
914
+ role: DeclarationRole | null;
915
+ }
916
+
917
+ /**
918
+ * One NOTE on the reading (5.5.3 v1.20): something true a reader needs and
919
+ * nothing a reader could not tell, so it carries no contribution.
920
+ */
921
+ export interface CaseNote {
922
+ kind: CaseNoteKind;
923
+ /** Which pack, by the reader's own name for it. */
924
+ packRef: string;
925
+ /**
926
+ * For `CONTENT_WITHHELD_BY_DECLARATION`, how many of the pack's records its
927
+ * declaration withholds.
928
+ */
929
+ count?: number;
930
+ }
931
+
932
+ /**
933
+ * One pack as the reading read it, in the order the reader was given them
934
+ * (5.5.4 v1.20). A surface names a pack by `signingKey`, the key that signed
935
+ * its wrapper, and never by an organization (D10).
936
+ */
937
+ export interface CasePackReading {
938
+ ref: string;
939
+ /** The wrapper key, normalized, or `null` where this reader could not read it. */
940
+ signingKey: string | null;
941
+ /**
942
+ * The case grade (5.5.1 step 1), or `null` where the wrapper did not hold or
943
+ * the key could not be read.
944
+ */
945
+ grade: PackVerdict | null;
946
+ /**
947
+ * Records withheld by the pack's declaration (5.4.5): in scope for their
948
+ * signature and commitment only. Never counted as verified records.
949
+ */
950
+ recordsWithheld: number;
951
+ /**
952
+ * `sanning.declaration/v1`, `sanning.declaration/v2`, or `null` where the
953
+ * pack carries no declaration, one this reader does not know, or a body this
954
+ * reader does not read.
955
+ */
956
+ declarationVersion: string | null;
833
957
  }
834
958
 
835
959
  export interface CaseReading {
@@ -873,6 +997,10 @@ export interface CaseReading {
873
997
  * checked packs, which is false.
874
998
  */
875
999
  onChainChecked: boolean;
1000
+ /** The NOTEs, in pack order (5.5.3 v1.20). No contribution. */
1001
+ notes: CaseNote[];
1002
+ /** One reading per pack, in the order the reader was given them. */
1003
+ packs: CasePackReading[];
876
1004
  }
877
1005
 
878
1006
  // ---- The reading ------------------------------------------------------------
@@ -919,6 +1047,24 @@ const wrapperHolds = (result: EvidenceBundleResult): boolean =>
919
1047
  * bundle-level error at all, and an exception that named the verdict and no
920
1048
  * reason would send a reader looking for a defect in the pack as a whole.
921
1049
  */
1050
+ /**
1051
+ * The reasons a reading carries only because of the side map, in the order the
1052
+ * verifier wrote them: what the content-dependent kinds are about, and nothing
1053
+ * a signature covers.
1054
+ */
1055
+ const onlyIn = (withMap: readonly string[], withoutMap: readonly string[]): string[] =>
1056
+ withMap.filter((reason) => !withoutMap.includes(reason));
1057
+
1058
+ /**
1059
+ * Whether a side-content map holds any key. Python's `if pack.content` is
1060
+ * the twin: `None` and an empty map both mean the two verify runs are one run.
1061
+ */
1062
+ function sideMapHasKeys(content: CasePack["content"]): boolean {
1063
+ if (content === null || content === undefined) return false;
1064
+ if (content instanceof Map) return content.size > 0;
1065
+ return Object.keys(content as object).length > 0;
1066
+ }
1067
+
922
1068
  const bundleReasons = (result: EvidenceBundleResult): string[] => [
923
1069
  ...result.errors,
924
1070
  ...(result.stamps ?? []).flatMap((stamp) => stamp.errors),
@@ -955,8 +1101,13 @@ function underQuarantine(specVersion: unknown): boolean {
955
1101
  /**
956
1102
  * The case verdict, from the contributions alone.
957
1103
  *
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.
1104
+ * **A proven failure keeps the headline** (`evidence-bundle.md` 5.5.4 v1.16,
1105
+ * A17, proof#158): if any contribution is `failed`, the case is `failed`,
1106
+ * whatever else contributes, and every other exception stays named beside it.
1107
+ * Otherwise one outcome is that outcome, and more than one is `mixed`
1108
+ * (anchor#198, criterion 4), so `mixed` never holds a `failed`. `malformed`
1109
+ * does NOT keep the headline: it says a record cannot be read, not that a
1110
+ * check ran and did not pass. `mixed` is in the 3 vocabulary and coins no word.
960
1111
  *
961
1112
  * THE INVARIANT, stated so a mutation test has something to kill: `verified` is
962
1113
  * reachable ONLY when `exceptions` is empty. Every exception kind carries a
@@ -968,13 +1119,26 @@ function underQuarantine(specVersion: unknown): boolean {
968
1119
  function verdictOf(
969
1120
  exceptions: readonly CaseException[],
970
1121
  contributions: readonly [string, CaseVerdict][],
1122
+ holdsPacks = true,
971
1123
  ): 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";
1124
+ // A reading that holds at least one pack and no contribution at all is
1125
+ // `verified` (5.5.4 v1.20, A23): every pack in it graded `verified` and
1126
+ // nothing in it was an exception. Every exception contributes, so no
1127
+ // contribution means no exception. A reading with no packs always carries
1128
+ // `case_holds_no_packs`, and is defended here anyway: an empty aggregate over
1129
+ // nothing reading as `verified` is the defect class itself.
1130
+ if (contributions.length === 0) {
1131
+ if (exceptions.length > 0) {
1132
+ throw new Error(`exceptions with no contribution: ${exceptions.map((e) => e.kind).join(", ")}`);
1133
+ }
1134
+ return holdsPacks ? "verified" : "partial";
1135
+ }
976
1136
  const outcomes = new Set(contributions.map(([, v]) => v));
977
- const verdict: CaseVerdict = outcomes.size === 1 ? [...outcomes][0] : "mixed";
1137
+ const verdict: CaseVerdict = outcomes.has("failed")
1138
+ ? "failed"
1139
+ : outcomes.size === 1
1140
+ ? [...outcomes][0]
1141
+ : "mixed";
978
1142
  if (verdict === "verified" && exceptions.length > 0) {
979
1143
  throw new Error(
980
1144
  `verified over a non-empty exception list: ${exceptions.map((e) => e.kind).join(", ")}`,
@@ -1013,28 +1177,40 @@ function verdictOf(
1013
1177
  export async function readCase(
1014
1178
  packs: readonly CasePack[],
1015
1179
  ): Promise<CaseReading> {
1016
- const exceptions: CaseException[] = [];
1017
- const contributions: [string, CaseVerdict][] = [];
1180
+ // A `null` slot is held for each pack that carries no declaration, and
1181
+ // settled once the pile says whether the reading holds a hand-off record.
1182
+ const exceptions: (CaseException | null)[] = [];
1183
+ const contributions: ([string, CaseVerdict] | null)[] = [];
1018
1184
 
1019
1185
  const note = (
1020
1186
  kind: CaseExceptionKind,
1021
1187
  fields: Omit<CaseException, "kind"> = {},
1022
1188
  ) => {
1023
1189
  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);
1190
+ // `null` is PACK_GRADE_NOT_VERIFIED: the pack's case grade, never
1191
+ // verified, and `incomplete` for a word a pack grade passes through and a
1192
+ // case verdict never issues (5.5.1 step 1 v1.20).
1193
+ let contribution = EXCEPTION_VERDICT[kind];
1194
+ if (contribution === null) {
1195
+ const status = fields.status as string;
1196
+ contribution = GRADE_CONTRIBUTES.has(status) ? (status as CaseVerdict) : "incomplete";
1197
+ }
1026
1198
  contributions.push([fields.eventId ?? fields.packRef ?? "case", contribution]);
1027
1199
  };
1028
1200
 
1029
1201
  if (packs.length === 0) {
1030
1202
  note(CASE_HOLDS_NO_PACKS);
1203
+ const named = exceptions as CaseException[];
1204
+ const contributed = contributions as [string, CaseVerdict][];
1031
1205
  return {
1032
- verdict: verdictOf(exceptions, contributions),
1033
- exceptions,
1206
+ verdict: verdictOf(named, contributed, false),
1207
+ exceptions: named,
1034
1208
  records: [],
1035
- contributions,
1209
+ contributions: contributed,
1036
1210
  handoffRecords: 0,
1037
1211
  onChainChecked: false,
1212
+ notes: [],
1213
+ packs: [],
1038
1214
  };
1039
1215
  }
1040
1216
 
@@ -1079,6 +1255,20 @@ export async function readCase(
1079
1255
  // below, from the pack envelope and never from the body, so no body type, no
1080
1256
  // disclosure choice and no record content decides whether it is read.
1081
1257
  const holderKeys = new Set<string>();
1258
+ // One reading per pack, in the order the reader was given them (5.5.4 v1.20).
1259
+ const packReadings: CasePackReading[] = [];
1260
+ // The NOTEs (5.5.3 v1.20), with the pack position they are about.
1261
+ const caseNotes: { position: number; note: CaseNote }[] = [];
1262
+ // The role each pack's readable v2 declaration signs for each record, keyed
1263
+ // by the pack's POSITION for the reason `claimsByPack` is.
1264
+ const rolesByPack = new Map<number, Map<string, DeclarationRole>>();
1265
+ // The undeclared packs' slots, settled after the pile is read.
1266
+ const undeclaredSlots: {
1267
+ exceptionAt: number;
1268
+ contributionAt: number;
1269
+ position: number;
1270
+ ref: string;
1271
+ }[] = [];
1082
1272
  // How many packs this reader could not tie to a key: an unreadable wrapper
1083
1273
  // key, or a wrapper that does not hold. While it is not zero, "no pack in this
1084
1274
  // case is signed by the named key" is not established, so the innocent
@@ -1095,6 +1285,13 @@ export async function readCase(
1095
1285
  // of these spellings (proof#135) and the reading must not.
1096
1286
  note(PACK_KEY_UNREADABLE, { packRef: pack.ref });
1097
1287
  unattributed += 1;
1288
+ packReadings.push({
1289
+ ref: pack.ref,
1290
+ signingKey: null,
1291
+ grade: null,
1292
+ recordsWithheld: 0,
1293
+ declarationVersion: null,
1294
+ });
1098
1295
  continue;
1099
1296
  }
1100
1297
 
@@ -1110,30 +1307,127 @@ export async function readCase(
1110
1307
  | undefined,
1111
1308
  });
1112
1309
  witnessChecked.push(verified.onChainChecked);
1310
+ // The same pack with the side map EMPTY (proof#159): what fails here is
1311
+ // what a signature covers, and what fails only above rests on bytes nobody
1312
+ // signed. Skipped where there is no map, because the two runs are then the
1313
+ // same run.
1314
+ const signed = sideMapHasKeys(pack.content)
1315
+ ? await verifyEvidenceBundle(pack.bundle, {})
1316
+ : verified;
1113
1317
 
1114
1318
  // WHOSE PACK IS IT. The wrapper, and only the wrapper.
1115
1319
  if (!wrapperHolds(verified)) {
1116
1320
  note(PACK_DOES_NOT_VERIFY, { packRef: pack.ref });
1117
1321
  unattributed += 1;
1322
+ packReadings.push({
1323
+ ref: pack.ref,
1324
+ signingKey: holder,
1325
+ grade: null,
1326
+ recordsWithheld: 0,
1327
+ declarationVersion: null,
1328
+ });
1118
1329
  continue;
1119
1330
  }
1120
1331
  holderKeys.add(holder);
1121
1332
 
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, {
1333
+ const bodyType = bodyTypeOf(pack.bundle);
1334
+ const records = packRecords(pack.bundle, pack.content);
1335
+ // The per-record gradings below are joined to `records` BY INDEX, so a
1336
+ // verifier that graded a different number of records than the pack holds
1337
+ // leaves this loop with no grading to read. That is not hypothetical: a
1338
+ // pack whose `body_hash` does not cover its body reads `failed` with
1339
+ // `events` EMPTY while `packRecords` reads every record in it, and only
1340
+ // `wrapperHolds` above stands between that pack and this loop. A reader
1341
+ // must never crash on a pack a party can build, so the gap is graded rather
1342
+ // than indexed into, and the pack's records are dropped exactly as a
1343
+ // `malformed` pack's are (gate 4 on proof#140, finding 3).
1344
+ const ungraded =
1345
+ verified.events.length !== records.length || signed.events.length !== records.length;
1346
+ const knownBody = (CASE_BODY_TYPES as readonly unknown[]).includes(bodyType);
1347
+
1348
+ // WHAT IS WRONG INSIDE IT, from here on. First the pack's own GRADE, which
1349
+ // propagates: the case never reads above its weakest pack (5.5.1 step 1
1350
+ // v1.20, A23; up to v1.19 it was the bundle status).
1351
+ let check: DeclarationCheck | null = null;
1352
+ let full: PackGrade | null = null;
1353
+ let causes: PackGradeCauseKind[] = [];
1354
+ let grade: PackVerdict;
1355
+ if (signed.status === "malformed") {
1356
+ grade = "malformed";
1357
+ } else if (!knownBody) {
1358
+ grade = signed.status as PackVerdict;
1359
+ } else if (ungraded) {
1360
+ grade = signed.status === "verified" ? "malformed" : (signed.status as PackVerdict);
1361
+ } else {
1362
+ check = checkDeclaration(pack.bundle, pack.content);
1363
+ const completeness = completenessNotes(pack.bundle);
1364
+ full = gradePack(verified, accountDisclosure(pack.bundle, pack.content ?? {}), completeness, check);
1365
+ const bareGrade = gradePack(
1366
+ signed,
1367
+ accountDisclosure(pack.bundle, {}),
1368
+ completeness,
1369
+ checkDeclaration(pack.bundle, null),
1370
+ );
1371
+ // NO `failed` HERE RESTS ON BYTES NOBODY SIGNED. The signed grade decides
1372
+ // where it is `failed` or `malformed`; a failure only the side map
1373
+ // produced is `incomplete`; otherwise the full grade.
1374
+ if (bareGrade.verdict === "failed" || bareGrade.verdict === "malformed") {
1375
+ grade = bareGrade.verdict;
1376
+ causes = bareGrade.causes.map((c) => c.kind);
1377
+ } else if (full.verdict === "failed" || full.verdict === "malformed") {
1378
+ grade = "incomplete";
1379
+ causes = full.causes.map((c) => c.kind);
1380
+ } else {
1381
+ grade = full.verdict;
1382
+ causes = full.causes.map((c) => c.kind);
1383
+ }
1384
+ }
1385
+ if (grade !== "verified") {
1386
+ const reasons = bundleReasons(signed);
1387
+ if (knownBody && ungraded && signed.status !== "malformed") {
1388
+ // Said, because nothing else says it: the verifier's own reasons are
1389
+ // about the bundle, and this pack's records are dropped for a reason
1390
+ // of this reader's.
1391
+ reasons.push(
1392
+ `this verifier graded ${verified.events.length} of the ${records.length} ` +
1393
+ "record(s) the pack holds, so none of them is read",
1394
+ );
1395
+ }
1396
+ note(PACK_GRADE_NOT_VERIFIED, {
1397
+ packRef: pack.ref,
1398
+ status: grade as CaseVerdict,
1399
+ causes,
1400
+ reasons,
1401
+ });
1402
+ }
1403
+ if (verified.status === "failed" && signed.status !== "failed") {
1404
+ note(PACK_BUNDLE_CONTENT_DEPENDENT, {
1126
1405
  packRef: pack.ref,
1127
- status: verified.status as CaseVerdict,
1128
- reasons: bundleReasons(verified),
1406
+ reasons: onlyIn(bundleReasons(verified), bundleReasons(signed)),
1407
+ });
1408
+ }
1409
+ const recordsWithheld = full !== null ? full.recordsWithheld : 0;
1410
+ packReadings.push({
1411
+ ref: pack.ref,
1412
+ signingKey: holder,
1413
+ grade,
1414
+ recordsWithheld,
1415
+ declarationVersion: knownBody ? declarationVersionOf(pack.bundle) : null,
1416
+ });
1417
+ if (recordsWithheld > 0) {
1418
+ // A NOTE, with no contribution (5.5.3 v1.20): the holder signed that
1419
+ // these records' content does not travel, and the scope sentence counts
1420
+ // them, so a clean verdict never hides them.
1421
+ caseNotes.push({
1422
+ position,
1423
+ note: { kind: CONTENT_WITHHELD_BY_DECLARATION, packRef: pack.ref, count: recordsWithheld },
1129
1424
  });
1130
1425
  }
1131
1426
  // The body is one the verifier could not read, so it graded no record, and
1132
1427
  // nothing below has a grading to read. Named above.
1133
- if (verified.status === "malformed") continue;
1428
+ if (signed.status === "malformed") continue;
1134
1429
 
1135
- const bodyType = bodyTypeOf(pack.bundle);
1136
- if (!(CASE_BODY_TYPES as readonly unknown[]).includes(bodyType)) {
1430
+ if (!knownBody) {
1137
1431
  // `sanning.evidence.export/v2` lands here, and it is NOT unwrapped: its
1138
1432
  // records sit under `body.source_bundle`, and reading them is a change to
1139
1433
  // which packs this reader grades, so it is a decision of its own and not
@@ -1143,58 +1437,37 @@ export async function readCase(
1143
1437
  note(PACK_BODY_TYPE_UNKNOWN, { packRef: pack.ref });
1144
1438
  continue;
1145
1439
  }
1146
-
1147
- const records = packRecords(pack.bundle, pack.content);
1148
- // The per-record gradings below are joined to `records` BY INDEX, so a
1149
- // verifier that graded a different number of records than the pack holds
1150
- // leaves this loop with no grading to read. That is not hypothetical: a
1151
- // pack whose `body_hash` does not cover its body reads `failed` with
1152
- // `events` EMPTY while `packRecords` reads every record in it, and only
1153
- // `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
1155
- // 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, {
1162
- 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
- ],
1168
- });
1169
- continue;
1170
- }
1440
+ // Graded above (`malformed` where the verifier called it `verified`), and
1441
+ // none of its records are read.
1442
+ if (ungraded) continue;
1171
1443
  if (records.length === 0) {
1172
1444
  note(PACK_HOLDS_NO_RECORDS, { packRef: pack.ref });
1173
1445
  continue;
1174
1446
  }
1175
1447
 
1176
- const check = checkDeclaration(pack.bundle, pack.content);
1448
+ const checked = check as DeclarationCheck;
1177
1449
  claimsByPack.set(position, declarationClaims(pack.bundle));
1178
- if (check.contentFindings.length > 0) {
1450
+ rolesByPack.set(position, new Map((checked.roles ?? []).map((r) => [r.eventId, r.role])));
1451
+ if (checked.contentFindings.length > 0) {
1179
1452
  // BESIDE the deviation exception and never instead of it: a pack can do
1180
1453
  // both, over different records, and the two say different things. This
1181
1454
  // one is `incomplete` because whoever assembled the case chose the bytes
1182
1455
  // it turns on (proof#147).
1183
1456
  note(PACK_DECLARATION_CONTENT_DEPENDENT, {
1184
1457
  packRef: pack.ref,
1185
- count: check.contentFindings.length,
1458
+ count: checked.contentFindings.length,
1186
1459
  });
1187
1460
  }
1188
- if (check.verdict === "failed") {
1461
+ if (checked.verdict === "failed") {
1189
1462
  note(PACK_DECLARATION_DEVIATES, {
1190
1463
  packRef: pack.ref,
1191
- count: check.deviations.length,
1464
+ count: checked.deviations.length,
1192
1465
  });
1193
- } else if (check.contentFindings.length > 0) {
1466
+ } else if (checked.contentFindings.length > 0) {
1194
1467
  // Already named above. `partial` here is not "the reader cannot read the
1195
1468
  // claim": the claim was read, and part of it rests on bytes nobody
1196
1469
  // signed.
1197
- } else if (check.verdict === "verified") {
1470
+ } else if (checked.verdict === "verified") {
1198
1471
  // A pack that agrees with its own claim contributes NOTHING, rather than
1199
1472
  // a `verified` of its own. The case verdict is over the hand-off
1200
1473
  // readings, and criterion 4's "mixed" is about mixed HAND-OFF outcomes: a
@@ -1202,14 +1475,25 @@ export async function readCase(
1202
1475
  // every case, so one broken pair beside two honest declarations would
1203
1476
  // read `mixed` and the failure would be the quieter half of a two-word
1204
1477
  // answer. A pack-level PROBLEM still contributes, through its exception.
1205
- } else if (check.present) {
1478
+ } else if (checked.present) {
1206
1479
  // `partial` with a declaration present is 5.4.3's "a claim this reader
1207
1480
  // cannot read", which `gradePack` already carries to `incomplete`. The
1208
1481
  // same posture here, for the same reason: the field has no accept-set and
1209
1482
  // the holder writes it.
1210
1483
  note(PACK_DECLARATION_UNREADABLE, { packRef: pack.ref });
1211
1484
  } else {
1212
- note(PACK_UNDECLARED, { packRef: pack.ref });
1485
+ // WHAT IT CONTRIBUTES TURNS ON THE WHOLE READING (5.5.1 step 2 v1.20,
1486
+ // A23, I4): `partial` where the reading holds a hand-off record, and a
1487
+ // NOTE where it holds none. That is not known until the pile is read, so
1488
+ // the slot is held HERE, in pack order, and settled below.
1489
+ undeclaredSlots.push({
1490
+ exceptionAt: exceptions.length,
1491
+ contributionAt: contributions.length,
1492
+ position,
1493
+ ref: pack.ref,
1494
+ });
1495
+ exceptions.push(null);
1496
+ contributions.push(null);
1213
1497
  }
1214
1498
 
1215
1499
  // Each record on its OWN envelope. `packRecords` and the verifier both walk
@@ -1218,6 +1502,7 @@ export async function readCase(
1218
1502
  for (let index = 0; index < records.length; index += 1) {
1219
1503
  const record = records[index];
1220
1504
  const graded = verified.events[index];
1505
+ const bare = signed.events[index];
1221
1506
  if (underQuarantine(recordSpecVersion(pack.bundle, index))) {
1222
1507
  note(RECORD_PROFILE_QUARANTINED, { packRef: pack.ref, eventId: record.eventId });
1223
1508
  continue;
@@ -1244,22 +1529,32 @@ export async function readCase(
1244
1529
  });
1245
1530
  continue;
1246
1531
  }
1247
- // Named, and the record stays in the pile (see the kinds).
1248
- if (graded.contentOk === false) {
1532
+ // Named, and the record stays in the pile (see the kinds). A mismatch
1533
+ // the signed body carries on its own is `failed`; one that appears only
1534
+ // with the side map is `incomplete` (proof#159).
1535
+ if (bare.contentOk === false) {
1249
1536
  note(RECORD_CONTENT_MISMATCH, {
1250
1537
  packRef: pack.ref,
1251
1538
  eventId: record.eventId,
1252
- reasons: [...graded.errors],
1539
+ reasons: [...bare.errors],
1540
+ });
1541
+ } else if (graded.contentOk === false) {
1542
+ note(RECORD_CONTENT_DEPENDENT, {
1543
+ packRef: pack.ref,
1544
+ eventId: record.eventId,
1545
+ reasons: onlyIn(graded.errors, bare.errors),
1253
1546
  });
1254
1547
  }
1548
+ // Every other check on the SIGNED reading, so side content can neither
1549
+ // add a failure here nor take one away.
1255
1550
  if (
1256
- !(graded.inclusionOk && graded.checkpointBound) ||
1257
- (!graded.ok && graded.contentOk !== false)
1551
+ !(bare.inclusionOk && bare.checkpointBound) ||
1552
+ (!bare.ok && bare.contentOk !== false)
1258
1553
  ) {
1259
1554
  note(RECORD_DOES_NOT_VERIFY_IN_ITS_PACK, {
1260
1555
  packRef: pack.ref,
1261
1556
  eventId: record.eventId,
1262
- reasons: [...graded.errors],
1557
+ reasons: [...bare.errors],
1263
1558
  });
1264
1559
  }
1265
1560
  held.push({
@@ -1290,6 +1585,7 @@ export async function readCase(
1290
1585
  findings: [...reading.findings],
1291
1586
  failedChecks: [...reading.failedChecks],
1292
1587
  counterparts: [...reading.candidates],
1588
+ role: rolesByPack.get(position)?.get(eventId) ?? null,
1293
1589
  });
1294
1590
  // proof#137's door, named. Read from the ONE reader of the declaration
1295
1591
  // (`declarationClaims`), never from a second parse here. Only a READABLE
@@ -1303,10 +1599,14 @@ export async function readCase(
1303
1599
  // `not_a_handoff_record` exit it was a check the audited party switched off
1304
1600
  // by respelling two fields, and behind the `malformed` exit it was one a
1305
1601
  // party switched off by breaking its own record.
1602
+ //
1603
+ // v1's ALONE (5.5.1 v1.20, A21). A v2 pack's claim check is rule 9, which
1604
+ // the declaration check already ran on the signed body.
1306
1605
  const claims = claimsByPack.get(position);
1307
1606
  if (
1308
1607
  claims !== undefined &&
1309
1608
  claims.readable &&
1609
+ claims.version === DECLARATION_VERSION &&
1310
1610
  !claims.claimedSides.includes(eventId)
1311
1611
  ) {
1312
1612
  note(RECORD_UNCLAIMED, { packRef, eventId });
@@ -1408,18 +1708,57 @@ export async function readCase(
1408
1708
  }
1409
1709
  }
1410
1710
 
1411
- if (handoffRecords === 0) note(CASE_HOLDS_NO_HANDOFF_RECORDS);
1711
+ // A reading with no hand-off record in it is not an exception (5.5.3 v1.20,
1712
+ // A23, D6). The undeclared packs, settled: the exception `pack_undeclared`,
1713
+ // contributing `partial`, in the slot it was given in pack order where the
1714
+ // reading holds a hand-off record; a NOTE with no contribution where it holds
1715
+ // none.
1716
+ for (const slot of undeclaredSlots) {
1717
+ if (handoffRecords > 0) {
1718
+ exceptions[slot.exceptionAt] = { kind: PACK_UNDECLARED, packRef: slot.ref };
1719
+ contributions[slot.contributionAt] = [slot.ref, EXCEPTION_VERDICT[PACK_UNDECLARED] as CaseVerdict];
1720
+ } else {
1721
+ caseNotes.push({ position: slot.position, note: { kind: PACK_UNDECLARED, packRef: slot.ref } });
1722
+ }
1723
+ }
1724
+ const named = exceptions.filter((e): e is CaseException => e !== null);
1725
+ const contributed = contributions.filter((c): c is [string, CaseVerdict] => c !== null);
1726
+ // Pack order, stable. A pack carries at most one note: one with no
1727
+ // declaration withholds nothing by one.
1728
+ const notes = caseNotes
1729
+ .map((n, i) => ({ ...n, i }))
1730
+ .sort((a, b) => a.position - b.position || a.i - b.i)
1731
+ .map((n) => n.note);
1412
1732
 
1413
1733
  return {
1414
- verdict: verdictOf(exceptions, contributions),
1415
- exceptions,
1734
+ verdict: verdictOf(named, contributed),
1735
+ exceptions: named,
1416
1736
  records,
1417
- contributions,
1737
+ contributions: contributed,
1418
1738
  handoffRecords,
1419
1739
  onChainChecked: witnessChecked.length > 0 && witnessChecked.every((c) => c),
1740
+ notes,
1741
+ packs: packReadings,
1420
1742
  };
1421
1743
  }
1422
1744
 
1745
+ /**
1746
+ * `body.declaration.declaration_version` where it is one this reader knows,
1747
+ * and `null` otherwise.
1748
+ */
1749
+ function declarationVersionOf(bundle: unknown): string | null {
1750
+ const at = (v: unknown, k: string): unknown =>
1751
+ typeof v === "object" && v !== null && !Array.isArray(v)
1752
+ ? (v as Record<string, unknown>)[k]
1753
+ : undefined;
1754
+ const declaration = at(at(bundle, "body"), "declaration");
1755
+ if (typeof declaration !== "object" || declaration === null || Array.isArray(declaration)) {
1756
+ return null;
1757
+ }
1758
+ const version = (declaration as Record<string, unknown>)["declaration_version"];
1759
+ return version === DECLARATION_VERSION || version === DECLARATION_VERSION_V2 ? version : null;
1760
+ }
1761
+
1423
1762
  // ---- Presentation (optional) ------------------------------------------------
1424
1763
 
1425
1764
  /**
@@ -1536,13 +1875,21 @@ export function renderException(exception: CaseException): string {
1536
1875
  "does not verify, so none of its records are read and the key it " +
1537
1876
  "names is not counted as its signer"
1538
1877
  );
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";
1878
+ case PACK_GRADE_NOT_VERIFIED: {
1879
+ let head = `${exception.packRef} grades ${(exception.status ?? "").toUpperCase()} as a pack`;
1880
+ if (exception.causes !== undefined && exception.causes.length > 0) {
1881
+ head += ` (${exception.causes.join(", ")})`;
1882
+ }
1543
1883
  if (exception.status === "malformed") head += ", so none of its records are read";
1544
1884
  return head + because(exception.reasons);
1545
1885
  }
1886
+ case PACK_BUNDLE_CONTENT_DEPENDENT:
1887
+ return (
1888
+ `${exception.packRef} reads FAILED as a bundle only together with ` +
1889
+ "the unsigned content handed in beside it, which anyone could have " +
1890
+ "supplied, so whether that failure is the pack's is UNKNOWN" +
1891
+ because(exception.reasons)
1892
+ );
1546
1893
  case RECORD_ENVELOPE_DOES_NOT_VERIFY:
1547
1894
  return (
1548
1895
  `${where} is in a pack whose wrapper verifies, and its own envelope ` +
@@ -1561,6 +1908,14 @@ export function renderException(exception: CaseException): string {
1561
1908
  "to, and it is still read as a hand-off record" +
1562
1909
  because(exception.reasons)
1563
1910
  );
1911
+ case RECORD_CONTENT_DEPENDENT:
1912
+ return (
1913
+ `${where} commits to content that the unsigned content handed in ` +
1914
+ "beside its pack does not match, and anyone could have supplied " +
1915
+ "that content, so what it says about the record is UNKNOWN; the " +
1916
+ "record is still read as a hand-off record" +
1917
+ because(exception.reasons)
1918
+ );
1564
1919
  case RECORD_PROFILE_QUARANTINED:
1565
1920
  return (
1566
1921
  `${where} is signed under a profile this case reader does not read ` +
@@ -1568,47 +1923,146 @@ export function renderException(exception: CaseException): string {
1568
1923
  );
1569
1924
  case CASE_HOLDS_NO_PACKS:
1570
1925
  return "this case holds no packs";
1571
- case CASE_HOLDS_NO_HANDOFF_RECORDS:
1926
+ default:
1927
+ return exception.kind;
1928
+ }
1929
+ }
1930
+
1931
+ /** The house wording for one NOTE. Offered, not imposed, as `renderException` is. */
1932
+ export function renderCaseNote(note: CaseNote): string {
1933
+ switch (note.kind) {
1934
+ case CONTENT_WITHHELD_BY_DECLARATION:
1572
1935
  return (
1573
- "no record this verifier read in this case is a hand-off record, " +
1574
- "so no hand-off was verified"
1936
+ `${note.packRef} withholds the content of ${note.count} record(s) by ` +
1937
+ "its own signed declaration: their signatures and commitments were " +
1938
+ "checked, their content was not"
1575
1939
  );
1940
+ case PACK_UNDECLARED:
1941
+ return `${note.packRef} does not say what it holds`;
1576
1942
  default:
1577
- return exception.kind;
1943
+ return (note as CaseNote).kind;
1578
1944
  }
1579
1945
  }
1580
1946
 
1581
1947
  /**
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.
1948
+ * The scope sentence, printed directly under the headline (evidence-bundle
1949
+ * 5.5.6 v1.20, A26), byte for byte the text `render_case_summary` returns.
1950
+ *
1951
+ * The packs in the reading; the distinct keys, compared as keys, that signed
1952
+ * the wrapper of a pack whose wrapper held; the hand-off records; the
1953
+ * exceptions; and that the reading was made offline. Where any pack holds
1954
+ * records withheld by its declaration, their total; where any pack is noted
1955
+ * `pack_undeclared`, how many. Those two lines are part of what the headline
1956
+ * MEANS, and a surface MUST print them with it.
1957
+ *
1958
+ * What was not checked on the public record is no longer a clause here. It is
1959
+ * the witness line (`renderWitnessLine`), printed with every headline.
1587
1960
  */
1588
1961
  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") {
1603
- return (
1604
- `this case verifies across ${reading.records.length} record(s): ` +
1605
- `${n} hand-off record(s), every one matched, no exceptions` +
1606
- scope
1962
+ const keys = new Set(reading.packs.filter((p) => p.grade !== null).map((p) => p.signingKey));
1963
+ const withheld = reading.packs.reduce((n, p) => n + p.recordsWithheld, 0);
1964
+ const undeclared = reading.notes.filter((n) => n.kind === PACK_UNDECLARED).length;
1965
+ const lines = [
1966
+ `${reading.packs.length} pack(s), ${keys.size} signing key(s), ` +
1967
+ `${reading.handoffRecords} hand-off record(s), ` +
1968
+ `${reading.exceptions.length} exception(s), read offline.`,
1969
+ ];
1970
+ if (withheld > 0) {
1971
+ lines.push(
1972
+ `${withheld} record(s)' content withheld by their holder: their ` +
1973
+ "signatures and commitments were checked, their content was not.",
1607
1974
  );
1608
1975
  }
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
- );
1976
+ if (undeclared > 0) lines.push(`${undeclared} pack(s) do not say what they hold.`);
1977
+ return lines.join(" ");
1978
+ }
1979
+
1980
+ // ---- The headline and the witness line (evidence-bundle 5.5.6 v1.20) -------
1981
+
1982
+ /**
1983
+ * The one display title of each of the eight 3 words. A surface prints the
1984
+ * title, never the word, and never a title of its own (#173, A18). The wording
1985
+ * is Will's to change through the glossary (DL-71); what each MEANS is pinned
1986
+ * in the spec.
1987
+ */
1988
+ export const VERDICT_DISPLAY_TITLES: Readonly<Record<string, string>> = {
1989
+ verified: "Verified",
1990
+ partial: "Partial",
1991
+ pending: "Pending",
1992
+ incomplete: "Incomplete",
1993
+ failed: "Failed",
1994
+ malformed: "Not readable",
1995
+ not_found: "Not found",
1996
+ mixed: "Mixed",
1997
+ };
1998
+
1999
+ /**
2000
+ * What a reader may rely on, for each of the six words a case reading reaches
2001
+ * (5.5.6 v1.20, A26), the same for every drop. `pending` and `not_found`
2002
+ * never head a case reading: they are witness states.
2003
+ */
2004
+ export const CASE_RELIANCE: Readonly<Record<CaseVerdict, string>> = {
2005
+ verified:
2006
+ "Every pack in this drop is genuine and unaltered and agrees with its " +
2007
+ "own signed declaration. Every hand-off with both sides here is " +
2008
+ "intact. Every other record is one its holder signed as a step. " +
2009
+ "Nothing here could not be checked. This was read offline, on this " +
2010
+ "device. It does not say these packs are everything the parties " +
2011
+ "recorded.",
2012
+ partial:
2013
+ "Nothing here was proven wrong, and a check could not be completed: a " +
2014
+ "pack names no witness, or a pack in a hand-off reading does not say " +
2015
+ "what it holds. The exceptions say which.",
2016
+ incomplete:
2017
+ "Nothing here was proven wrong, and part of it could not be checked: " +
2018
+ "the other side of a hand-off is not in this drop, bytes were not " +
2019
+ "handed over, or a claim could not be read. Each exception says what " +
2020
+ "is missing and, where a key is known, whose pack would supply it.",
2021
+ failed:
2022
+ "A check ran and did not pass. Do not rely on this drop until the " +
2023
+ "named failure is explained. The failure is named on the pack or " +
2024
+ "record it is about, and every other exception is listed beside it.",
2025
+ malformed:
2026
+ "Part of this drop could not be read as evidence, and nothing was " +
2027
+ "proven wrong. The exceptions name what could not be read.",
2028
+ mixed:
2029
+ "The readings here differ and none of them failed: some are verified " +
2030
+ "and some could not be checked. Read each pack's row and each " +
2031
+ "exception before relying on any of it.",
2032
+ };
2033
+
2034
+ /** The three witness states, and how each is printed (5.5.6, D7). No other. */
2035
+ export const WITNESS_STATES = {
2036
+ confirmed: "Confirmed on the public record",
2037
+ not_checked: "Not checked",
2038
+ differs: "The public record holds different bytes",
2039
+ } as const;
2040
+ export type WitnessState = keyof typeof WITNESS_STATES;
2041
+
2042
+ /**
2043
+ * The witness line: a lookup of the public record, reported beside the
2044
+ * headline and never in it (D7). It never raises or lowers the headline and
2045
+ * never changes its title. `reasons` says why a `not_checked` line did not
2046
+ * settle, in a fixed order; `services` names each service that answered, as
2047
+ * the URL the lookup used: named, never trusted (6).
2048
+ */
2049
+ export interface WitnessLine {
2050
+ state: WitnessState;
2051
+ reasons: string[];
2052
+ services: string[];
2053
+ }
2054
+
2055
+ /** The line printed where no lookup ran, which is every reading this kernel produces on its own. */
2056
+ export const WITNESS_NOT_LOOKED_UP: Readonly<WitnessLine> = Object.freeze({
2057
+ state: "not_checked",
2058
+ reasons: ["no lookup was run"],
2059
+ services: [],
2060
+ });
2061
+
2062
+ /** The witness line as both kernels print it. */
2063
+ export function renderWitnessLine(line: WitnessLine): string {
2064
+ let text = `Witness: ${WITNESS_STATES[line.state]}`;
2065
+ if (line.reasons.length > 0) text += `: ${line.reasons.join("; ")}`;
2066
+ if (line.services.length > 0) text += ` (answered: ${line.services.join(", ")})`;
2067
+ return text;
1614
2068
  }