@sanning/proof 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +43 -8
  2. package/dist/agent-proof.d.ts +2 -2
  3. package/dist/agent-proof.js +4 -4
  4. package/dist/case-file.d.ts +126 -0
  5. package/dist/case-file.js +349 -0
  6. package/dist/case.d.ts +199 -43
  7. package/dist/case.js +522 -146
  8. package/dist/cli.d.ts +132 -1
  9. package/dist/cli.js +1140 -96
  10. package/dist/crypto.d.ts +1 -1
  11. package/dist/crypto.js +5 -1
  12. package/dist/declaration.d.ts +58 -52
  13. package/dist/declaration.js +256 -33
  14. package/dist/evidence.d.ts +2 -0
  15. package/dist/evidence.js +134 -19
  16. package/dist/exit-codes.d.ts +17 -0
  17. package/dist/exit-codes.js +50 -0
  18. package/dist/grading.d.ts +28 -13
  19. package/dist/grading.js +90 -31
  20. package/dist/handoff.d.ts +16 -1
  21. package/dist/handoff.js +91 -16
  22. package/dist/index.d.ts +10 -7
  23. package/dist/index.js +15 -7
  24. package/dist/message-parts.d.ts +11 -9
  25. package/dist/message-parts.js +11 -9
  26. package/dist/types.d.ts +1 -0
  27. package/dist/verifier.d.ts +9 -2
  28. package/dist/verifier.js +28 -12
  29. package/dist/zip-reader.d.ts +50 -0
  30. package/dist/zip-reader.js +220 -0
  31. package/package.json +2 -2
  32. package/src/agent-proof.ts +4 -4
  33. package/src/case-file.ts +419 -0
  34. package/src/case.ts +651 -156
  35. package/src/cli.ts +1325 -100
  36. package/src/crypto.ts +5 -1
  37. package/src/declaration.ts +248 -52
  38. package/src/evidence.ts +142 -21
  39. package/src/exit-codes.ts +54 -0
  40. package/src/grading.ts +109 -52
  41. package/src/handoff.ts +108 -17
  42. package/src/index.ts +60 -3
  43. package/src/message-parts.ts +11 -9
  44. package/src/types.ts +5 -0
  45. package/src/verifier.ts +35 -13
  46. package/src/zip-reader.ts +229 -0
  47. package/vectors/README.md +56 -3
  48. package/vectors/case-file-index.json +668 -0
  49. package/vectors/case-split.json +1102 -0
  50. package/vectors/pack-grading.json +68 -4
  51. package/vectors/stamp-on-chain.json +3 -3
package/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
@@ -185,7 +187,8 @@
185
187
  * (F2).
186
188
  * 10. **`counterpart_absent`** (`continue`). Skips the ambiguity and pair
187
189
  * reporting, and there is nothing to skip: 4.1 emits that finding only
188
- * for an EMPTY candidate list. Which of the three counterpart kinds it
190
+ * where no candidate is left to read, an EMPTY candidate list or one whose
191
+ * every candidate another record accounts for (4.1 v1.17, A20). Which of the three counterpart kinds it
189
192
  * gets reads the keys of the packs that verified, counted before exit 4,
190
193
  * and whether any pack took exit 2 or 3.
191
194
  *
@@ -206,7 +209,9 @@
206
209
  * verdict, exactly as `counterpart_absent` and `not_a_handoff_record` are
207
210
  * findings beside a verdict in 4.1.
208
211
  *
