@sanning/proof 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +25 -12
  2. package/dist/agent-proof.d.ts +2 -2
  3. package/dist/agent-proof.js +4 -4
  4. package/dist/case-file.d.ts +126 -0
  5. package/dist/case-file.js +353 -0
  6. package/dist/case.d.ts +217 -26
  7. package/dist/case.js +535 -124
  8. package/dist/cli.d.ts +64 -8
  9. package/dist/cli.js +799 -219
  10. package/dist/compose.js +6 -1
  11. package/dist/crypto.d.ts +1 -1
  12. package/dist/crypto.js +5 -1
  13. package/dist/declaration.d.ts +58 -52
  14. package/dist/declaration.js +256 -33
  15. package/dist/evidence.d.ts +38 -0
  16. package/dist/evidence.js +238 -22
  17. package/dist/exit-codes.d.ts +17 -0
  18. package/dist/exit-codes.js +50 -0
  19. package/dist/file-match.d.ts +105 -0
  20. package/dist/file-match.js +374 -0
  21. package/dist/grading.d.ts +28 -13
  22. package/dist/grading.js +90 -31
  23. package/dist/index.d.ts +14 -7
  24. package/dist/index.js +20 -6
  25. package/dist/message-parts.d.ts +18 -10
  26. package/dist/message-parts.js +25 -15
  27. package/dist/types.d.ts +1 -0
  28. package/dist/verifier.d.ts +63 -4
  29. package/dist/verifier.js +223 -15
  30. package/dist/witness-cache.d.ts +85 -0
  31. package/dist/witness-cache.js +264 -0
  32. package/dist/zip-reader.d.ts +50 -0
  33. package/dist/zip-reader.js +220 -0
  34. package/package.json +2 -2
  35. package/src/agent-proof.ts +4 -4
  36. package/src/case-file.ts +422 -0
  37. package/src/case.ts +671 -131
  38. package/src/cli.ts +932 -217
  39. package/src/compose.ts +8 -1
  40. package/src/crypto.ts +5 -1
  41. package/src/declaration.ts +248 -52
  42. package/src/evidence.ts +257 -23
  43. package/src/exit-codes.ts +54 -0
  44. package/src/file-match.ts +461 -0
  45. package/src/grading.ts +109 -52
  46. package/src/index.ts +101 -2
  47. package/src/message-parts.ts +25 -15
  48. package/src/types.ts +5 -0
  49. package/src/verifier.ts +225 -16
  50. package/src/witness-cache.ts +306 -0
  51. package/src/zip-reader.ts +229 -0
  52. package/vectors/README.md +66 -8
  53. package/vectors/case-file-index.json +668 -0
  54. package/vectors/case-split.json +218 -9
  55. package/vectors/file-match.json +1808 -0
  56. package/vectors/jcs-reader.json +82 -3
  57. package/vectors/pack-grading.json +68 -4
  58. package/vectors/stamp-on-chain.json +20 -5
  59. package/vectors/witness-cache.json +1304 -0
package/src/compose.ts CHANGED
@@ -39,7 +39,7 @@ import {
39
39
  sha256Hex,
40
40
  utf8,
41
41
  } from "./crypto.js";
42
- import { jcs } from "./verifier.js";
42
+ import { isRfc3339UtcInstant, jcs } from "./verifier.js";
43
43
 
44
44
  // The exporter's Ed25519 wrapper-signing key. `privateKey` is a 32-byte seed as
45
45
  // lowercase hex (the @noble/ed25519 / stack convention, e.g. the fixtures'
@@ -133,6 +133,13 @@ export async function composeExport(
133
133
  throw new Error("composeExport: exporterKey.privateKey (32-byte hex seed) is required");
134
134
  }
135
135
 
136
+ if (options.generatedAt !== undefined && !isRfc3339UtcInstant(options.generatedAt)) {
137
+ // The verifier reads this as `malformed generated_at` (evidence-bundle §2,
138
+ // A31), so composing one would sign an export this kernel rejects.
139
+ throw new Error(
140
+ "composeExport: generatedAt is not an RFC 3339 UTC date-time with Z naming a real date and time",
141
+ );
142
+ }
136
143
  const generatedAt = options.generatedAt ?? new Date().toISOString();
137
144
  const previousHash = options.previousHash ?? GENESIS;
138
145
  const issuerId = options.issuerId ?? "sanning-verify";
package/src/crypto.ts CHANGED
@@ -80,9 +80,13 @@ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
80
80
  // appear in the envelope; message is raw bytes. Never throws — a malformed
