@sanning/proof 0.16.0 → 0.17.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/src/case.ts CHANGED
@@ -248,6 +248,7 @@ import {
248
248
  type DeclarationRole,
249
249
  DECLARATION_VERSION,
250
250
  DECLARATION_VERSION_V2,
251
+ type PackRecord,
251
252
  checkDeclaration,
252
253
  declarationClaims,
253
254
  packRecords,
@@ -260,7 +261,7 @@ import {
260
261
  readHandoffs,
261
262
  recordIsMalformed,
262
263
  } from "./handoff.js";
263
- import { coSignaturesPresent, verifyEvidenceBundle } from "./evidence.js";
264
+ import { coSignaturesPresent, resolveTraceEvents, verifyEvidenceBundle } from "./evidence.js";
264
265
  import type { EvidenceBundleResult } from "./evidence.js";
265
266
  import {
266
267
  type PackGrade,
@@ -784,6 +785,42 @@ export const CASE_EXCEPTION_KINDS = [
784
785
 
785
786
  export type CaseExceptionKind = (typeof CASE_EXCEPTION_KINDS)[number];
786
787
 
788
+ /**
789
+ * The status every record in every pack carries (evidence-bundle 5.5.4
790
+ * v1.35, A43, proof#204), as a closed set. A record is listed for each element
791
+ * of its pack's `body.events` as `packRecords` reads them, or, for a
792
+ * `sanning.evidence.export/v2` pack, of the events of the inline source
793
+ * bundle it wraps (`resolveTraceEvents`), and gets exactly one:
794
+ *
795
+ * - `read_clean`: the record went into the pile, no exception carries its
796
+ * `packIndex` and `recordIndex`, and its pack's declaration check
797
+ * names it by nothing (`declarationFindings` is empty). It says nothing
798
+ * about its PACK: the pack's own grade and exceptions are on the pack, and a
799
+ * surface MUST show that grade beside the status.
800
+ * - `read_flagged`: the record went into the pile, and at least one
801
+ * exception carries its `packIndex` and `recordIndex`, or its pack's
802
+ * declaration check names its `eventId` by a deviation or a content
803
+ * finding (`declarationFindings` is not empty; under an `eventId` the
804
+ * pack carries twice, every record with that id). An exception about the
805
+ * pack alone flags no record.
806
+ * - `not_read`: the record never went into the pile. `reason` is the kind
807
+ * of the exception that says why, on the record
808
+ * (`record_profile_quarantined`, `record_envelope_does_not_verify`,
809
+ * `record_bytes_withheld`, `record_bytes_unreadable`) or on its pack
810
+ * (`pack_key_unreadable`, `pack_does_not_verify`,
811
+ * `pack_body_type_unknown`, `pack_grade_not_verified` where the body
812
+ * could not be read or the verifier graded a different number of records).
813
+ * An export's wrapped records carry the reason of the exception their pack
814
+ * actually got: `pack_body_type_unknown` where its wrapper holds, and
815
+ * otherwise `pack_key_unreadable`, `pack_does_not_verify` or
816
+ * `pack_grade_not_verified`.
817
+ *
818
+ * Before A43 the reading listed only the records it read, so a surface had to
819
+ * work out which records were missing, and why, by a rule of its own.
820
+ */
821
+ export const CASE_RECORD_STATUSES = ["read_clean", "read_flagged", "not_read"] as const;
822
+ export type CaseRecordStatus = (typeof CASE_RECORD_STATUSES)[number];
823
+
787
824
  /**
788
825
  * What each exception kind contributes to the case verdict. It is a TABLE and
789
826
  * not a chain of branches, so "which exceptions move the verdict" is one thing
@@ -868,10 +905,25 @@ export interface CasePack {
868
905
  * `renderException`. */
869
906
  export interface CaseException {
870
907
  kind: CaseExceptionKind;
871
- /** Which pack this is about, by the reader's own name for it. */
908
+ /**
909
+ * Which pack this is about, by the reader's own name for it. Two packs can
910
+ * carry one `ref`, so it is a label and never the join: `packIndex` is.
911
+ */
872
912
  packRef?: string;
913
+ /**
914
+ * Which pack this is about, by its position in `CaseReading.packs` (0
915
+ * first), present wherever `packRef` is (A43). Unique in the reading.
916
+ */
917
+ packIndex?: number;
873
918
  /** Which record, by its envelope `event_id`. */
874
919
  eventId?: string;
920
+ /**
921
+ * Which record, by its position in its pack's `body.events` (0 first),
922
+ * present wherever `eventId` is (A43). One pack can carry one `event_id`
923
+ * twice, so `packIndex` and `recordIndex` together are the join to
924
+ * `CaseReading.records`.
925
+ */
926
+ recordIndex?: number;
875
927
  /**
876
928
  * The counterparty key the record named, for the two counterpart kinds.
877
929
  * Normalized (`normalizedSigningKey`), so a reader compares what this module
@@ -904,20 +956,54 @@ export interface CaseException {
904
956
  }
905
957
 
906
958
  /**
907
- * 4.1's reading of one record the case holds, with the pack it came from.
908
- *
909
- * EVERY record every pack supplied and this reader could read gets one of
910
- * these, in pack order and then in bundle order, including the ones that are
911
- * not hand-off records at all (`findings` carries `not_a_handoff_record`). 4.1
912
- * (v1.9, A14) requires that and names the reason: the one thing an attacker
913
- * wants is for a record to be absent from the output.
959
+ * One record of one pack, with its status and, where it was read, 4.1's
960
+ * reading of it.
961
+ *
962
+ * EVERY record of every pack gets one of these (A43), in pack order and then
963
+ * in bundle order: the ones this reader read, including the ones that are not
964
+ * hand-off records at all (`findings` carries `not_a_handoff_record`), and the
965
+ * ones it did not, with `status` `not_read` and the `reason`. 4.1 (v1.9, A14)
966
+ * names why: the one thing an attacker wants is for a record to be absent from
967
+ * the output. An export's wrapped records are listed, `not_read`. A pack whose
968
+ * body carries no `events` array, and wraps none, lists none, and its own
969
+ * exception says why.
914
970
  */
915
971
  export interface CaseRecordReading {
916
972
  packRef: string;
973
+ /** The pack, by its position in `CaseReading.packs` (A43). */
974
+ packIndex: number;
917
975
  eventId: string;
976
+ /**
977
+ * The record, by its position in the list of records its pack lists (A43):
978
+ * `body.events`, or for a `sanning.evidence.export/v2` pack the events of the
979
+ * source bundle it wraps.
980
+ */
981
+ recordIndex: number;
982
+ /** One of `CASE_RECORD_STATUSES` (A43). */
983
+ status: CaseRecordStatus;
984
+ /**
985
+ * For `not_read`, the kind of the exception that says why; `null` for a
986
+ * record that was read.
987
+ */
988
+ reason: CaseExceptionKind | null;
989
+ /**
990
+ * The kinds its pack's declaration check names this record by, deviations
991
+ * (`DECLARATION_DEVIATION_KINDS`) then content findings
992
+ * (`DECLARATION_CONTENT_KINDS`), each in the check's own order. Matched on
993
+ * `eventId`, so under an `event_id` the pack carries twice every record with
994
+ * that id carries it. A record that went into the pile with any is
995
+ * `read_flagged` (A43).
996
+ */
997
+ declarationFindings: string[];
998
+ /** 4.1's verdict, or `null` where 4.1 gave none or the record was not read. */
918
999
  verdict: string | null;
919
1000
  findings: string[];
920
1001
  failedChecks: string[];
1002
+ /**
1003
+ * 4.1's candidate counterparts, each as `<pack ref>/<event_id>`. A LABEL for
1004
+ * a person to read and never the join: two packs can carry one ref, and one
1005
+ * pack one `event_id` twice (A43).
1006
+ */
921
1007
  counterparts: string[];
922
1008
  /**
923
1009
  * The role the pack's v2 declaration signs for this record, `handoff` or
@@ -935,6 +1021,8 @@ export interface CaseNote {
935
1021
  kind: CaseNoteKind;
936
1022
  /** Which pack, by the reader's own name for it. */
937
1023
  packRef: string;
1024
+ /** Which pack, by its position in `CaseReading.packs` (A43). */
1025
+ packIndex: number;
938
1026
  /**
939
1027
  * For `CONTENT_WITHHELD_BY_DECLARATION`, how many of the pack's records its
940
1028
  * declaration withholds.
@@ -948,6 +1036,13 @@ export interface CaseNote {
948
1036
  * its wrapper, and never by an organization (D10).
949
1037
  */
950
1038
  export interface CasePackReading {
1039
+ /**
1040
+ * The pack's position in the order the reader was given the packs, 0 first
1041
+ * (A43): the one name for a pack that is unique in the reading. Every
1042
+ * exception, note and record names its pack by it as `packIndex`.
1043
+ */
1044
+ index: number;
1045
+ /** The reader's own name for the pack. Two packs can carry the same one. */
951
1046
  ref: string;
952
1047
  /** The wrapper key, normalized, or `null` where this reader could not read it. */
953
1048
  signingKey: string | null;
@@ -980,7 +1075,10 @@ export interface CaseReading {
980
1075
  * reachable only together with `verdict === "verified"`.
981
1076
  */
982
1077
  exceptions: CaseException[];
983
- /** One reading per record the case holds and this reader could read. */
1078
+ /**
1079
+ * One entry per record of every pack, read or not, in pack order and then
1080
+ * bundle order, each with its status (A43).
1081
+ */
984
1082
  records: CaseRecordReading[];
985
1083
  /**
986
1084
  * Every `[contributor, verdict]` the case verdict was computed from, in
@@ -1237,8 +1335,40 @@ export async function readCase(
1237
1335
  // rather than parsed back out of a reference string. A reference is for a
1238
1336
  // human to read; a join rebuilt by splitting one is a join on a value a
1239
1337
  // holder can put a separator in.
1240
- const provenance: { position: number; packRef: string; eventId: string }[] =
1241
- [];
1338
+ const provenance: {
1339
+ position: number;
1340
+ packRef: string;
1341
+ eventId: string;
1342
+ recordIndex: number;
1343
+ }[] = [];
1344
+ // Every record of every pack, in pack order and bundle order (A43): where it
1345
+ // went into the pile, its place there (`heldAt`), and where it did not, the
1346
+ // kind of the exception that says why. Nothing is listed only where the pack
1347
+ // body carries no `events` array, and that pack's own exception says why.
1348
+ const slots: {
1349
+ position: number;
1350
+ packRef: string;
1351
+ recordIndex: number;
1352
+ eventId: string;
1353
+ heldAt: number | null;
1354
+ reason: CaseExceptionKind | null;
1355
+ }[] = [];
1356
+ const unread = (
1357
+ position: number,
1358
+ packRef: string,
1359
+ listed: readonly PackRecord[],
1360
+ reason: CaseExceptionKind,
1361
+ ) => {
1362
+ listed.forEach((record, recordIndex) =>
1363
+ slots.push({ position, packRef, recordIndex, eventId: record.eventId, heldAt: null, reason }),
1364
+ );
1365
+ };
1366
+ // The kinds each pack's declaration check names each record by, keyed by the
1367
+ // pack's POSITION and then by `eventId` (A43). The check names a record by
1368
+ // `event_id` alone, so under an id the pack carries twice every record with
1369
+ // that id is named: failing closed, rather than guessing which of the two the
1370
+ // check meant.
1371
+ const declarationFindings = new Map<number, Map<string, string[]>>();
1242
1372
  // Keyed by the pack's POSITION and never by its `ref`. `ref` is the reader's
1243
1373
  // own name for a pack, and nothing stops two packs in one archive carrying
1244
1374
  // the same one: keyed by ref, the second pack's claims silently replaced the
@@ -1296,9 +1426,11 @@ export async function readCase(
1296
1426
  // pack whose signer is UNKNOWN, and not a pack nobody signed. Checked
1297
1427
  // BEFORE verification, because the two bundle verifiers disagree on some
1298
1428
  // of these spellings (proof#135) and the reading must not.
1299
- note(PACK_KEY_UNREADABLE, { packRef: pack.ref });
1429
+ note(PACK_KEY_UNREADABLE, { packRef: pack.ref, packIndex: position });
1300
1430
  unattributed += 1;
1431
+ unread(position, pack.ref, listedRecords(pack.bundle, pack.content), PACK_KEY_UNREADABLE);
1301
1432
  packReadings.push({
1433
+ index: position,
1302
1434
  ref: pack.ref,
1303
1435
  signingKey: null,
1304
1436
  grade: null,
@@ -1330,9 +1462,11 @@ export async function readCase(
1330
1462
 
1331
1463
  // WHOSE PACK IS IT. The wrapper, and only the wrapper.
1332
1464
  if (!wrapperHolds(verified)) {
1333
- note(PACK_DOES_NOT_VERIFY, { packRef: pack.ref });
1465
+ note(PACK_DOES_NOT_VERIFY, { packRef: pack.ref, packIndex: position });
1334
1466
  unattributed += 1;
1467
+ unread(position, pack.ref, listedRecords(pack.bundle, pack.content), PACK_DOES_NOT_VERIFY);
1335
1468
  packReadings.push({
1469
+ index: position,
1336
1470
  ref: pack.ref,
1337
1471
  signingKey: holder,
1338
1472
  grade: null,
@@ -1349,7 +1483,7 @@ export async function readCase(
1349
1483
  // the case reading names it rather than moving anything on its account.
1350
1484
  caseNotes.push({
1351
1485
  position,
1352
- note: { kind: CO_SIGNATURES_PRESENT, packRef: pack.ref },
1486
+ note: { kind: CO_SIGNATURES_PRESENT, packRef: pack.ref, packIndex: position },
1353
1487
  });
1354
1488
  }
1355
1489
 
@@ -1418,6 +1552,7 @@ export async function readCase(
1418
1552
  }
1419
1553
  note(PACK_GRADE_NOT_VERIFIED, {
1420
1554
  packRef: pack.ref,
1555
+ packIndex: position,
1421
1556
  status: grade as CaseVerdict,
1422
1557
  causes,
1423
1558
  reasons,
@@ -1426,11 +1561,13 @@ export async function readCase(
1426
1561
  if (verified.status === "failed" && signed.status !== "failed") {
1427
1562
  note(PACK_BUNDLE_CONTENT_DEPENDENT, {
1428
1563
  packRef: pack.ref,
1564
+ packIndex: position,
1429
1565
  reasons: onlyIn(bundleReasons(verified), bundleReasons(signed)),
1430
1566
  });
1431
1567
  }
1432
1568
  const recordsWithheld = full !== null ? full.recordsWithheld : 0;
1433
1569
  packReadings.push({
1570
+ index: position,
1434
1571
  ref: pack.ref,
1435
1572
  signingKey: holder,
1436
1573
  grade,
@@ -1443,12 +1580,20 @@ export async function readCase(
1443
1580
  // them, so a clean verdict never hides them.
1444
1581
  caseNotes.push({
1445
1582
  position,
1446
- note: { kind: CONTENT_WITHHELD_BY_DECLARATION, packRef: pack.ref, count: recordsWithheld },
1583
+ note: {
1584
+ kind: CONTENT_WITHHELD_BY_DECLARATION,
1585
+ packRef: pack.ref,
1586
+ packIndex: position,
1587
+ count: recordsWithheld,
1588
+ },
1447
1589
  });
1448
1590
  }
1449
1591
  // The body is one the verifier could not read, so it graded no record, and
1450
1592
  // nothing below has a grading to read. Named above.
1451
- if (signed.status === "malformed") continue;
1593
+ if (signed.status === "malformed") {
1594
+ unread(position, pack.ref, listedRecords(pack.bundle, pack.content), PACK_GRADE_NOT_VERIFIED);
1595
+ continue;
1596
+ }
1452
1597
 
1453
1598
  if (!knownBody) {
1454
1599
  // `sanning.evidence.export/v2` lands here, and it is NOT unwrapped: its
@@ -1457,20 +1602,35 @@ export async function readCase(
1457
1602
  // a repair (evidence-bundle.md 5.5.1 step 1 says so too). What this exit
1458
1603
  // costs is that pack's RECORDS, and it is named every time. It no longer
1459
1604
  // costs the pack's KEY: that was read above.
1460
- note(PACK_BODY_TYPE_UNKNOWN, { packRef: pack.ref });
1605
+ note(PACK_BODY_TYPE_UNKNOWN, { packRef: pack.ref, packIndex: position });
1606
+ // Listed, and not read: the records a `sanning.evidence.export/v2` wraps
1607
+ // are listed from its inline source bundle, so a reading never says "0
1608
+ // not read" of a pack that holds records (A43).
1609
+ unread(position, pack.ref, listedRecords(pack.bundle, pack.content), PACK_BODY_TYPE_UNKNOWN);
1461
1610
  continue;
1462
1611
  }
1463
1612
  // Graded above (`malformed` where the verifier called it `verified`), and
1464
1613
  // none of its records are read.
1465
- if (ungraded) continue;
1614
+ if (ungraded) {
1615
+ unread(position, pack.ref, records, PACK_GRADE_NOT_VERIFIED);
1616
+ continue;
1617
+ }
1466
1618
  if (records.length === 0) {
1467
- note(PACK_HOLDS_NO_RECORDS, { packRef: pack.ref });
1619
+ note(PACK_HOLDS_NO_RECORDS, { packRef: pack.ref, packIndex: position });
1468
1620
  continue;
1469
1621
  }
1470
1622
 
1471
1623
  const checked = check as DeclarationCheck;
1472
1624
  claimsByPack.set(position, declarationClaims(pack.bundle));
1473
1625
  rolesByPack.set(position, new Map((checked.roles ?? []).map((r) => [r.eventId, r.role])));
1626
+ const namedByCheck = new Map<string, string[]>();
1627
+ for (const finding of [...checked.deviations, ...checked.contentFindings]) {
1628
+ if (finding.eventId === undefined) continue;
1629
+ const kinds = namedByCheck.get(finding.eventId) ?? [];
1630
+ kinds.push(finding.kind);
1631
+ namedByCheck.set(finding.eventId, kinds);
1632
+ }
1633
+ declarationFindings.set(position, namedByCheck);
1474
1634
  if (checked.contentFindings.length > 0) {
1475
1635
  // BESIDE the deviation exception and never instead of it: a pack can do
1476
1636
  // both, over different records, and the two say different things. This
@@ -1478,12 +1638,14 @@ export async function readCase(
1478
1638
  // it turns on (proof#147).
1479
1639
  note(PACK_DECLARATION_CONTENT_DEPENDENT, {
1480
1640
  packRef: pack.ref,
1641
+ packIndex: position,
1481
1642
  count: checked.contentFindings.length,
1482
1643
  });
1483
1644
  }
1484
1645
  if (checked.verdict === "failed") {
1485
1646
  note(PACK_DECLARATION_DEVIATES, {
1486
1647
  packRef: pack.ref,
1648
+ packIndex: position,
1487
1649
  count: checked.deviations.length,
1488
1650
  });
1489
1651
  } else if (checked.contentFindings.length > 0) {
@@ -1503,7 +1665,7 @@ export async function readCase(
1503
1665
  // cannot read", which `gradePack` already carries to `incomplete`. The
1504
1666
  // same posture here, for the same reason: the field has no accept-set and
1505
1667
  // the holder writes it.
1506
- note(PACK_DECLARATION_UNREADABLE, { packRef: pack.ref });
1668
+ note(PACK_DECLARATION_UNREADABLE, { packRef: pack.ref, packIndex: position });
1507
1669
  } else {
1508
1670
  // WHAT IT CONTRIBUTES TURNS ON THE WHOLE READING (5.5.1 step 2 v1.20,
1509
1671
  // A23, I4): `partial` where the reading holds a hand-off record, and a
@@ -1526,30 +1688,34 @@ export async function readCase(
1526
1688
  const record = records[index];
1527
1689
  const graded = verified.events[index];
1528
1690
  const bare = signed.events[index];
1691
+ // Which pack and which record, by position: the two names that are
1692
+ // unique in the reading whatever the reader called the pack and whatever
1693
+ // the holder wrote as `event_id` (A43).
1694
+ const at = {
1695
+ packRef: pack.ref,
1696
+ packIndex: position,
1697
+ eventId: record.eventId,
1698
+ recordIndex: index,
1699
+ };
1700
+ // Not read: listed with the kind of the exception that says why.
1701
+ const skip = (kind: CaseExceptionKind, fields: Omit<CaseException, "kind"> = {}) => {
1702
+ note(kind, { ...at, ...fields });
1703
+ slots.push({ position, packRef: pack.ref, recordIndex: index, eventId: record.eventId, heldAt: null, reason: kind });
1704
+ };
1529
1705
  if (underQuarantine(recordSpecVersion(pack.bundle, index))) {
1530
- note(RECORD_PROFILE_QUARANTINED, { packRef: pack.ref, eventId: record.eventId });
1706
+ skip(RECORD_PROFILE_QUARANTINED);
1531
1707
  continue;
1532
1708
  }
1533
1709
  if (!graded.envelopeOk) {
1534
- note(RECORD_ENVELOPE_DOES_NOT_VERIFY, {
1535
- packRef: pack.ref,
1536
- eventId: record.eventId,
1537
- reasons: [...graded.errors],
1538
- });
1710
+ skip(RECORD_ENVELOPE_DOES_NOT_VERIFY, { reasons: [...graded.errors] });
1539
1711
  continue;
1540
1712
  }
1541
1713
  if (!record.bytesSupplied) {
1542
- note(RECORD_BYTES_WITHHELD, {
1543
- packRef: pack.ref,
1544
- eventId: record.eventId,
1545
- });
1714
+ skip(RECORD_BYTES_WITHHELD);
1546
1715
  continue;
1547
1716
  }
1548
1717
  if (record.record === null) {
1549
- note(RECORD_BYTES_UNREADABLE, {
1550
- packRef: pack.ref,
1551
- eventId: record.eventId,
1552
- });
1718
+ skip(RECORD_BYTES_UNREADABLE);
1553
1719
  continue;
1554
1720
  }
1555
1721
  // Named, and the record stays in the pile (see the kinds). A mismatch
@@ -1557,14 +1723,12 @@ export async function readCase(
1557
1723
  // with the side map is `incomplete` (proof#159).
1558
1724
  if (bare.contentOk === false) {
1559
1725
  note(RECORD_CONTENT_MISMATCH, {
1560
- packRef: pack.ref,
1561
- eventId: record.eventId,
1726
+ ...at,
1562
1727
  reasons: [...bare.errors],
1563
1728
  });
1564
1729
  } else if (graded.contentOk === false) {
1565
1730
  note(RECORD_CONTENT_DEPENDENT, {
1566
- packRef: pack.ref,
1567
- eventId: record.eventId,
1731
+ ...at,
1568
1732
  reasons: onlyIn(graded.errors, bare.errors),
1569
1733
  });
1570
1734
  }
@@ -1575,17 +1739,24 @@ export async function readCase(
1575
1739
  (!bare.ok && bare.contentOk !== false)
1576
1740
  ) {
1577
1741
  note(RECORD_DOES_NOT_VERIFY_IN_ITS_PACK, {
1578
- packRef: pack.ref,
1579
- eventId: record.eventId,
1742
+ ...at,
1580
1743
  reasons: [...bare.errors],
1581
1744
  });
1582
1745
  }
1746
+ slots.push({
1747
+ position,
1748
+ packRef: pack.ref,
1749
+ recordIndex: index,
1750
+ eventId: record.eventId,
1751
+ heldAt: held.length,
1752
+ reason: null,
1753
+ });
1583
1754
  held.push({
1584
1755
  publicKey: record.publicKey,
1585
1756
  record: record.record,
1586
1757
  ref: `${pack.ref}/${record.eventId}`,
1587
1758
  });
1588
- provenance.push({ position, packRef: pack.ref, eventId: record.eventId });
1759
+ provenance.push({ position, packRef: pack.ref, eventId: record.eventId, recordIndex: index });
1589
1760
  }
1590
1761
  }
1591
1762
 
@@ -1596,20 +1767,12 @@ export async function readCase(
1596
1767
  const readings: HandoffReading[] = readHandoffs(held);
1597
1768
 
1598
1769
  // Step 4.
1599
- const records: CaseRecordReading[] = [];
1600
1770
  let handoffRecords = 0;
1601
1771
  for (let index = 0; index < readings.length; index += 1) {
1602
1772
  const reading = readings[index];
1603
- const { position, packRef, eventId } = provenance[index];
1604
- records.push({
1605
- packRef,
1606
- eventId,
1607
- verdict: reading.verdict,
1608
- findings: [...reading.findings],
1609
- failedChecks: [...reading.failedChecks],
1610
- counterparts: [...reading.candidates],
1611
- role: rolesByPack.get(position)?.get(eventId) ?? null,
1612
- });
1773
+ const { position, packRef, eventId, recordIndex } = provenance[index];
1774
+ // The record this reading is about, by position (A43).
1775
+ const at = { packRef, packIndex: position, eventId, recordIndex };
1613
1776
  // proof#137's door, named. Read from the ONE reader of the declaration
1614
1777
  // (`declarationClaims`), never from a second parse here. Only a READABLE
1615
1778
  // declaration can make a record unclaimed: where the claim itself is absent
@@ -1632,7 +1795,7 @@ export async function readCase(
1632
1795
  claims.version === DECLARATION_VERSION &&
1633
1796
  !claims.claimedSides.includes(eventId)
1634
1797
  ) {
1635
- note(RECORD_UNCLAIMED, { packRef, eventId });
1798
+ note(RECORD_UNCLAIMED, at);
1636
1799
  }
1637
1800
 
1638
1801
  if (reading.findings.includes(NOT_A_HANDOFF_FINDING)) {
@@ -1651,10 +1814,10 @@ export async function readCase(
1651
1814
  // counterpart, ambiguity and `failedChecks` reporting below all keep
1652
1815
  // running over it.
1653
1816
  if (recordIsMalformed(held[index])) {
1654
- note(RECORD_MALFORMED, { packRef, eventId });
1817
+ note(RECORD_MALFORMED, at);
1655
1818
  continue;
1656
1819
  }
1657
- note(COUNTERPART_MALFORMED, { packRef, eventId });
1820
+ note(COUNTERPART_MALFORMED, at);
1658
1821
  }
1659
1822
 
1660
1823
  if (reading.findings.includes(COUNTERPART_ABSENT)) {
@@ -1700,16 +1863,15 @@ export async function readCase(
1700
1863
  note(
1701
1864
  kind,
1702
1865
  setAside > 0
1703
- ? { packRef, eventId, counterpartyKey: named, count: setAside }
1704
- : { packRef, eventId, counterpartyKey: named },
1866
+ ? { ...at, counterpartyKey: named, count: setAside }
1867
+ : { ...at, counterpartyKey: named },
1705
1868
  );
1706
1869
  continue;
1707
1870
  }
1708
1871
 
1709
1872
  if (reading.findings.includes(COUNTERPART_AMBIGUOUS)) {
1710
1873
  note(COUNTERPART_AMBIGUOUS, {
1711
- packRef,
1712
- eventId,
1874
+ ...at,
1713
1875
  count: reading.candidateReadings.filter((c) => c.accountedFor === null).length,
1714
1876
  });
1715
1877
  }
@@ -1722,8 +1884,7 @@ export async function readCase(
1722
1884
  // check whatever the aggregate word is.
1723
1885
  if (reading.failedChecks.length > 0) {
1724
1886
  note(PAIR_FAILED, {
1725
- packRef,
1726
- eventId,
1887
+ ...at,
1727
1888
  failedChecks: [...reading.failedChecks],
1728
1889
  });
1729
1890
  } else if (reading.verdict === "verified") {
@@ -1738,10 +1899,17 @@ export async function readCase(
1738
1899
  // none.
1739
1900
  for (const slot of undeclaredSlots) {
1740
1901
  if (handoffRecords > 0) {
1741
- exceptions[slot.exceptionAt] = { kind: PACK_UNDECLARED, packRef: slot.ref };
1902
+ exceptions[slot.exceptionAt] = {
1903
+ kind: PACK_UNDECLARED,
1904
+ packRef: slot.ref,
1905
+ packIndex: slot.position,
1906
+ };
1742
1907
  contributions[slot.contributionAt] = [slot.ref, EXCEPTION_VERDICT[PACK_UNDECLARED] as CaseVerdict];
1743
1908
  } else {
1744
- caseNotes.push({ position: slot.position, note: { kind: PACK_UNDECLARED, packRef: slot.ref } });
1909
+ caseNotes.push({
1910
+ position: slot.position,
1911
+ note: { kind: PACK_UNDECLARED, packRef: slot.ref, packIndex: slot.position },
1912
+ });
1745
1913
  }
1746
1914
  }
1747
1915
  const named = exceptions.filter((e): e is CaseException => e !== null);
@@ -1753,6 +1921,54 @@ export async function readCase(
1753
1921
  .sort((a, b) => a.position - b.position || a.i - b.i)
1754
1922
  .map((n) => n.note);
1755
1923
 
1924
+ // Every record of every pack, with its status (A43). A record that went
1925
+ // into the pile is `read_flagged` where any exception names it by pack and
1926
+ // record position, or its pack's declaration check names it (by deviation
1927
+ // or by content finding), and `read_clean` where neither does; every other
1928
+ // record is `not_read`, with the kind of the exception that says why.
1929
+ const flagged = new Set(
1930
+ named
1931
+ .filter((e) => e.packIndex !== undefined && e.recordIndex !== undefined)
1932
+ .map((e) => `${e.packIndex}:${e.recordIndex}`),
1933
+ );
1934
+ const records: CaseRecordReading[] = slots.map((slot) => {
1935
+ const base = {
1936
+ packRef: slot.packRef,
1937
+ packIndex: slot.position,
1938
+ eventId: slot.eventId,
1939
+ recordIndex: slot.recordIndex,
1940
+ };
1941
+ const byCheck = [...(declarationFindings.get(slot.position)?.get(slot.eventId) ?? [])];
1942
+ if (slot.heldAt === null) {
1943
+ return {
1944
+ ...base,
1945
+ status: "not_read",
1946
+ reason: slot.reason,
1947
+ declarationFindings: byCheck,
1948
+ verdict: null,
1949
+ findings: [],
1950
+ failedChecks: [],
1951
+ counterparts: [],
1952
+ role: null,
1953
+ };
1954
+ }
1955
+ const reading = readings[slot.heldAt];
1956
+ return {
1957
+ ...base,
1958
+ status:
1959
+ flagged.has(`${slot.position}:${slot.recordIndex}`) || byCheck.length > 0
1960
+ ? "read_flagged"
1961
+ : "read_clean",
1962
+ reason: null,
1963
+ declarationFindings: byCheck,
1964
+ verdict: reading.verdict,
1965
+ findings: [...reading.findings],
1966
+ failedChecks: [...reading.failedChecks],
1967
+ counterparts: [...reading.candidates],
1968
+ role: rolesByPack.get(slot.position)?.get(slot.eventId) ?? null,
1969
+ };
1970
+ });
1971
+
1756
1972
  return {
1757
1973
  verdict: verdictOf(named, contributed),
1758
1974
  exceptions: named,
@@ -1765,6 +1981,19 @@ export async function readCase(
1765
1981
  };
1766
1982
  }
1767
1983
 
1984
+ /**
1985
+ * The records a pack LISTS where this reader does not read them one by one
1986
+ * (A43): the events `resolveTraceEvents` presents, which unwraps a
1987
+ * `sanning.evidence.export/v2` to its inline source bundle, and otherwise
1988
+ * `body.events` as `packRecords` reads it. Listing is not reading: every record
1989
+ * listed here is `not_read`.
1990
+ */
1991
+ function listedRecords(bundle: unknown, content: CasePack["content"]): PackRecord[] {
1992
+ const events = resolveTraceEvents(bundle);
1993
+ if (events === undefined) return packRecords(bundle, content);
1994
+ return packRecords({ body: { events } }, content);
1995
+ }
1996
+
1768
1997
  /**
1769
1998
  * `body.declaration.declaration_version` where it is one this reader knows,
1770
1999
  * and `null` otherwise.