209
- * A case with more than one outcome is `mixed` (anchor#198, criterion 4).
212
+ * A case with any `failed` contribution is `failed`: a proven failure keeps
213
+ * the headline (5.5.4 v1.16, A17, proof#158). Otherwise a case with more than
214
+ * one outcome is `mixed` (anchor#198, criterion 4).
210
215
  *
211
216
  * WHAT THIS READER DOES NOT CLAIM
212
217
  * ===============================
@@ -225,20 +230,24 @@
225
230
  * with that verdict and its reasons, so the case never reads above its
226
231
  * weakest pack. On-chain re-fetch and `gradePack` are a caller's to run.
227
232
  *
228
- * THE COST OF 4.1's "GRADE EVERY CANDIDATE", INHERITED HERE AND STATED RATHER
229
- * THAN LEFT TO BE FOUND (proof#134, ratified by Phil 2026-09-22). A reader
230
- * whose pile holds two or more records of the complementary type signed by one
231
- * counterparty key cannot reach `verified`: the other exchanges' records are
232
- * candidates too, they commit to other messages, and they fail. One agent
233
- * sending two messages to the same counterparty inside one case lands there,
234
- * and it surfaces as `COUNTERPART_AMBIGUOUS` beside the failure. An
235
- * alternating exchange (A sends, B replies) does NOT, because a reply is
236
- * signed by the other key and is the other type. Narrowing that here with
237
- * `handoffs[]` is precisely the move that would reopen proof#137's door.
233
+ * THE COST OF 4.1's "GRADE EVERY CANDIDATE", AND WHAT IS LEFT OF IT (4.1
234
+ * v1.17, amendment A20, which reopens proof#134). Up to v1.16 a pile holding
235
+ * two or more records of the complementary type signed by one counterparty key
236
+ * could not reach `verified`. 4.1 now sets such a candidate aside where a
237
+ * record the subject's own key signed pairs with it verified at another
238
+ * sequence, so two messages in one direction, and the demo's A to B, B to A,
239
+ * A to B, read clean. What is left surfaces as `COUNTERPART_AMBIGUOUS` beside
240
+ * the failure: fan-in, fan-out, and the same pair in two cases at the same
241
+ * sequence. Narrowing any of it here with `handoffs[]` is precisely the move
242
+ * that would reopen proof#137's door.
238
243
  */
239
244
 
240
245
  import {
246
+ type DeclarationCheck,
241
247
  type DeclarationClaims,
248
+ type DeclarationRole,
249
+ DECLARATION_VERSION,
250
+ DECLARATION_VERSION_V2,
242
251
  checkDeclaration,
243
252
  declarationClaims,
244
253
  packRecords,
@@ -253,7 +262,14 @@ import {
253
262
  } from "./handoff.js";
254
263
  import { verifyEvidenceBundle } from "./evidence.js";
255
264
  import type { EvidenceBundleResult } from "./evidence.js";
256
- 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";
257
273
  import type { SideContentMap } from "./side-content.js";
258
274
 
259
275
  // ---- The vocabulary ---------------------------------------------------------
@@ -335,7 +351,9 @@ export const CASE_QUARANTINED_PROFILES = ["sanning.mlflow/v1"] as const;
335
351
  /**
336
352
  * A conformant hand-off record with no counterpart among the records this
337
353
  * reader read, where every pack in the case verified under a key this reader
338
- * can read and NO pack in the case is signed by the key the record names.
354
+ * can read and NO pack in the case is signed by the key the record names,
355
+ * or where every candidate it had was accounted for by another record under
356
+ * A20 (the exception then carries `count`, and names no pack).
339
357
  * **An exception, never a failure** (anchor#198, criterion 3).
340
358
  *
341
359
  * That is ALL it says. It does not say the counterparty's account is absent,
@@ -425,9 +443,11 @@ export const COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS =
425
443
  export const COUNTERPART_ABSENT_SIGNER_UNKNOWN = "counterpart_absent_signer_unknown";
426
444
 
427
445
  /**
428
- * More than one candidate counterpart (4.1, and proof#134's stated cost). The
429
- * reading is the worst of them and the ambiguity is named: a reader permitted
430
- * to take one match can be handed an honest decoy beside a tampered record.
446
+ * More than one candidate counterpart that nothing accounts for (4.1 v1.17,
447
+ * A20). The reading is the worst of them and the ambiguity is named: a reader
448
+ * permitted to take one match can be handed an honest decoy beside a tampered
449
+ * record. `count` is how many of the candidates are live, which is the number
450
+ * the ambiguity is about.
431
451
  */
432
452
  export const COUNTERPART_AMBIGUOUS = "counterpart_ambiguous";
433
453
 
@@ -618,17 +638,26 @@ export const PACK_KEY_UNREADABLE = "pack_key_unreadable";
618
638
  export const PACK_DOES_NOT_VERIFY = "pack_does_not_verify";
619
639
 
620
640
  /**
621
- * The pack's wrapper HOLDS and its own bundle verdict, from
622
- * `verifyEvidenceBundle` run here offline over the pack and the content handed
623
- * in beside it, is not `verified`. The exception carries that verdict
624
- * (`status`) and the verifier's reasons (`reasons`: its bundle errors, then its
625
- * stamp errors, then its checkpoint errors, each as the verifier wrote it), and
626
- * it CONTRIBUTES that verdict, so the case verdict never reads above its
627
- * weakest pack. Gate 3 on proof#140: two unwitnessed (`partial`) packs read as
628
- * a `verified` case with no exception. It does not say who is at fault:
629
- * 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.
630
659
  */
631
- export const PACK_BUNDLE_NOT_VERIFIED = "pack_bundle_not_verified";
660
+ export const PACK_GRADE_NOT_VERIFIED = "pack_grade_not_verified";
632
661
 
633
662
  /**
634
663
  * A record in a pack whose wrapper holds, whose OWN envelope does not verify
@@ -652,13 +681,41 @@ export const RECORD_DOES_NOT_VERIFY_IN_ITS_PACK = "record_does_not_verify_in_its
652
681
 
653
682
  /**
654
683
  * A record whose envelope verifies and whose disclosed content does not hash to
655
- * the `content_hash` it commits to (`EventResult.contentOk` is false). `failed`,
656
- * 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
657
689
  * supplied by whoever assembled the case, and gate 3 on proof#140 showed that
658
690
  * party erasing an honest verified pair by swapping it.
659
691
  */
660
692
  export const RECORD_CONTENT_MISMATCH = "record_content_mismatch";
661
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
+
662
719
  /**
663
720
  * A record signed under a ratified profile that is under quarantine
664
721
  * (`CASE_QUARANTINED_PROFILES`, pending DL-63). Not read, in any kernel.
@@ -670,11 +727,14 @@ export const RECORD_PROFILE_QUARANTINED = "record_profile_quarantined";
670
727
  export const CASE_HOLDS_NO_PACKS = "case_holds_no_packs";
671
728
 
672
729
  /**
673
- * Every pack was read and not one hand-off record came out of any of them.
674
- * There is no hand-off to have a verdict about, and saying `verified` over that
675
- * 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.
676
734
  */
677
- 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];
678
738
 
679
739
  /**
680
740
  * Every exception this reader can report, as a closed set. A surface may render
@@ -699,13 +759,14 @@ export const CASE_EXCEPTION_KINDS = [
699
759
  PACK_HOLDS_NO_RECORDS,
700
760
  PACK_KEY_UNREADABLE,
701
761
  PACK_DOES_NOT_VERIFY,
702
- PACK_BUNDLE_NOT_VERIFIED,
762
+ PACK_GRADE_NOT_VERIFIED,
763
+ PACK_BUNDLE_CONTENT_DEPENDENT,
703
764
  RECORD_ENVELOPE_DOES_NOT_VERIFY,
704
765
  RECORD_DOES_NOT_VERIFY_IN_ITS_PACK,
705
766
  RECORD_CONTENT_MISMATCH,
767
+ RECORD_CONTENT_DEPENDENT,
706
768
  RECORD_PROFILE_QUARANTINED,
707
769
  CASE_HOLDS_NO_PACKS,
708
- CASE_HOLDS_NO_HANDOFF_RECORDS,
709
770
  ] as const;
710
771
 
711
772
  export type CaseExceptionKind = (typeof CASE_EXCEPTION_KINDS)[number];
@@ -721,10 +782,10 @@ export type CaseExceptionKind = (typeof CASE_EXCEPTION_KINDS)[number];
721
782
  * contributes a clean verdict, which is what makes the invariant in `verdictOf`
722
783
  * hold.
723
784
  *
724
- * `PACK_BUNDLE_NOT_VERIFIED` is the one row that is not a constant: it
725
- * contributes the pack's own bundle verdict, which is `partial`, `failed` or
726
- * `malformed` and never `verified` (the exception is emitted only when it is
727
- * 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`.
728
789
  */
729
790
  const EXCEPTION_VERDICT: Record<CaseExceptionKind, CaseVerdict | null> = {
730
791
  [COUNTERPART_ABSENT]: "incomplete",
@@ -745,15 +806,28 @@ const EXCEPTION_VERDICT: Record<CaseExceptionKind, CaseVerdict | null> = {
745
806
  [PACK_HOLDS_NO_RECORDS]: "incomplete",
746
807
  [PACK_KEY_UNREADABLE]: "incomplete",
747
808
  [PACK_DOES_NOT_VERIFY]: "failed",
748
- [PACK_BUNDLE_NOT_VERIFIED]: null,
809
+ [PACK_GRADE_NOT_VERIFIED]: null,
810
+ [PACK_BUNDLE_CONTENT_DEPENDENT]: "incomplete",
749
811
  [RECORD_ENVELOPE_DOES_NOT_VERIFY]: "failed",
750
812
  [RECORD_DOES_NOT_VERIFY_IN_ITS_PACK]: "failed",
751
813
  [RECORD_CONTENT_MISMATCH]: "failed",
814
+ [RECORD_CONTENT_DEPENDENT]: "incomplete",
752
815
  [RECORD_PROFILE_QUARANTINED]: "incomplete",
753
816
  [CASE_HOLDS_NO_PACKS]: "partial",
754
- [CASE_HOLDS_NO_HANDOFF_RECORDS]: "partial",
755
817
  };
756
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
+
757
831
  // ---- Inputs -----------------------------------------------------------------
758
832
 
759
833
  /**
@@ -793,13 +867,24 @@ export interface CaseException {
793
867
  counterpartyKey?: string | null;
794
868
  /** Which checks a formed pair failed, in 4.1's fixed order. */
795
869
  failedChecks?: string[];
796
- /** How many of a thing the exception is about, where it is about a count. */
870
+ /**
871
+ * How many of a thing the exception is about, where it is about a count. On
872
+ * `COUNTERPART_ABSENT` it is set only where 4.1 set every candidate aside
873
+ * (v1.17, A20), and it is how many were set aside.
874
+ */
797
875
  count?: number;
798
- /** The pack's own bundle verdict, for `PACK_BUNDLE_NOT_VERIFIED`. */
876
+ /** The pack's case grade, for `PACK_GRADE_NOT_VERIFIED`. */
799
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[];
800
885
  /**
801
886
  * What the bundle verifier said, as it said it, for the kinds that carry a
802
- * 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
803
888
  * graded on the record's own envelope and content.
804
889
  */
805
890
  reasons?: string[];
@@ -821,6 +906,54 @@ export interface CaseRecordReading {
821
906
  findings: string[];
822
907
  failedChecks: string[];
823
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;
824
957
  }
825
958
 
826
959
  export interface CaseReading {
@@ -864,6 +997,10 @@ export interface CaseReading {
864
997
  * checked packs, which is false.
865
998
  */
866
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[];
867
1004
  }
868
1005
 
869
1006
  // ---- The reading ------------------------------------------------------------
@@ -910,6 +1047,24 @@ const wrapperHolds = (result: EvidenceBundleResult): boolean =>
910
1047
  * bundle-level error at all, and an exception that named the verdict and no
911
1048
  * reason would send a reader looking for a defect in the pack as a whole.
912
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
+
913
1068
  const bundleReasons = (result: EvidenceBundleResult): string[] => [
914
1069
  ...result.errors,
915
1070
  ...(result.stamps ?? []).flatMap((stamp) => stamp.errors),
@@ -946,8 +1101,13 @@ function underQuarantine(specVersion: unknown): boolean {
946
1101
  /**
947
1102
  * The case verdict, from the contributions alone.
948
1103
  *
949
- * One outcome is that outcome; more than one is `mixed` (anchor#198,
950
- * 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.
951
1111
  *
952
1112
  * THE INVARIANT, stated so a mutation test has something to kill: `verified` is
953
1113
  * reachable ONLY when `exceptions` is empty. Every exception kind carries a
@@ -959,13 +1119,26 @@ function underQuarantine(specVersion: unknown): boolean {
959
1119
  function verdictOf(
960
1120
  exceptions: readonly CaseException[],
961
1121
  contributions: readonly [string, CaseVerdict][],
1122
+ holdsPacks = true,
962
1123
  ): CaseVerdict {
963
- // Unreachable through `readCase`, which names a case with no contributions
964
- // rather than returning one. Defended anyway: an empty aggregate reading as
965
- // `verified` is the defect class itself.
966
- 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
+ }
967
1136
  const outcomes = new Set(contributions.map(([, v]) => v));
968
- 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";
969
1142
  if (verdict === "verified" && exceptions.length > 0) {
970
1143
  throw new Error(
971
1144
  `verified over a non-empty exception list: ${exceptions.map((e) => e.kind).join(", ")}`,
@@ -1004,28 +1177,40 @@ function verdictOf(
1004
1177
  export async function readCase(
1005
1178
  packs: readonly CasePack[],
1006
1179
  ): Promise<CaseReading> {
1007
- const exceptions: CaseException[] = [];
1008
- 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)[] = [];
1009
1184
 
1010
1185
  const note = (
1011
1186
  kind: CaseExceptionKind,
1012
1187
  fields: Omit<CaseException, "kind"> = {},
1013
1188
  ) => {
1014
1189
  exceptions.push({ kind, ...fields });
1015
- // `null` is PACK_BUNDLE_NOT_VERIFIED: the pack's own verdict, never verified.
1016
- 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
+ }
1017
1198
  contributions.push([fields.eventId ?? fields.packRef ?? "case", contribution]);
1018
1199
  };
1019
1200
 
1020
1201
  if (packs.length === 0) {
1021
1202
  note(CASE_HOLDS_NO_PACKS);
1203
+ const named = exceptions as CaseException[];
1204
+ const contributed = contributions as [string, CaseVerdict][];
1022
1205
  return {
1023
- verdict: verdictOf(exceptions, contributions),
1024
- exceptions,
1206
+ verdict: verdictOf(named, contributed, false),
1207
+ exceptions: named,
1025
1208
  records: [],
1026
- contributions,
1209
+ contributions: contributed,
1027
1210
  handoffRecords: 0,
1028
1211
  onChainChecked: false,
1212
+ notes: [],
1213
+ packs: [],
1029
1214
  };
1030
1215
  }
1031
1216
 
@@ -1070,6 +1255,20 @@ export async function readCase(
1070
1255
  // below, from the pack envelope and never from the body, so no body type, no
1071
1256
  // disclosure choice and no record content decides whether it is read.
1072
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
+ }[] = [];
1073
1272
  // How many packs this reader could not tie to a key: an unreadable wrapper
1074
1273
  // key, or a wrapper that does not hold. While it is not zero, "no pack in this
1075
1274
  // case is signed by the named key" is not established, so the innocent
@@ -1086,6 +1285,13 @@ export async function readCase(
1086
1285
  // of these spellings (proof#135) and the reading must not.
1087
1286
  note(PACK_KEY_UNREADABLE, { packRef: pack.ref });
1088
1287
  unattributed += 1;
1288
+ packReadings.push({
1289
+ ref: pack.ref,
1290
+ signingKey: null,
1291
+ grade: null,
1292
+ recordsWithheld: 0,
1293
+ declarationVersion: null,
1294
+ });
1089
1295
  continue;
1090
1296
  }
1091
1297
 
@@ -1101,30 +1307,127 @@ export async function readCase(
1101
1307
  | undefined,
1102
1308
  });
1103
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;
1104
1317
 
1105
1318
  // WHOSE PACK IS IT. The wrapper, and only the wrapper.
1106
1319
  if (!wrapperHolds(verified)) {
1107
1320
  note(PACK_DOES_NOT_VERIFY, { packRef: pack.ref });
1108
1321
  unattributed += 1;
1322
+ packReadings.push({
1323
+ ref: pack.ref,
1324
+ signingKey: holder,
1325
+ grade: null,
1326
+ recordsWithheld: 0,
1327
+ declarationVersion: null,
1328
+ });
1109
1329
  continue;
1110
1330
  }
1111
1331
  holderKeys.add(holder);
1112
1332
 
1113
- // WHAT IS WRONG INSIDE IT, from here on. First the pack's own assurance,
1114
- // which propagates: the case never reads above its weakest pack.
1115
- if (verified.status !== "verified") {
1116
- 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, {
1117
1405
  packRef: pack.ref,
1118
- status: verified.status as CaseVerdict,
1119
- 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 },
1120
1424
  });
1121
1425
  }
1122
1426
  // The body is one the verifier could not read, so it graded no record, and
1123
1427
  // nothing below has a grading to read. Named above.
1124
- if (verified.status === "malformed") continue;
1428
+ if (signed.status === "malformed") continue;
1125
1429
 
1126
- const bodyType = bodyTypeOf(pack.bundle);
1127
- if (!(CASE_BODY_TYPES as readonly unknown[]).includes(bodyType)) {
1430
+ if (!knownBody) {
1128
1431
  // `sanning.evidence.export/v2` lands here, and it is NOT unwrapped: its
1129
1432
  // records sit under `body.source_bundle`, and reading them is a change to
1130
1433
  // which packs this reader grades, so it is a decision of its own and not
@@ -1134,58 +1437,37 @@ export async function readCase(
1134
1437
  note(PACK_BODY_TYPE_UNKNOWN, { packRef: pack.ref });
1135
1438
  continue;
1136
1439
  }
1137
-
1138
- const records = packRecords(pack.bundle, pack.content);
1139
- // The per-record gradings below are joined to `records` BY INDEX, so a
1140
- // verifier that graded a different number of records than the pack holds
1141
- // leaves this loop with no grading to read. That is not hypothetical: a
1142
- // pack whose `body_hash` does not cover its body reads `failed` with
1143
- // `events` EMPTY while `packRecords` reads every record in it, and only
1144
- // `wrapperHolds` above stands between that pack and this loop. A reader
1145
- // must never crash on a pack a party can build, so the gap is NAMED rather
1146
- // than indexed into, and the pack's records are dropped exactly as a
1147
- // `malformed` pack's are (gate 4 on proof#140, finding 3: under the
1148
- // mutation that dropped `bodyHashOk` from the wrapper test, this kernel
1149
- // threw `Cannot read properties of undefined (reading 'envelopeOk')` here
1150
- // and nothing in the ladder said so).
1151
- if (verified.events.length !== records.length) {
1152
- note(PACK_BUNDLE_NOT_VERIFIED, {
1153
- packRef: pack.ref,
1154
- status: verified.status as CaseVerdict,
1155
- reasons: [
1156
- `this verifier graded ${verified.events.length} of the ` +
1157
- `${records.length} record(s) the pack holds, so none of them is read`,
1158
- ],
1159
- });
1160
- continue;
1161
- }
1440
+ // Graded above (`malformed` where the verifier called it `verified`), and
1441
+ // none of its records are read.
1442
+ if (ungraded) continue;
1162
1443
  if (records.length === 0) {
1163
1444
  note(PACK_HOLDS_NO_RECORDS, { packRef: pack.ref });
1164
1445
  continue;
1165
1446
  }
1166
1447
 
1167
- const check = checkDeclaration(pack.bundle, pack.content);
1448
+ const checked = check as DeclarationCheck;
1168
1449
  claimsByPack.set(position, declarationClaims(pack.bundle));
1169
- 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) {
1170
1452
  // BESIDE the deviation exception and never instead of it: a pack can do
1171
1453
  // both, over different records, and the two say different things. This
1172
1454
  // one is `incomplete` because whoever assembled the case chose the bytes
1173
1455
  // it turns on (proof#147).
1174
1456
  note(PACK_DECLARATION_CONTENT_DEPENDENT, {
1175
1457
  packRef: pack.ref,
1176
- count: check.contentFindings.length,
1458
+ count: checked.contentFindings.length,
1177
1459
  });
1178
1460
  }
1179
- if (check.verdict === "failed") {
1461
+ if (checked.verdict === "failed") {
1180
1462
  note(PACK_DECLARATION_DEVIATES, {
1181
1463
  packRef: pack.ref,
1182
- count: check.deviations.length,
1464
+ count: checked.deviations.length,
1183
1465
  });
1184
- } else if (check.contentFindings.length > 0) {
1466
+ } else if (checked.contentFindings.length > 0) {
1185
1467
  // Already named above. `partial` here is not "the reader cannot read the
1186
1468
  // claim": the claim was read, and part of it rests on bytes nobody
1187
1469
  // signed.
1188
- } else if (check.verdict === "verified") {
1470
+ } else if (checked.verdict === "verified") {
1189
1471
  // A pack that agrees with its own claim contributes NOTHING, rather than
1190
1472
  // a `verified` of its own. The case verdict is over the hand-off
1191
1473
  // readings, and criterion 4's "mixed" is about mixed HAND-OFF outcomes: a
@@ -1193,14 +1475,25 @@ export async function readCase(
1193
1475
  // every case, so one broken pair beside two honest declarations would
1194
1476
  // read `mixed` and the failure would be the quieter half of a two-word
1195
1477
  // answer. A pack-level PROBLEM still contributes, through its exception.
1196
- } else if (check.present) {
1478
+ } else if (checked.present) {
1197
1479
  // `partial` with a declaration present is 5.4.3's "a claim this reader
1198
1480
  // cannot read", which `gradePack` already carries to `incomplete`. The
1199
1481
  // same posture here, for the same reason: the field has no accept-set and
1200
1482
  // the holder writes it.
1201
1483
  note(PACK_DECLARATION_UNREADABLE, { packRef: pack.ref });
1202
1484
  } else {
1203
- 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);
1204
1497
  }
1205
1498
 
1206
1499
  // Each record on its OWN envelope. `packRecords` and the verifier both walk
@@ -1209,6 +1502,7 @@ export async function readCase(
1209
1502
  for (let index = 0; index < records.length; index += 1) {
1210
1503
  const record = records[index];
1211
1504
  const graded = verified.events[index];
1505
+ const bare = signed.events[index];
1212
1506
  if (underQuarantine(recordSpecVersion(pack.bundle, index))) {
1213
1507
  note(RECORD_PROFILE_QUARANTINED, { packRef: pack.ref, eventId: record.eventId });
1214
1508
  continue;
@@ -1235,22 +1529,32 @@ export async function readCase(
1235
1529
  });
1236
1530
  continue;
1237
1531
  }
1238
- // Named, and the record stays in the pile (see the kinds).
1239
- 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) {
1240
1536
  note(RECORD_CONTENT_MISMATCH, {
1241
1537
  packRef: pack.ref,
1242
1538
  eventId: record.eventId,
1243
- 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),
1244
1546
  });
1245
1547
  }
1548
+ // Every other check on the SIGNED reading, so side content can neither
1549
+ // add a failure here nor take one away.
1246
1550
  if (
1247
- !(graded.inclusionOk && graded.checkpointBound) ||
1248
- (!graded.ok && graded.contentOk !== false)
1551
+ !(bare.inclusionOk && bare.checkpointBound) ||
1552
+ (!bare.ok && bare.contentOk !== false)
1249
1553
  ) {
1250
1554
  note(RECORD_DOES_NOT_VERIFY_IN_ITS_PACK, {
1251
1555
  packRef: pack.ref,
1252
1556
  eventId: record.eventId,
1253
- reasons: [...graded.errors],
1557
+ reasons: [...bare.errors],
1254
1558
  });
1255
1559
  }
1256
1560
  held.push({
@@ -1281,6 +1585,7 @@ export async function readCase(
1281
1585
  findings: [...reading.findings],
1282
1586
  failedChecks: [...reading.failedChecks],
1283
1587
  counterparts: [...reading.candidates],
1588
+ role: rolesByPack.get(position)?.get(eventId) ?? null,
1284
1589
  });
1285
1590
  // proof#137's door, named. Read from the ONE reader of the declaration
1286
1591
  // (`declarationClaims`), never from a second parse here. Only a READABLE
@@ -1294,10 +1599,14 @@ export async function readCase(
1294
1599
  // `not_a_handoff_record` exit it was a check the audited party switched off
1295
1600
  // by respelling two fields, and behind the `malformed` exit it was one a
1296
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.
1297
1605
  const claims = claimsByPack.get(position);
1298
1606
  if (
1299
1607
  claims !== undefined &&
1300
1608
  claims.readable &&
1609
+ claims.version === DECLARATION_VERSION &&
1301
1610
  !claims.claimedSides.includes(eventId)
1302
1611
  ) {
1303
1612
  note(RECORD_UNCLAIMED, { packRef, eventId });
@@ -1343,13 +1652,34 @@ export async function readCase(
1343
1652
  // that party's pack. Where some pack could not be tied to a key, "no
1344
1653
  // pack is signed by the named key" is not established and the reading
1345
1654
  // says it could not tell.
1655
+ //
1656
+ // EXCEPT where 4.1 set every candidate aside (v1.17, A20). There the
1657
+ // absence is a finding about the candidates the reader DID read, each
1658
+ // paired `verified` with another record the subject's own signer made,
1659
+ // and not a finding about any pack. 4.1 and evidence-bundle A16 name the
1660
+ // neutral kind for it, and the damning one would put the audited party's
1661
+ // own unfaithful record on the counterparty's pack as a statement
1662
+ // against it. `count` is how many candidates were set aside, which is
1663
+ // also what tells the rendered sentence which absence this is.
1664
+ const graded = reading.candidateReadings;
1665
+ const setAside =
1666
+ graded.length > 0 && graded.every((c) => c.accountedFor !== null)
1667
+ ? graded.length
1668
+ : 0;
1346
1669
  const kind =
1347
- named !== null && holderKeys.has(named)
1348
- ? COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS
1349
- : unattributed > 0
1350
- ? COUNTERPART_ABSENT_SIGNER_UNKNOWN
1351
- : COUNTERPART_ABSENT;
1352
- note(kind, { packRef, eventId, counterpartyKey: named });
1670
+ setAside > 0
1671
+ ? COUNTERPART_ABSENT
1672
+ : named !== null && holderKeys.has(named)
1673
+ ? COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS
1674
+ : unattributed > 0
1675
+ ? COUNTERPART_ABSENT_SIGNER_UNKNOWN
1676
+ : COUNTERPART_ABSENT;
1677
+ note(
1678
+ kind,
1679
+ setAside > 0
1680
+ ? { packRef, eventId, counterpartyKey: named, count: setAside }
1681
+ : { packRef, eventId, counterpartyKey: named },
1682
+ );
1353
1683
  continue;
1354
1684
  }
1355
1685
 
@@ -1357,7 +1687,7 @@ export async function readCase(
1357
1687
  note(COUNTERPART_AMBIGUOUS, {
1358
1688
  packRef,
1359
1689
  eventId,
1360
- count: reading.candidates.length,
1690
+ count: reading.candidateReadings.filter((c) => c.accountedFor === null).length,
1361
1691
  });
1362
1692
  }
1363
1693
  // ON `failedChecks` AND NOT ON THE VERDICT WORD. 4.1 keeps the UNION of
@@ -1378,18 +1708,57 @@ export async function readCase(
1378
1708
  }
1379
1709
  }
1380
1710
 
1381
- 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);
1382
1732
 
1383
1733
  return {
1384
- verdict: verdictOf(exceptions, contributions),
1385
- exceptions,
1734
+ verdict: verdictOf(named, contributed),
1735
+ exceptions: named,
1386
1736
  records,
1387
- contributions,
1737
+ contributions: contributed,
1388
1738
  handoffRecords,
1389
1739
  onChainChecked: witnessChecked.length > 0 && witnessChecked.every((c) => c),
1740
+ notes,
1741
+ packs: packReadings,
1390
1742
  };
1391
1743
  }
1392
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
+
1393
1762
  // ---- Presentation (optional) ------------------------------------------------
1394
1763
 
1395
1764
  /**
@@ -1406,6 +1775,17 @@ export function renderException(exception: CaseException): string {
1406
1775
  const where = `${exception.packRef}/${exception.eventId}`;
1407
1776
  switch (exception.kind) {
1408
1777
  case COUNTERPART_ABSENT:
1778
+ if (exception.count) {
1779
+ const which =
1780
+ exception.count === 1
1781
+ ? "its one candidate counterpart pairs"
1782
+ : `each of its ${exception.count} candidate counterparts pairs`;
1783
+ return (
1784
+ `${where} has no counterpart among the records this verifier read: ` +
1785
+ `${which} verified with another record signed by the key that ` +
1786
+ "signed this one, at a different sequence, and is set aside"
1787
+ );
1788
+ }
1409
1789
  return (
1410
1790
  `${where} has no counterpart among the records this verifier read, ` +
1411
1791
  "and no pack in this case is signed by the key it names"
@@ -1495,13 +1875,21 @@ export function renderException(exception: CaseException): string {
1495
1875
  "does not verify, so none of its records are read and the key it " +
1496
1876
  "names is not counted as its signer"
1497
1877
  );
1498
- case PACK_BUNDLE_NOT_VERIFIED: {
1499
- let head =
1500
- `${exception.packRef} reads ${(exception.status ?? "").toUpperCase()} as ` +
1501
- "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
+ }
1502
1883
  if (exception.status === "malformed") head += ", so none of its records are read";
1503
1884
  return head + because(exception.reasons);
1504
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
+ );
1505
1893
  case RECORD_ENVELOPE_DOES_NOT_VERIFY:
1506
1894
  return (
1507
1895
  `${where} is in a pack whose wrapper verifies, and its own envelope ` +
@@ -1520,54 +1908,161 @@ export function renderException(exception: CaseException): string {
1520
1908
  "to, and it is still read as a hand-off record" +
1521
1909
  because(exception.reasons)
1522
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
+ );
1523
1919
  case RECORD_PROFILE_QUARANTINED:
1524
1920
  return (
1525
1921
  `${where} is signed under a profile this case reader does not read ` +
1526
- "while its admission is undecided (DL-63), so what it says is UNKNOWN"
1922
+ "while its admission is undecided, so what it says is UNKNOWN"
1527
1923
  );
1528
1924
  case CASE_HOLDS_NO_PACKS:
1529
1925
  return "this case holds no packs";
1530
- 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:
1531
1935
  return (
1532
- "no record this verifier read in this case is a hand-off record, " +
1533
- "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"
1534
1939
  );
1940
+ case PACK_UNDECLARED:
1941
+ return `${note.packRef} does not say what it holds`;
1535
1942
  default:
1536
- return exception.kind;
1943
+ return (note as CaseNote).kind;
1537
1944
  }
1538
1945
  }
1539
1946
 
1540
1947
  /**
1541
- * One line a surface can print for a whole case.
1542
- *
1543
- * Both kernels print this sentence, so a reader who runs the Python command and
1544
- * the TypeScript one over one case archive reads one sentence rather than two
1545
- * 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.
1546
1960
  */
1547
1961
  export function renderCaseSummary(reading: CaseReading): string {
1548
- const n = reading.handoffRecords;
1549
- // What was NOT checked, in the same sentence as what was, and in the
1550
- // `verified` one above all (gate 4 on proof#140, finding 3). It is a
1551
- // statement of SCOPE and not a doubt: every pack here was verified offline,
1552
- // and no `anchor_ref` any of them names was fetched from a chain, because
1553
- // this reader makes no network call. Left unsaid, a clean case reads as
1554
- // though a witness had been checked, which nobody established. It is not an
1555
- // exception, and it must never become one: an empty exception list is the
1556
- // only shape a reader may read as clean, so a standing exception here would
1557
- // put `verified` out of reach for good.
1558
- const scope = reading.onChainChecked
1559
- ? ""
1560
- : "; no anchor_ref in it was re-fetched on chain, which this reader never does";
1561
- if (reading.verdict === "verified") {
1562
- return (
1563
- `this case verifies across ${reading.records.length} record(s): ` +
1564
- `${n} hand-off record(s), every one matched, no exceptions` +
1565
- 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.",
1566
1974
  );
1567
1975
  }
1568
- return (
1569
- `this case reads ${reading.verdict.toUpperCase()} over ${n} hand-off ` +
1570
- `record(s), with ${reading.exceptions.length} exception(s)` +
1571
- scope
1572
- );
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;
1573
2068
  }