81
81
  // signature or key is "not verified," not an exception, so a hostile envelope
82
82
  // can't crash the checker.
83
+ // Arguments in the Python twin's order, `ed25519_verify(message,
84
+ // signature_hex, public_key_hex)` (P-03, 0.13.0). The message came second
85
+ // until 0.13.0; a caller that still passes the signature first gets a compile
86
+ // error, because a string is not a Uint8Array.
83
87
  export async function ed25519Verify(
84
- signatureHex: string,
85
88
  message: Uint8Array,
89
+ signatureHex: string,
86
90
  publicKeyHex: string,
87
91
  ): Promise<boolean> {
88
92
  try {
@@ -119,7 +119,7 @@
119
119
  // last-write-wins in both languages, so a second record under a declared id
120
120
  // was graded by neither rule 2 nor rule 7 and was never reported as surplus.
121
121
  // FIRST in bundle order is now the record every other rule grades, and every
122
- // record after it under the same id is a `duplicate-event-id` deviation.
122
+ // record after it under the same id is a `duplicate_event_id` deviation.
123
123
  //
124
124
  // MINIMUM DISCLOSURE IS NOT A DEVIATION. A claimed side whose `event_bytes`
125
125
  // the pack withholds was graded "not a conformant hand-off record", so an
@@ -133,36 +133,75 @@
133
133
  // `src/sanning_proof/canonicalize.py` is the twin (proof#140 gate 5).
134
134
  import { hexToBytes } from "./crypto.js";
135
135
  import { jcsParse } from "./verifier.js";
136
- import { isConformantHandoffRecord, type HandoffRecord } from "./handoff.js";
136
+ import { isConformantHandoffRecord, isHandoffNamespace, type HandoffRecord } from "./handoff.js";
137
137
  import { hasSideContent, inBodyContentOffered, type SideContentMap } from "./side-content.js";
138
138
 
139
- /** The declaration schema this reader knows. */
139
+ /** The first declaration schema, which every earlier reader knows. */
140
140
  export const DECLARATION_VERSION = "sanning.declaration/v1";
141
141
 
142
+ /**
143
+ * v1 plus a signed `records[].role` on every record (evidence-bundle.md
144
+ * 5.4.1 v1.20, A21). A reader that knows only v1 reads a v2 declaration as one
145
+ * it cannot read, which is the safe direction.
146
+ */
147
+ export const DECLARATION_VERSION_V2 = "sanning.declaration/v2";
148
+
149
+ /** Every declaration schema this reader knows, in the order they were added. */
150
+ export const DECLARATION_VERSIONS = [DECLARATION_VERSION, DECLARATION_VERSION_V2] as const;
151
+
152
+ /**
153
+ * What a v2 declaration may sign a record IS, compared exactly: `handoff`, a
154
+ * record 4.1 governs that the pack claims as a side in `handoffs[]`, or
155
+ * `step`, any other record.
156
+ */
157
+ export const DECLARATION_ROLES = ["handoff", "step"] as const;
158
+ export type DeclarationRole = (typeof DECLARATION_ROLES)[number];
159
+
142
160
  /** The three states a declaration check can reach, from the §3 vocabulary. */
143
- export type DeclarationVerdict = "verified" | "failed" | "partial";
161
+ export const DECLARATION_VERDICTS = ["verified", "failed", "partial"] as const;
162
+ export type DeclarationVerdict = (typeof DECLARATION_VERDICTS)[number];
144
163
 
145
- export type DeclarationDeviationKind =
164
+ export const DECLARATION_DEVIATION_KINDS = [
146
165
  /** The declaration is written to this version and is not readable as one. */
147
- | "declaration-malformed"
166
+ "declaration_malformed",
148
167
  /** A record the declaration expects is not in the pack. */
149
- | "declared-absent"
168
+ "declared_absent",
150
169
  /** A record in the pack the declaration never expected. */
151
- | "undeclared-present"
170
+ "undeclared_present",
152
171
  /**
153
172
  * A record declared `content_withheld` whose bytes the pack supplies INSIDE
154
173
  * its own signed body, in `events[].content`.
155
174
  */
156
- | "withheld-but-disclosed"
175
+ "withheld_but_disclosed",
157
176
  /** The pack carries one `event_id` on two or more records. */
158
- | "duplicate-event-id"
177
+ "duplicate_event_id",
159
178
  /** A claimed hand-off names a record the declaration does not declare. */
160
- | "pair-record-undeclared"
179
+ "pair_record_undeclared",
161
180
  /**
162
181
  * A claimed hand-off names a record that is not a conformant hand-off
163
182
  * record of the side it is claimed on (envelope-spec.md 4.1).
164
183
  */
165
- | "pair-record-not-a-handoff";
184
+ "pair_record_not_a_handoff",
185
+ /**
186
+ * Rule 9 (v1.20, A21), `sanning.declaration/v2` only. A record declared
187
+ * `handoff` that no `handoffs[]` entry names as a side.
188
+ */
189
+ "role_handoff_unclaimed",
190
+ /** A record a `handoffs[]` entry names as a side, declared `step`. */
191
+ "role_step_claimed",
192
+ /**
193
+ * A record declared `step` that 4.1's scope test puts in scope: its `event`
194
+ * carries `counterparty_public_key`, or its folded `event_type` has a
195
+ * `handoff` segment. The holder signed that a hand-off record is not one.
196
+ */
197
+ "role_step_governed",
198
+ /**
199
+ * A record declared `step` whose supplied `event_bytes` this reader cannot
200
+ * read under rule 7's parser rule.
201
+ */
202
+ "role_step_unreadable",
203
+ ] as const;
204
+ export type DeclarationDeviationKind = (typeof DECLARATION_DEVIATION_KINDS)[number];
166
205
 
167
206
  /**
168
207
  * Every way a pack and the UNSIGNED content handed in beside it fail to line
@@ -190,7 +229,7 @@ export type DeclarationDeviationKind =
190
229
  * legal and grades `verified`, which is strictly better than the `incomplete`
191
230
  * these carry.
192
231
  */
193
- export type DeclarationContentKind =
232
+ export const DECLARATION_CONTENT_KINDS = [
194
233
  /**
195
234
  * A record declared `disclosed` whose bytes reached this reader from
196
235
  * neither channel. ALWAYS content-dependent: 5.4.1 lets those bytes travel
@@ -198,14 +237,16 @@ export type DeclarationContentKind =
198
237
  * fact about what the READER was handed and never a fact about what the
199
238
  * holder supplied. No signature covers the absence of a file nobody signed.
200
239
  */
201
- | "content-dependent-withholding"
240
+ "content_dependent_withholding",
202
241
  /**
203
242
  * A record declared `content_withheld` whose bytes reached this reader ONLY
204
243
  * from the unsigned side-content map. The pack's own body supplies none, so
205
244
  * the holder committed to no such disclosure. The in-body half of the same
206
- * rule is the `withheld-but-disclosed` DEVIATION and still fails.
245
+ * rule is the `withheld_but_disclosed` DEVIATION and still fails.
207
246
  */
208
- | "content-dependent-disclosure";
247
+ "content_dependent_disclosure",
248
+ ] as const;
249
+ export type DeclarationContentKind = (typeof DECLARATION_CONTENT_KINDS)[number];
209
250
 
210
251
  /**
211
252
  * One way the pack and the unsigned content beside it do not line up. Named
@@ -218,18 +259,27 @@ export interface DeclarationContentFinding {
218
259
  }
219
260
 
220
261
  /** Everything this check reports that is NOT a deviation, as a closed set. */
221
- export type DeclarationNoteKind =
262
+ export const DECLARATION_NOTE_KINDS = [
222
263
  /**
223
264
  * Every record declared `disclosed` supplied no bytes, so nothing was
224
265
  * selected: the bytes did not travel with the pack.
225
266
  */
226
- | "disclosure-did-not-travel"
267
+ "disclosure_did_not_travel",
227
268
  /**
228
269
  * A claimed hand-off side whose `event_bytes` the pack withholds. The reader
229
270
  * cannot see what the record is either way, so it is neither a conformant
230
271
  * hand-off record nor a non-conformant one.
231
272
  */
232
- | "pair-record-withheld";
273
+ "pair_record_withheld",
274
+ /**
275
+ * A record a v2 declaration signs `step` whose `event_bytes` the pack
276
+ * withholds (rule 9, v1.20). Its role cannot be checked in either direction,
277
+ * so it is a note; the pack grade still holds the pack at `incomplete`,
278
+ * because its payload binding is undetermined.
279
+ */
280
+ "role_step_withheld",
281
+ ] as const;
282
+ export type DeclarationNoteKind = (typeof DECLARATION_NOTE_KINDS)[number];
233
283
 
234
284
  /**
235
285
  * One named non-deviation finding, as data.
@@ -244,9 +294,9 @@ export interface DeclarationNote {
244
294
  count?: number;
245
295
  /** The record this is about, where it is about one. */
246
296
  eventId?: string;
247
- /** Which side of a claimed hand-off named it, for `pair-record-withheld`. */
297
+ /** Which side of a claimed hand-off named it, for `pair_record_withheld`. */
248
298
  side?: "sent" | "received";
249
- /** Which claimed hand-off, by position, for `pair-record-withheld`. */
299
+ /** Which claimed hand-off, by position, for `pair_record_withheld`. */
250
300
  pairIndex?: number;
251
301
  }
252
302
 
@@ -259,8 +309,14 @@ export interface DeclarationDeviation {
259
309
  side?: "sent" | "received";
260
310
  /** Which claimed hand-off, by position, for the two `pair-record-` deviations. */
261
311
  pairIndex?: number;
262
- /** What is wrong with the declaration itself, for `declaration-malformed`. */
312
+ /** What is wrong with the declaration itself, for `declaration_malformed`. */
263
313
  reason?: string;
314
+ /**
315
+ * The `declaration_version` the object claimed, for `declaration_malformed`,
316
+ * so the rendered sentence names the version the holder wrote to rather than
317
+ * the first one this reader learned.
318
+ */
319
+ version?: string;
264
320
  }
265
321
 
266
322
  export interface DeclarationCheck {
@@ -286,6 +342,19 @@ export interface DeclarationCheck {
286
342
  declared: string[];
287
343
  /** Of those, the ones declared withheld on purpose. */
288
344
  declaredWithheld: string[];
345
+ /**
346
+ * The `declaration_version` this reader read the object as, one of
347
+ * `DECLARATION_VERSIONS`, or `null` where the body carries no declaration or
348
+ * one whose version this reader does not know. Set for a malformed object
349
+ * too: the version was read, the rest was not. Optional so a check a caller
350
+ * builds by hand, as rule version 5 adapters do, still type-checks.
351
+ */
352
+ version?: string | null;
353
+ /**
354
+ * Every record a v2 declaration signs a role for, in declaration order.
355
+ * Empty for v1, and for anything this reader could not read.
356
+ */
357
+ roles?: { eventId: string; role: DeclarationRole }[];
289
358
  }
290
359
 
291
360
  // ---- Reading the pack --------------------------------------------------------
@@ -476,6 +545,7 @@ const isId = (v: unknown): v is string => typeof v === "string" && v.length > 0;
476
545
  interface ReadDeclaration {
477
546
  records: { eventId: string; withheld: boolean }[];
478
547
  pairs: { sent: string | null; received: string | null }[];
548
+ roles: { eventId: string; role: DeclarationRole }[];
479
549
  }
480
550
 
481
551
  /**
@@ -488,8 +558,14 @@ interface ReadDeclaration {
488
558
  */
489
559
  function readDeclaration(
490
560
  declaration: Record<string, unknown>,
561
+ version: string = DECLARATION_VERSION,
491
562
  ): { ok: true; read: ReadDeclaration } | { ok: false; reasons: string[] } {
492
563
  const reasons: string[] = [];
564
+ // Under v2 every `records[]` entry must also carry `role`, exactly `handoff`
565
+ // or `step` (5.4.1 v1.20, A21); under v1 the member is not read at all, so a
566
+ // v1 declaration carrying `role` reads exactly as the same object without it.
567
+ const roles: { eventId: string; role: DeclarationRole }[] = [];
568
+ const v2 = version === DECLARATION_VERSION_V2;
493
569
  const note = (r: string) => reasons.push(r);
494
570
 
495
571
  if (!isId(declaration["case_id"])) note("case_id is not a non-empty string");
@@ -526,12 +602,20 @@ function readDeclaration(
526
602
  note(`records[${i}] (${eventId}) has no disclosure of "disclosed" or "content_withheld"`);
527
603
  continue;
528
604
  }
605
+ // Compared exactly: `Handoff`, `hand-off` and `step ` are other values,
606
+ // and a non-string never equals one.
607
+ const role = r["role"];
608
+ if (v2 && !(role === "handoff" || role === "step")) {
609
+ note(`records[${i}] (${eventId}) has no role of "handoff" or "step"`);
610
+ continue;
611
+ }
529
612
  if (seen.has(eventId)) {
530
613
  note(`records[${i}] declares ${eventId} a second time`);
531
614
  continue;
532
615
  }
533
616
  seen.add(eventId);
534
617
  records.push({ eventId, withheld: disclosure === "content_withheld" });
618
+ if (v2) roles.push({ eventId, role: role as DeclarationRole });
535
619
  }
536
620
  }
537
621
 
@@ -559,7 +643,7 @@ function readDeclaration(
559
643
  }
560
644
  }
