@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.
- package/README.md +25 -12
- package/dist/agent-proof.d.ts +2 -2
- package/dist/agent-proof.js +4 -4
- package/dist/case-file.d.ts +126 -0
- package/dist/case-file.js +349 -0
- package/dist/case.d.ts +173 -26
- package/dist/case.js +469 -123
- package/dist/cli.d.ts +38 -5
- package/dist/cli.js +557 -208
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.js +5 -1
- package/dist/declaration.d.ts +58 -52
- package/dist/declaration.js +256 -33
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.js +134 -19
- package/dist/exit-codes.d.ts +17 -0
- package/dist/exit-codes.js +50 -0
- package/dist/grading.d.ts +28 -13
- package/dist/grading.js +90 -31
- package/dist/index.d.ts +10 -7
- package/dist/index.js +13 -6
- package/dist/message-parts.d.ts +11 -9
- package/dist/message-parts.js +11 -9
- package/dist/types.d.ts +1 -0
- package/dist/verifier.d.ts +9 -2
- package/dist/verifier.js +28 -12
- package/dist/zip-reader.d.ts +50 -0
- package/dist/zip-reader.js +220 -0
- package/package.json +2 -2
- package/src/agent-proof.ts +4 -4
- package/src/case-file.ts +419 -0
- package/src/case.ts +586 -132
- package/src/cli.ts +651 -206
- package/src/crypto.ts +5 -1
- package/src/declaration.ts +248 -52
- package/src/evidence.ts +142 -21
- package/src/exit-codes.ts +54 -0
- package/src/grading.ts +109 -52
- package/src/index.ts +58 -2
- package/src/message-parts.ts +11 -9
- package/src/types.ts +5 -0
- package/src/verifier.ts +35 -13
- package/src/zip-reader.ts +229 -0
- package/vectors/README.md +23 -5
- package/vectors/case-file-index.json +668 -0
- package/vectors/case-split.json +218 -9
- package/vectors/pack-grading.json +68 -4
- 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
|
|
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
|
|
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
|
|
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
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
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
|
|
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
|
|
661
|
-
*
|
|
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
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
* `
|
|
730
|
-
* contributes the pack's
|
|
731
|
-
*
|
|
732
|
-
*
|
|
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
|
-
[
|
|
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
|
|
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: `
|
|
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
|
-
*
|
|
959
|
-
*
|
|
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
|
-
//
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
|
|
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.
|
|
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
|
-
|
|
1017
|
-
|
|
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
|
|
1025
|
-
|
|
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(
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
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 (
|
|
1428
|
+
if (signed.status === "malformed") continue;
|
|
1134
1429
|
|
|
1135
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
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
|
|
1448
|
+
const checked = check as DeclarationCheck;
|
|
1177
1449
|
claimsByPack.set(position, declarationClaims(pack.bundle));
|
|
1178
|
-
|
|
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:
|
|
1458
|
+
count: checked.contentFindings.length,
|
|
1186
1459
|
});
|
|
1187
1460
|
}
|
|
1188
|
-
if (
|
|
1461
|
+
if (checked.verdict === "failed") {
|
|
1189
1462
|
note(PACK_DECLARATION_DEVIATES, {
|
|
1190
1463
|
packRef: pack.ref,
|
|
1191
|
-
count:
|
|
1464
|
+
count: checked.deviations.length,
|
|
1192
1465
|
});
|
|
1193
|
-
} else if (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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: [...
|
|
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
|
-
!(
|
|
1257
|
-
(!
|
|
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: [...
|
|
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
|
-
|
|
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(
|
|
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
|
|
1540
|
-
let head =
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1574
|
-
"
|
|
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
|
|
1943
|
+
return (note as CaseNote).kind;
|
|
1578
1944
|
}
|
|
1579
1945
|
}
|
|
1580
1946
|
|
|
1581
1947
|
/**
|
|
1582
|
-
*
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
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
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
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
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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
|
}
|