561
645
 
562
- return reasons.length > 0 ? { ok: false, reasons } : { ok: true, read: { records, pairs } };
646
+ return reasons.length > 0 ? { ok: false, reasons } : { ok: true, read: { records, pairs, roles } };
563
647
  }
564
648
 
565
649
  // ---- The check ---------------------------------------------------------------
@@ -572,8 +656,48 @@ const noDeclaration = (): DeclarationCheck => ({
572
656
  present: false,
573
657
  declared: [],
574
658
  declaredWithheld: [],
659
+ version: null,
660
+ roles: [],
575
661
  });
576
662
 
663
+ /**
664
+ * The `declaration_version` of an object this reader knows, or `null`: a
665
+ * declaration that is not an object, an absent or `null` version and a version
666
+ * outside `DECLARATION_VERSIONS` are each a claim this reader cannot read.
667
+ */
668
+ function knownVersion(declaration: unknown): string | null {
669
+ if (!isObject(declaration)) return null;
670
+ const version = declaration["declaration_version"];
671
+ return version === DECLARATION_VERSION || version === DECLARATION_VERSION_V2 ? version : null;
672
+ }
673
+
674
+ /**
675
+ * Rule 9 over one record the pack holds: the deviation or note it earns, or
676
+ * `null` where the signed role agrees with the pack and the record. The first
677
+ * of the four deviations that applies, in the order 5.4.2 lists them. Whether
678
+ * 4.1 governs the record is `isHandoffNamespace`, CALLED, never restated.
679
+ */
680
+ function roleReading(
681
+ p: { eventId: string; publicKey: string; record: HandoffRecord | null; bytesSupplied: boolean },
682
+ role: DeclarationRole,
683
+ claimed: boolean,
684
+ ):
685
+ | "role_handoff_unclaimed"
686
+ | "role_step_claimed"
687
+ | "role_step_governed"
688
+ | "role_step_unreadable"
689
+ | "role_step_withheld"
690
+ | null {
691
+ if (role === "handoff") return claimed ? null : "role_handoff_unclaimed";
692
+ if (claimed) return "role_step_claimed";
693
+ if (!p.bytesSupplied) return "role_step_withheld";
694
+ if (p.record === null) return "role_step_unreadable";
695
+ if (isHandoffNamespace({ publicKey: p.publicKey, record: p.record, ref: p.eventId })) {
696
+ return "role_step_governed";
697
+ }
698
+ return null;
699
+ }
700
+
577
701
  /**
578
702
  * Check a pack against the declaration inside its body.
579
703
  *
@@ -612,24 +736,31 @@ export function checkDeclaration(
612
736
  // a deleted one, a null one, and a `declaration` that is a string, an array
613
737
  // or null. Each of those hides the evidence of a trim inside a signed body,
614
738
  // so none of them may reach a clean pack verdict.
615
- if (!isObject(declaration) || declaration["declaration_version"] !== DECLARATION_VERSION) {
739
+ const version = knownVersion(declaration);
740
+ if (version === null) {
616
741
  return { ...noDeclaration(), present: true };
617
742
  }
618
743
 
619
- const read = readDeclaration(declaration);
744
+ const read = readDeclaration(declaration as Record<string, unknown>, version);
620
745
  if (!read.ok) {
621
746
  return {
622
747
  verdict: "failed",
623
- deviations: read.reasons.map((reason) => ({ kind: "declaration-malformed", reason })),
748
+ deviations: read.reasons.map((reason) => ({
749
+ kind: "declaration_malformed",
750
+ reason,
751
+ version,
752
+ })),
624
753
  contentFindings: [],
625
754
  notes: [],
626
755
  present: true,
627
756
  declared: [],
628
757
  declaredWithheld: [],
758
+ version,
759
+ roles: [],
629
760
  };
630
761
  }
631
762
 
632
- const { records, pairs } = read.read;
763
+ const { records, pairs, roles } = read.read;
633
764
  const declared = records.map((r) => r.eventId);
634
765
  const declaredSet = new Set(declared);
635
766
  const pack = packRecords(bundle, content);
@@ -650,7 +781,7 @@ export function checkDeclaration(
650
781
  // Declared and absent. The trimmed hand-over, which is the whole reason a
651
782
  // declaration exists: nothing else in the pack can see it.
652
783
  for (const r of records) {
653
- if (!byId.has(r.eventId)) deviations.push({ kind: "declared-absent", eventId: r.eventId });
784
+ if (!byId.has(r.eventId)) deviations.push({ kind: "declared_absent", eventId: r.eventId });
654
785
  }
655
786
 
656
787
  // Present and undeclared, and the pack carrying one id twice.
@@ -668,12 +799,12 @@ export function checkDeclaration(
668
799
  const seenInPack = new Set<string>();
669
800
  for (const p of pack) {
670
801
  if (p.nameable && seenInPack.has(p.eventId)) {
671
- deviations.push({ kind: "duplicate-event-id", eventId: p.eventId });
802
+ deviations.push({ kind: "duplicate_event_id", eventId: p.eventId });
672
803
  continue;
673
804
  }
674
805
  if (p.nameable) seenInPack.add(p.eventId);
675
806
  if (!p.nameable || !declaredSet.has(p.eventId)) {
676
- deviations.push({ kind: "undeclared-present", eventId: p.eventId });
807
+ deviations.push({ kind: "undeclared_present", eventId: p.eventId });
677
808
  }
678
809
  }
679
810
 
@@ -688,7 +819,7 @@ export function checkDeclaration(
688
819
  .filter((p): p is NonNullable<typeof p> => p !== undefined);
689
820
  const nothingTravelled = disclosedHeld.length > 0 && !disclosedHeld.some((p) => p.disclosed);
690
821
  const notes: DeclarationNote[] = nothingTravelled
691
- ? [{ kind: "disclosure-did-not-travel", count: disclosedHeld.length }]
822
+ ? [{ kind: "disclosure_did_not_travel", count: disclosedHeld.length }]
692
823
  : [];
693
824
 
694
825
  // The two disclosure rules, in declaration order, over the records the pack
@@ -713,12 +844,12 @@ export function checkDeclaration(
713
844
  // the pack, so no pack can prove it supplied them and no reader can
714
845
  // prove it did not: an accuser holding a twenty-file drop reaches this
715
846
  // reading by deleting one file.
716
- contentFindings.push({ kind: "content-dependent-withholding", eventId: r.eventId });
847
+ contentFindings.push({ kind: "content_dependent_withholding", eventId: r.eventId });
717
848
  } else if (r.withheld && p.disclosed) {
718
849
  if (p.disclosedInBody) {
719
- deviations.push({ kind: "withheld-but-disclosed", eventId: r.eventId });
850
+ deviations.push({ kind: "withheld_but_disclosed", eventId: r.eventId });
720
851
  } else {
721
- contentFindings.push({ kind: "content-dependent-disclosure", eventId: r.eventId });
852
+ contentFindings.push({ kind: "content_dependent_disclosure", eventId: r.eventId });
722
853
  }
723
854
  }
724
855
  }
@@ -748,20 +879,42 @@ export function checkDeclaration(
748
879
  const id = pair[side];
749
880
  if (id === null) continue;
750
881
  if (!declaredSet.has(id)) {
751
- deviations.push({ kind: "pair-record-undeclared", eventId: id, side, pairIndex: i });
882
+ deviations.push({ kind: "pair_record_undeclared", eventId: id, side, pairIndex: i });
752
883
  continue;
753
884
  }
754
885
  const p = byId.get(id);
755
886
  if (p === undefined) continue;
756
887
  const reading = sideReading(p, side);
757
888
  if (reading === "withheld") {
758
- notes.push({ kind: "pair-record-withheld", eventId: id, side, pairIndex: i });
889
+ notes.push({ kind: "pair_record_withheld", eventId: id, side, pairIndex: i });
759
890
  } else if (reading === "not-a-handoff") {
760
- deviations.push({ kind: "pair-record-not-a-handoff", eventId: id, side, pairIndex: i });
891
+ deviations.push({ kind: "pair_record_not_a_handoff", eventId: id, side, pairIndex: i });
761
892
  }
762
893
  }
763
894
  }
764
895
 
896
+ // Rule 9 (5.4.2 v1.20, A21), `sanning.declaration/v2` only: a record's
897
+ // signed role against the pack and against the record. Its deviations follow
898
+ // every deviation rules 1 to 8 report, and its note follows every other
899
+ // note, both in `records[]` order. It reads the signed body alone and NEVER
900
+ // the side-content map, so no party that signed nothing can create, move or
901
+ // remove one of its findings.
902
+ if (roles.length > 0) {
903
+ const claimed = new Set<string>();
904
+ for (const pair of pairs) {
905
+ if (pair.sent !== null) claimed.add(pair.sent);
906
+ if (pair.received !== null) claimed.add(pair.received);
907
+ }
908
+ for (const { eventId, role } of roles) {
909
+ const p = byId.get(eventId);
910
+ // Declared and absent: rule 1 named it, and one defect is one finding.
911
+ if (p === undefined) continue;
912
+ const kind = roleReading(p, role, claimed.has(eventId));
913
+ if (kind === "role_step_withheld") notes.push({ kind, eventId });
914
+ else if (kind !== null) deviations.push({ kind, eventId });
915
+ }
916
+ }
917
+
765
918
  // A proven deviation wins: it is a mismatch between what the pack claimed
766
919
  // and what its own signed body carries, and a failing check always wins.
767
920
  // With no deviation and a content-dependent finding, the pack lines up with
@@ -779,6 +932,8 @@ export function checkDeclaration(
779
932
  present: true,
780
933
  declared,
781
934
  declaredWithheld: records.filter((r) => r.withheld).map((r) => r.eventId),
935
+ version,
936
+ roles,
782
937
  };
783
938
  }
784
939
 
@@ -827,7 +982,7 @@ export interface DeclarationClaims {
827
982
  */
828
983
  present: boolean;
829
984
  /**
830
- * Whether this reader could read the claim as `DECLARATION_VERSION` AND the
985
+ * Whether this reader could read the claim as one of `DECLARATION_VERSIONS` AND the
831
986
  * object satisfies 5.4.1. `false` covers an absent declaration, an unknown
832
987
  * version and a malformed object, and a case reader must not read any of the
833
988
  * three as "the holder claimed nothing to check".
@@ -841,6 +996,14 @@ export interface DeclarationClaims {
841
996
  * is legal and is exactly the shape a case reader has to name.
842
997
  */
843
998
  claimedSides: string[];
999
+ /**
1000
+ * The version the claim was read as, where `readable`; else `null`. The case
1001
+ * reader's claim check is v1's alone (5.5.1 v1.20): a v2 pack's claim check
1002
+ * is rule 9, which the declaration check already ran.
1003
+ */
1004
+ version: string | null;
1005
+ /** Every record a READABLE v2 declaration signs a role for. Empty for v1. */
1006
+ roles: { eventId: string; role: DeclarationRole }[];
844
1007
  }
845
1008
 
846
1009
  /**
@@ -857,14 +1020,17 @@ export function declarationClaims(bundle: unknown): DeclarationClaims {
857
1020
  readable: false,
858
1021
  caseId: null,
859
1022
  claimedSides: [],
1023
+ version: null,
1024
+ roles: [],
860
1025
  };
861
1026
  const body = bodyOf(bundle);
862
1027
  if (!isObject(body) || !("declaration" in body)) return none;
863
1028
  const declaration = (body as Record<string, unknown>)["declaration"];
864
- if (!isObject(declaration) || declaration["declaration_version"] !== DECLARATION_VERSION) {
1029
+ const version = knownVersion(declaration);
1030
+ if (version === null || !isObject(declaration)) {
865
1031
  return { ...none, present: true };
866
1032
  }
867
- const read = readDeclaration(declaration);
1033
+ const read = readDeclaration(declaration, version);
868
1034
  if (!read.ok) return { ...none, present: true };
869
1035
 
870
1036
  const claimedSides: string[] = [];
@@ -879,6 +1045,8 @@ export function declarationClaims(bundle: unknown): DeclarationClaims {
879
1045
  readable: true,
880
1046
  caseId: typeof caseId === "string" ? caseId : null,
881
1047
  claimedSides,
1048
+ version,
1049
+ roles: read.read.roles,
882
1050
  };
883
1051
  }
884
1052
 
@@ -936,17 +1104,22 @@ export function renderDeclarationSummary(check: DeclarationCheck): string {
936
1104
  */
937
1105
  export function renderNote(note: DeclarationNote): string {
938
1106
  switch (note.kind) {
939
- case "disclosure-did-not-travel":
1107
+ case "disclosure_did_not_travel":
940
1108
  return (
941
1109
  `this pack declares ${note.count} record(s) disclosed and supplies no bytes for ` +
942
1110
  "any of them: the bytes did not travel with the pack, so their content is UNKNOWN"
943
1111
  );
944
- case "pair-record-withheld":
1112
+ case "pair_record_withheld":
945
1113
  return (
946
1114
  `the claimed hand-off at position ${note.pairIndex} names ${note.eventId} on its ` +
947
1115
  `${note.side} side, whose record bytes this pack withholds, so what that record ` +
948
1116
  "IS is UNKNOWN"
949
1117
  );
1118
+ case "role_step_withheld":
1119
+ return (
1120
+ `${note.eventId} is signed as a step and this pack withholds its ` +
1121
+ "record bytes, so whether it is one is UNKNOWN"
1122
+ );
950
1123
  }
951
1124
  }
952
1125
 
@@ -960,13 +1133,13 @@ export function renderNote(note: DeclarationNote): string {
960
1133
  */
961
1134
  export function renderContentFinding(finding: DeclarationContentFinding): string {
962
1135
  switch (finding.kind) {
963
- case "content-dependent-withholding":
1136
+ case "content_dependent_withholding":
964
1137
  return (
965
1138
  `${finding.eventId} is declared disclosed and no bytes for it reached this ` +
966
1139
  "verifier, from the pack or from beside it, so whether the holder supplied " +
967
1140
  "them is UNKNOWN"
968
1141
  );
969
- case "content-dependent-disclosure":
1142
+ case "content_dependent_disclosure":
970
1143
  return (
971
1144
  `${finding.eventId} is declared withheld and bytes for it were handed in ` +
972
1145
  "beside this pack, which the pack does not carry and nobody signed, so " +
@@ -977,29 +1150,52 @@ export function renderContentFinding(finding: DeclarationContentFinding): string
977
1150
 
978
1151
  export function renderDeviation(deviation: DeclarationDeviation): string {
979
1152
  switch (deviation.kind) {
980
- case "declaration-malformed":
981
- return `the declaration is not readable as ${DECLARATION_VERSION}: ${deviation.reason}`;
982
- case "declared-absent":
1153
+ case "declaration_malformed":
1154
+ return (
1155
+ "the declaration is not readable as " +
1156
+ `${deviation.version || DECLARATION_VERSION}: ${deviation.reason}`
1157
+ );
1158
+ case "declared_absent":
983
1159
  return `${deviation.eventId} is declared and is NOT in this pack`;
984
- case "undeclared-present":
1160
+ case "undeclared_present":
985
1161
  return `${deviation.eventId} is in this pack and is NOT declared`;
986
- case "withheld-but-disclosed":
1162
+ case "withheld_but_disclosed":
987
1163
  return (
988
1164
  `${deviation.eventId} is declared withheld and this pack carries its bytes ` +
989
1165
  "in its own signed body"
990
1166
  );
991
- case "duplicate-event-id":
1167
+ case "duplicate_event_id":
992
1168
  return `this pack carries ${deviation.eventId} more than once`;
993
- case "pair-record-undeclared":
1169
+ case "pair_record_undeclared":
994
1170
  return (
995
1171
  `the claimed hand-off at position ${deviation.pairIndex} names ` +
996
1172
  `${deviation.eventId} on its ${deviation.side} side, which the declaration does not declare`
997
1173
  );
998
- case "pair-record-not-a-handoff":
1174
+ case "pair_record_not_a_handoff":
999
1175
  return (
1000
1176
  `the claimed hand-off at position ${deviation.pairIndex} names ` +
1001
1177
  `${deviation.eventId} on its ${deviation.side} side, which is not a conformant ` +
1002
1178
  `${SIDE_EVENT_TYPES[deviation.side ?? "sent"]} record`
1003
1179
  );
1180
+ case "role_handoff_unclaimed":
1181
+ return (
1182
+ `${deviation.eventId} is signed as a hand-off record and no claimed ` +
1183
+ "hand-off names it"
1184
+ );
1185
+ case "role_step_claimed":
1186
+ return (
1187
+ `${deviation.eventId} is signed as a step and a claimed hand-off ` +
1188
+ "names it as a side"
1189
+ );
1190
+ case "role_step_governed":
1191
+ return (
1192
+ `${deviation.eventId} is signed as a step and is a record the ` +
1193
+ "hand-off rules govern"
1194
+ );
1195
+ case "role_step_unreadable":
1196
+ return (
1197
+ `${deviation.eventId} is signed as a step and this pack carries ` +
1198
+ "record bytes for it that this verifier cannot read"
1199
+ );
1004
1200
  }
1005
1201
  }