@sanning/proof 0.5.0 → 0.6.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/evidence.ts CHANGED
@@ -32,6 +32,11 @@ import {
32
32
  jcs,
33
33
  verifyEnvelope,
34
34
  } from "./verifier.js";
35
+ import {
36
+ inBodyContentOffered,
37
+ sideContentEntry,
38
+ type SideEntry,
39
+ } from "./side-content.js";
35
40
  import type { Envelope } from "./types.js";
36
41
 
37
42
  // Only the evidence-bundle major(s) this kernel verifies. A new major is a
@@ -43,7 +48,23 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
43
48
  // body_type is not a hard failure of the WRAPPER (the signature + body_hash
44
49
  // still verify), but its body cannot be re-derived, so the per-body verdict is
45
50
  // "undetermined" rather than "verified".
46
- export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v1";
51
+ export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
52
+
53
+ /**
54
+ * The frozen pre-`anchor_ref` body (evidence-bundle.md §5.1.3).
55
+ *
56
+ * Accepted PERMANENTLY, not for a deprecation window. Every bundle emitted
57
+ * before 2026-09-03 carries this shape, and those bundles are evidence — a
58
+ * reader that stopped accepting them would destroy the record it exists to
59
+ * defend (DL-30). Nothing new emits it.
60
+ */
61
+ export const ANCHOR_TRACE_BODY_TYPE_V1 = "sanning.anchor.trace/v1";
62
+
63
+ /** Every anchor-trace body this kernel verifies. Order is newest-first. */
64
+ export const ANCHOR_TRACE_BODY_TYPES = [
65
+ ANCHOR_TRACE_BODY_TYPE,
66
+ ANCHOR_TRACE_BODY_TYPE_V1,
67
+ ] as const;
47
68
 
48
69
  // The issuer-composed attested-evidence-export body (specs/evidence-export.md
49
70
  // §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
@@ -70,20 +91,58 @@ export interface EvidenceVerdict {
70
91
  [k: string]: unknown;
71
92
  }
72
93
 
73
- // `sanning.anchor.trace/v1` body (§5.1). Uint8Array fields ride as lowercase hex.
94
+ /**
95
+ * Where a commitment was witnessed (evidence-bundle.md §5.1.1).
96
+ *
97
+ * The same three-part shape the control plane indexes (`kind` / one promoted
98
+ * `id` / a typed remainder), so the signed body, the index row and the SDK
99
+ * artifact stop being three vocabularies for one fact.
100
+ *
101
+ * NOT ONE FIELD HERE NAMES A CHAIN. Arweave's chunk proof, data_path, offsets
102
+ * and packing live in `ref`, opaque to every reader above this seam. A field
103
+ * that would need renaming for a second witness belongs in `ref` by definition.
104
+ */
105
+ export interface AnchorRef {
106
+ kind: string;
107
+ id: string;
108
+ ref?: Record<string, unknown>;
109
+ }
110
+
111
+ // `sanning.anchor.trace/v2` body (§5.1). Uint8Array fields ride as lowercase hex.
74
112
  export interface TraceCheckpoint {
75
- tx_id: string;
113
+ /**
114
+ * The checkpoint's identity and the join key, DERIVED: hex SHA-256 of the
115
+ * decoded `record_bytes`. Recomputed by the verifier — in v1 this was an
116
+ * asserted label matched against another asserted label, so a producer could
117
+ * relabel a checkpoint and the bundle still verified.
118
+ */
119
+ checkpoint_ref: string;
120
+ /**
121
+ * Where it was witnessed, IF IT HAS BEEN. Optional, and absent is honest:
122
+ * under DL-52 a checkpoint is routinely placed before it is witnessed, and a
123
+ * required field that must be filled dishonestly is a defect in the format.
124
+ */
125
+ anchor_ref?: AnchorRef;
76
126
  envelope: Envelope;
77
127
  record_bytes: string; // hex of JCS(checkpoint record)
78
128
  merkle_root: string; // hex
79
129
  }
80
130
 
131
+ /** The frozen `sanning.anchor.trace/v1` checkpoint (§5.1.3). Read, never written. */
132
+ export interface TraceCheckpointV1 {
133
+ tx_id: string;
134
+ envelope: Envelope;
135
+ record_bytes: string;
136
+ merkle_root: string;
137
+ }
138
+
81
139
  export interface TraceInclusion {
82
140
  leaf_hash: string; // hex
83
141
  leaf_index: number;
84
142
  leaf_count: number;
85
143
  audit_path: string[]; // hex[]
86
- checkpoint_tx_id: string;
144
+ /** Resolves against `checkpoints[].checkpoint_ref`. v1 spells this `checkpoint_tx_id`. */
145
+ checkpoint_ref: string;
87
146
  }
88
147
 
89
148
  export interface TraceEvent {
@@ -127,7 +186,7 @@ export type EvidenceStatus =
127
186
  | "malformed"; // structurally unparseable / unknown major / body_hash mismatch
128
187
 
129
188
  // Per-gateway on-chain outcome for one checkpoint tx (evidence-export.md §4.2).
130
- // The on-chain re-fetch resolves each checkpoint_tx_id against EACH configured
189
+ // The on-chain re-fetch resolves each checkpoint's anchor_ref.id against EACH configured
131
190
  // gateway and records that gateway's individual outcome:
132
191
  // confirm — the gateway returned the tx and its bytes match the record.
133
192
  // mismatch — the gateway returned the tx but its bytes disagree — a finding.
@@ -151,7 +210,25 @@ export interface OnChainResult {
151
210
  }
152
211
 
153
212
  export interface CheckpointResult {
154
- txId: string;
213
+ /**
214
+ * The checkpoint's join key AS THE BUNDLE SPELLS IT — the derived
215
+ * `checkpoint_ref` for a v2 body, the legacy `tx_id` for a frozen v1 body.
216
+ * Never re-derived across forms: a v1 bundle's key is its `tx_id`, because
217
+ * that is what its own events resolve against and what an attestation
218
+ * written against it carries.
219
+ */
220
+ checkpointRef: string;
221
+ /**
222
+ * Where this checkpoint was witnessed, normalised to one shape.
223
+ *
224
+ * `null` when a v2 checkpoint carries no `anchor_ref` — placed but not yet
225
+ * witnessed, which is a normal state and NOT a deficiency. For a frozen v1
226
+ * checkpoint this is synthesised as `{ kind: "arweave", id: tx_id }`, which
227
+ * is exactly what v1's spec said the field was ("the Arweave TX the
228
+ * checkpoint envelope was anchored as"). The synthesis is reader-side only
229
+ * and never re-enters a signed artifact or a verdict.
230
+ */
231
+ anchorRef: AnchorRef | null;
155
232
  // The checkpoint envelope verified (signature + payload binding to its record).
156
233
  envelopeOk: boolean;
157
234
  // The committed record's merkle_root equals the checkpoint's claimed merkle_root.
@@ -176,7 +253,7 @@ export interface EventResult {
176
253
  payloadBindingOk: boolean | null;
177
254
  // RFC 9162 inclusion of this event's leaf in its checkpoint's root.
178
255
  inclusionOk: boolean;
179
- // The inclusion's checkpoint_tx_id resolved to a checkpoint in the bundle.
256
+ // The inclusion's checkpoint_ref resolved to a checkpoint in the bundle.
180
257
  checkpointBound: boolean;
181
258
  // Raw-log (content) binding: SHA-256 of the disclosed bytes equals the
182
259
  // committed event.content_hash (in record_bytes) — or, for a promoted
@@ -215,7 +292,12 @@ export interface EvidenceBundleResult {
215
292
  // One embedded operator attestation record (§2.2 / §3). RSA-PSS-SHA-256 over
216
293
  // JCS(payload); the operator key is the operator's Arweave RSA wallet.
217
294
  export interface AttestationRecord {
218
- checkpoint_tx_id: string;
295
+ /**
296
+ * The source checkpoint this attestation binds to, spelled in the SOURCE
297
+ * bundle's vocabulary: `checkpoint_ref` for a v2 source, the legacy `tx_id`
298
+ * for a frozen v1 one. Not re-derived across forms.
299
+ */
300
+ checkpoint_ref: string;
219
301
  payload: AttestationPayload;
220
302
  signature_alg: string; // "rsa-pss-sha256"
221
303
  public_key: { kty: "RSA"; n: string; e: string };
@@ -281,7 +363,7 @@ export interface VerdictAttestation {
281
363
  }
282
364
 
283
365
  export interface VerdictCheckpoint {
284
- checkpoint_tx_id: string;
366
+ checkpoint_ref: string;
285
367
  merkle_root_ok: boolean;
286
368
  on_chain: VerdictOnChain | null;
287
369
  attestations: VerdictAttestation[];
@@ -300,7 +382,7 @@ export interface VerdictObject {
300
382
  }
301
383
 
302
384
  export interface AttestationResult {
303
- checkpointTxId: string;
385
+ checkpointRef: string;
304
386
  operator: string;
305
387
  gateway: string | null;
306
388
  // The RSA-PSS-SHA-256 signature over JCS(payload) verified (§5 step 6a).
@@ -310,7 +392,7 @@ export interface AttestationResult {
310
392
  // payload.data_hash == the resolved source checkpoint's committed content hash
311
393
  // (SHA-256(JCS(checkpoint.envelope))) (§5 step 6c).
312
394
  dataHashBound: boolean;
313
- // checkpoint_tx_id resolved to a present source checkpoint.
395
+ // checkpoint_ref resolved to a present source checkpoint.
314
396
  checkpointResolved: boolean;
315
397
  level: number | null;
316
398
  // subject_ref (§3.2) tri-state: null absent/undetermined, true/false only
@@ -340,7 +422,7 @@ export interface ExportResult {
340
422
  }
341
423
 
342
424
  export interface VerifyEvidenceOptions {
343
- // Gateways to re-fetch each checkpoint_tx_id from (first responsive wins).
425
+ // Gateways to re-fetch each anchor_ref.id from (first responsive wins).
344
426
  // When supplied, the on-chain bytes MUST byte-match the bundle's checkpoint
345
427
  // envelope, proving the proof is anchored, not just locally asserted.
346
428
  gateways?: string[];
@@ -349,7 +431,15 @@ export interface VerifyEvidenceOptions {
349
431
  // Uint8Array is used as-is; a string is parsed as lowercase hex. In-body
350
432
  // `events[].content` (signed) takes precedence over this side input; if both
351
433
  // are present for an event and disagree, the event fails (§5.1).
352
- content?: Record<string, Uint8Array | string>;
434
+ //
435
+ // A `Map` is the SAFE shape and the one to prefer — it is what
436
+ // `disclosedContent` hands back, so our output feeds straight back in, and a
437
+ // Map keys on the string itself: no prototype to inherit from and no
438
+ // `__proto__` write that silently lands somewhere else. The plain `Record` is
439
+ // still accepted (it was the original signature and remains supported), and
440
+ // the lookup guards it — see `lookupSideContent`. Widening is additive: every
441
+ // existing caller passing a Record keeps working unchanged.
442
+ content?: Record<string, Uint8Array | string> | ReadonlyMap<string, Uint8Array | string>;
353
443
  // Injectable fetch (Node >=18 global `fetch` by default) — test seam.
354
444
  fetchImpl?: typeof fetch;
355
445
  }
@@ -482,13 +572,13 @@ export async function verifyEvidenceBundle(
482
572
  return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
483
573
  }
484
574
 
485
- if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
575
+ if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
486
576
  // The wrapper is authentic but we don't know this body's structure. Per
487
577
  // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
488
578
  // body verdict undetermined) — not a failure.
489
579
  errors.push(
490
580
  `body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
491
- `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
581
+ `(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`,
492
582
  );
493
583
  return {
494
584
  status: "partial",
@@ -507,6 +597,158 @@ export async function verifyEvidenceBundle(
507
597
  return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
508
598
  }
509
599
 
600
+ /**
601
+ * Index a source bundle's checkpoints by the key its attestations will use.
602
+ *
603
+ * The attestation join key follows the SOURCE bundle's own vocabulary — a
604
+ * v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
605
+ * alternative (always deriving a v2-style key) would silently un-resolve every
606
+ * attestation ever written against a v1 export: `data_hash_bound` would go
607
+ * false, the export would stop reaching `verified`, and nothing would say why.
608
+ */
609
+ async function indexSourceCheckpoints(
610
+ bundle: EvidenceBundle,
611
+ ): Promise<Map<string, AnyTraceCheckpoint>> {
612
+ const out = new Map<string, AnyTraceCheckpoint>();
613
+ const sb = bundle.body as AnchorTraceBody | undefined;
614
+ const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
615
+ if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints)) return out;
616
+ for (const cp of sb.checkpoints as AnyTraceCheckpoint[]) {
617
+ if (!cp || typeof cp !== "object") continue;
618
+ let recordBytes: Uint8Array | undefined;
619
+ try {
620
+ if (typeof cp.record_bytes === "string") recordBytes = hexToBytes(cp.record_bytes);
621
+ } catch {
622
+ recordBytes = undefined;
623
+ }
624
+ const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
625
+ if (keyed.key !== null && !out.has(keyed.key)) out.set(keyed.key, cp);
626
+ }
627
+ return out;
628
+ }
629
+
630
+ /** Either shape of checkpoint, before the form is resolved. */
631
+ type AnyTraceCheckpoint = Partial<TraceCheckpoint> & Partial<TraceCheckpointV1>;
632
+
633
+ /**
634
+ * Resolve a checkpoint's join key and its witness reference, for whichever
635
+ * body form we are reading.
636
+ *
637
+ * ── The rule that matters, and why it is a reject rather than a preference ──
638
+ *
639
+ * A checkpoint carrying BOTH vocabularies is two answers to one question
640
+ * inside one signature. A reader that silently prefers one gives a different
641
+ * answer from a reader that prefers the other, over the same signed bytes —
642
+ * and both look like they verified. So a v2 body carrying `tx_id`, or a v1
643
+ * body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
644
+ * resolved. This is cheap to enforce here and impossible to enforce later.
645
+ *
646
+ * ── Why v2's key is recomputed ──
647
+ *
648
+ * In v1 the key was an asserted label matched against another asserted label;
649
+ * nothing tied either to the bytes, so relabelling a checkpoint and every
650
+ * event pointing at it left a bundle that still verified. In v2 the key IS the
651
+ * bytes, so the same relabel fails here. v1's behaviour is preserved exactly
652
+ * as it was — deliberately, because those bundles are already signed and
653
+ * re-interpreting them is not backwards compatibility.
654
+ */
655
+ async function resolveCheckpointKey(
656
+ cp: AnyTraceCheckpoint,
657
+ recordBytes: Uint8Array | undefined,
658
+ isV1: boolean,
659
+ ): Promise<{ key: string | null; anchorRef: AnchorRef | null; errors: string[] }> {
660
+ const errors: string[] = [];
661
+
662
+ if (isV1) {
663
+ if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
664
+ errors.push(
665
+ "sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) — " +
666
+ "one bundle speaks one vocabulary",
667
+ );
668
+ return { key: null, anchorRef: null, errors };
669
+ }
670
+ if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
671
+ errors.push("checkpoint missing tx_id");
672
+ return { key: null, anchorRef: null, errors };
673
+ }
674
+ // v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
675
+ // through the same shape as v2 gives ONE reader-side vocabulary without
676
+ // touching a byte of the signed bundle.
677
+ return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
678
+ }
679
+
680
+ if (cp.tx_id !== undefined) {
681
+ errors.push(
682
+ "sanning.anchor.trace/v2 checkpoint carries legacy tx_id — one bundle speaks one vocabulary",
683
+ );
684
+ return { key: null, anchorRef: null, errors };
685
+ }
686
+ if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
687
+ errors.push("checkpoint missing checkpoint_ref");
688
+ return { key: null, anchorRef: null, errors };
689
+ }
690
+ if (recordBytes === undefined) {
691
+ // Without the bytes the key cannot be recomputed, so it cannot be trusted.
692
+ errors.push("checkpoint_ref cannot be verified — record_bytes missing or not hex");
693
+ return { key: null, anchorRef: null, errors };
694
+ }
695
+ const derived = await sha256Hex(recordBytes);
696
+ if (derived !== cp.checkpoint_ref.toLowerCase()) {
697
+ errors.push(
698
+ `checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`,
699
+ );
700
+ return { key: null, anchorRef: null, errors };
701
+ }
702
+
703
+ const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
704
+ return { key: derived, anchorRef, errors };
705
+ }
706
+
707
+ /**
708
+ * Validate an optional `anchor_ref` (§5.1.1).
709
+ *
710
+ * ── An unrecognised `kind` is NOT a failure, and that is the point ──
711
+ *
712
+ * The offline verdict — signatures, payload binding, Merkle inclusion — is
713
+ * computed with no witness access at all. A reader that cannot resolve a
714
+ * witness has therefore learned NOTHING about whether the evidence was
715
+ * altered, and must not say otherwise. The shipped issuer did the opposite:
716
+ * an unrecognised signature type returned `false` rather than `null`, mapped
717
+ * to `tampered`, and was cached as permanent — a false accusation, produced
718
+ * by a coverage gap, for a company whose product is telling people whether
719
+ * records were altered.
720
+ *
721
+ * So this validates the SHAPE and never the vocabulary. A malformed
722
+ * `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
723
+ * the reader, and only the first is the bundle's fault.
724
+ */
725
+ function parseAnchorRef(raw: unknown, errors: string[]): AnchorRef | null {
726
+ if (raw === undefined || raw === null) return null; // placed, not yet witnessed
727
+ if (typeof raw !== "object" || Array.isArray(raw)) {
728
+ errors.push("anchor_ref must be an object");
729
+ return null;
730
+ }
731
+ const o = raw as Record<string, unknown>;
732
+ if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
733
+ errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
734
+ return null;
735
+ }
736
+ if (typeof o.id !== "string" || o.id === "") {
737
+ errors.push("anchor_ref.id is missing or empty");
738
+ return null;
739
+ }
740
+ const ref = o.ref;
741
+ if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
742
+ errors.push("anchor_ref.ref must be an object when present");
743
+ return null;
744
+ }
745
+ return {
746
+ kind: o.kind,
747
+ id: o.id,
748
+ ...(ref === undefined ? {} : { ref: ref as Record<string, unknown> }),
749
+ };
750
+ }
751
+
510
752
  async function verifyAnchorTrace(
511
753
  b: EvidenceBundle,
512
754
  bodyType: string,
@@ -516,40 +758,55 @@ async function verifyAnchorTrace(
516
758
  options: VerifyEvidenceOptions,
517
759
  ): Promise<EvidenceBundleResult> {
518
760
  const body = b.body as AnchorTraceBody;
761
+ const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
519
762
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
520
- return malformed("sanning.anchor.trace/v1 body must have checkpoints[] and events[]", true);
763
+ return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
521
764
  }
522
765
 
523
766
  const gateways = options.gateways ?? [];
524
767
  const onChainChecked = gateways.length > 0;
525
768
 
526
769
  // --- Step 3: per-checkpoint -------------------------------------------------
527
- const byTxId = new Map<string, TraceCheckpoint>();
770
+ //
771
+ // ── Why the join key is resolved BEFORE anything else ──
772
+ //
773
+ // `byKey` is what binds an event to the root its inclusion proof is checked
774
+ // against. Get it wrong and an event proves inclusion in a tree it was never
775
+ // in — the one failure in this file that produces a WRONG VERIFIED, not a
776
+ // failed verify. Everything below is downstream of it.
777
+ const byKey = new Map<string, AnyTraceCheckpoint>();
778
+ const anchorByKey = new Map<string, AnchorRef | null>();
528
779
  const checkpointResults: CheckpointResult[] = [];
529
- for (const cp of body.checkpoints) {
780
+ for (const cp of body.checkpoints as AnyTraceCheckpoint[]) {
530
781
  const cpErrors: string[] = [];
531
782
  let envelopeOk = false;
532
783
  let merkleRootOk = false;
533
784
 
534
- if (typeof cp.tx_id !== "string") {
535
- cpErrors.push("checkpoint missing tx_id");
536
- } else if (byTxId.has(cp.tx_id)) {
537
- cpErrors.push(`duplicate checkpoint tx_id ${cp.tx_id}`);
538
- } else {
539
- byTxId.set(cp.tx_id, cp);
540
- }
541
-
542
- // Verify the checkpoint envelope against its committed record (external
543
- // commitment — the record is the payload these external envelopes bind to).
785
+ // Decode record_bytes first: in v2 the join key is DERIVED from it, so it
786
+ // is an input to identity, not just to the payload binding below.
544
787
  let recordBytes: Uint8Array | undefined;
545
788
  try {
546
789
  if (typeof cp.record_bytes === "string") recordBytes = hexToBytes(cp.record_bytes);
547
790
  } catch (e) {
548
791
  cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
549
792
  }
793
+
794
+ const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
795
+ cpErrors.push(...keyed.errors);
796
+ if (keyed.key !== null) {
797
+ if (byKey.has(keyed.key)) {
798
+ cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
799
+ } else {
800
+ byKey.set(keyed.key, cp);
801
+ anchorByKey.set(keyed.key, keyed.anchorRef);
802
+ }
803
+ }
804
+
805
+ // Verify the checkpoint envelope against its committed record (external
806
+ // commitment — the record is the payload these external envelopes bind to).
550
807
  try {
551
808
  const res = await verifyEnvelope(
552
- cp.envelope,
809
+ cp.envelope as Envelope,
553
810
  recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
554
811
  );
555
812
  // A checkpoint must fully bind: signature + the committed record matching
@@ -583,12 +840,19 @@ async function verifyAnchorTrace(
583
840
  }
584
841
 
585
842
  checkpointResults.push({
586
- txId: typeof cp.tx_id === "string" ? cp.tx_id : "",
843
+ checkpointRef: keyed.key ?? "",
844
+ anchorRef: keyed.anchorRef,
587
845
  envelopeOk,
588
846
  merkleRootOk,
589
847
  onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
590
848
  onChain: null, // per-gateway (§4.2), filled in step 5
591
- ok: envelopeOk && merkleRootOk,
849
+ // `keyed.errors` is load-bearing here, not decorative. A structurally
850
+ // invalid anchor_ref rides INSIDE the signature, so it is a defect we
851
+ // can see rather than a coverage gap we cannot — and this kernel's
852
+ // standing rule is that malformed is a failure while absent is
853
+ // undetermined (the same rule disclosed `content` follows). Without
854
+ // this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
855
+ ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
592
856
  errors: cpErrors,
593
857
  });
594
858
  }
@@ -622,16 +886,34 @@ async function verifyAnchorTrace(
622
886
 
623
887
  // Bind the event to exactly one checkpoint in the bundle, then verify
624
888
  // RFC 9162 inclusion against THAT checkpoint's merkle_root.
625
- const incl = ev.inclusion;
626
- const cp = incl && typeof incl.checkpoint_tx_id === "string" ? byTxId.get(incl.checkpoint_tx_id) : undefined;
627
- const checkpointBound = cp !== undefined;
889
+ const incl = ev.inclusion as (TraceInclusion & { checkpoint_tx_id?: unknown }) | undefined;
890
+ // The join key is spelled differently in the two forms and MUST NOT be read
891
+ // from the other one: an inclusion carrying v1's spelling inside a v2 body
892
+ // is a mixed bundle, and resolving it anyway is how an event silently binds
893
+ // to a checkpoint nobody meant.
894
+ const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
895
+ const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
896
+ const inclAsRecord = incl as unknown as Record<string, unknown> | undefined;
897
+ const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
898
+ const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
899
+ let cp: AnyTraceCheckpoint | undefined;
628
900
  if (!incl || typeof incl !== "object") {
629
901
  evErrors.push("event missing inclusion proof");
630
- } else if (!checkpointBound) {
902
+ } else if (strayKey !== undefined) {
631
903
  evErrors.push(
632
- `event inclusion.checkpoint_tx_id ${JSON.stringify(incl.checkpoint_tx_id)} resolves to no checkpoint`,
904
+ `event inclusion carries ${wrongField} in a ${bodyType} body — one bundle speaks one vocabulary`,
633
905
  );
906
+ } else if (typeof rawKey !== "string") {
907
+ evErrors.push(`event inclusion missing ${keyField}`);
908
+ } else {
909
+ cp = byKey.get(rawKey);
910
+ if (cp === undefined) {
911
+ evErrors.push(
912
+ `event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`,
913
+ );
914
+ }
634
915
  }
916
+ const checkpointBound = cp !== undefined;
635
917
 
636
918
  let inclusionOk = false;
637
919
  if (incl && checkpointBound && cp) {
@@ -641,7 +923,7 @@ async function verifyAnchorTrace(
641
923
  incl.leaf_index,
642
924
  incl.leaf_count,
643
925
  (incl.audit_path ?? []).map((h) => hexToBytes(h)),
644
- hexToBytes(cp.merkle_root),
926
+ hexToBytes(cp.merkle_root ?? ""),
645
927
  );
646
928
  if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
647
929
 
@@ -761,9 +1043,26 @@ async function verifyAnchorTrace(
761
1043
  errors.push("on-chain re-fetch requested but no fetch implementation is available");
762
1044
  } else {
763
1045
  for (const cpr of checkpointResults) {
764
- const cp = byTxId.get(cpr.txId);
1046
+ const cp = byKey.get(cpr.checkpointRef);
765
1047
  if (!cp) continue;
766
- const outcome = await refetchCheckpoint(cp, gateways, fetchImpl);
1048
+ // A checkpoint with no anchor_ref has NOTHING to re-fetch. Under DL-52
1049
+ // that is the normal state inside a stamp interval — placed, readable,
1050
+ // not yet witnessed — so it reports as unreachable (which downgrades
1051
+ // the run to partial) and NEVER as a mismatch. Treating "we never
1052
+ // anchored it yet" as "the chain disagrees" would be the same false
1053
+ // accusation this whole change is removing.
1054
+ if (cpr.anchorRef === null) {
1055
+ gatewayUnavailable = true;
1056
+ cpr.onChain = {
1057
+ perGateway: gateways.map((gw) => ({ gateway: gw, outcome: "unreachable" as const })),
1058
+ rollup: "unreachable",
1059
+ onChainOk: null,
1060
+ };
1061
+ cpr.onChainOk = null;
1062
+ cpr.errors.push("checkpoint has no anchor_ref — not yet witnessed, nothing to re-fetch");
1063
+ continue;
1064
+ }
1065
+ const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
767
1066
  cpr.onChain = outcome.onChain;
768
1067
  cpr.onChainOk = outcome.onChain.onChainOk;
769
1068
  if (outcome.unavailable) gatewayUnavailable = true;
@@ -910,7 +1209,7 @@ export async function disclosedContent(
910
1209
  const traceEvents = resolveTraceEvents(bundle);
911
1210
  if (traceEvents === undefined) {
912
1211
  errors.push(
913
- `no ${ANCHOR_TRACE_BODY_TYPE} body to materialize content from ` +
1212
+ `no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")} body to materialize content from ` +
914
1213
  `(body_type ${JSON.stringify(verification.bodyType)})`,
915
1214
  );
916
1215
  return empty();
@@ -1013,7 +1312,7 @@ function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
1013
1312
  if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
1014
1313
  const b = cur as EvidenceBundle;
1015
1314
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
1016
- if (bodyType === ANCHOR_TRACE_BODY_TYPE) {
1315
+ if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
1017
1316
  const body = b.body as AnchorTraceBody | null | undefined;
1018
1317
  if (body === null || typeof body !== "object" || !Array.isArray(body.events)) return undefined;
1019
1318
  return body.events;
@@ -1118,14 +1417,8 @@ async function verifyExportBody(
1118
1417
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
1119
1418
  const sourceResult = await verifyEvidenceBundle(inline, options);
1120
1419
 
1121
- // Map source checkpoints by tx_id for attestation data_hash binding (§5 6c).
1122
- const sourceCheckpoints = new Map<string, TraceCheckpoint>();
1123
- const sb = inline.body as AnchorTraceBody | undefined;
1124
- if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
1125
- for (const cp of sb.checkpoints) {
1126
- if (cp && typeof cp.tx_id === "string") sourceCheckpoints.set(cp.tx_id, cp);
1127
- }
1128
- }
1420
+ // Attestation data_hash binding (§5 6c).
1421
+ const sourceCheckpoints = await indexSourceCheckpoints(inline);
1129
1422
 
1130
1423
  // --- Step 6: embedded attestation records ---------------------------------
1131
1424
  const attestations: AttestationResult[] = [];
@@ -1257,15 +1550,9 @@ export async function recomputeExportVerdict(
1257
1550
  ): Promise<ComposeVerdictResult> {
1258
1551
  const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
1259
1552
 
1260
- // Map source checkpoints by tx_id for attestation data_hash binding (§5 6c) —
1261
- // identical to verifyExportBody's map construction.
1262
- const sourceCheckpoints = new Map<string, TraceCheckpoint>();
1263
- const sb = sourceBundle.body as AnchorTraceBody | undefined;
1264
- if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
1265
- for (const cp of sb.checkpoints) {
1266
- if (cp && typeof cp.tx_id === "string") sourceCheckpoints.set(cp.tx_id, cp);
1267
- }
1268
- }
1553
+ // Attestation data_hash binding (§5 6c) — the SAME index construction as
1554
+ // verifyExportBody, deliberately shared rather than restated.
1555
+ const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
1269
1556
 
1270
1557
  const attestations: AttestationResult[] = [];
1271
1558
  for (const rec of attestationRecords) {
@@ -1309,12 +1596,12 @@ export async function recomputeExportVerdict(
1309
1596
  // undetermined-tolerant and never gates.
1310
1597
  async function verifyAttestation(
1311
1598
  rec: AttestationRecord,
1312
- sourceCheckpoints: Map<string, TraceCheckpoint>,
1599
+ sourceCheckpoints: Map<string, AnyTraceCheckpoint>,
1313
1600
  ): Promise<{ result: AttestationResult; malformed?: string }> {
1314
1601
  const errs: string[] = [];
1315
1602
  const result: AttestationResult = {
1316
- checkpointTxId:
1317
- rec && typeof rec.checkpoint_tx_id === "string" ? rec.checkpoint_tx_id : "",
1603
+ checkpointRef:
1604
+ rec && typeof rec.checkpoint_ref === "string" ? rec.checkpoint_ref : "",
1318
1605
  operator: "",
1319
1606
  gateway: null,
1320
1607
  signatureOk: false,
@@ -1392,11 +1679,11 @@ async function verifyAttestation(
1392
1679
  // uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
1393
1680
  // against, §4.2), so the checkpoint's committed content hash is
1394
1681
  // SHA-256(JCS(checkpoint.envelope)).
1395
- const cp = sourceCheckpoints.get(result.checkpointTxId);
1682
+ const cp = sourceCheckpoints.get(result.checkpointRef);
1396
1683
  result.checkpointResolved = cp !== undefined;
1397
1684
  if (!cp) {
1398
1685
  errs.push(
1399
- `attestation checkpoint_tx_id ${JSON.stringify(result.checkpointTxId)} resolves to no source checkpoint`,
1686
+ `attestation checkpoint_ref ${JSON.stringify(result.checkpointRef)} resolves to no source checkpoint`,
1400
1687
  );
1401
1688
  } else {
1402
1689
  try {
@@ -1455,7 +1742,7 @@ function buildVerdictObject(
1455
1742
  ): VerdictObject {
1456
1743
  const attByCheckpoint = new Map<string, VerdictAttestation[]>();
1457
1744
  for (const a of attestations) {
1458
- const list = attByCheckpoint.get(a.checkpointTxId) ?? [];
1745
+ const list = attByCheckpoint.get(a.checkpointRef) ?? [];
1459
1746
  list.push({
1460
1747
  operator: a.operator,
1461
1748
  ...(a.gateway ? { gateway: a.gateway } : {}),
@@ -1465,7 +1752,7 @@ function buildVerdictObject(
1465
1752
  ...(a.level !== null ? { level: a.level } : {}),
1466
1753
  subject_ref_ok: a.subjectRefOk,
1467
1754
  });
1468
- attByCheckpoint.set(a.checkpointTxId, list);
1755
+ attByCheckpoint.set(a.checkpointRef, list);
1469
1756
  }
1470
1757
 
1471
1758
  const events: VerdictEvent[] = source.events.map((e) => ({
@@ -1478,10 +1765,10 @@ function buildVerdictObject(
1478
1765
  }));
1479
1766
 
1480
1767
  const checkpoints: VerdictCheckpoint[] = source.checkpoints.map((c) => ({
1481
- checkpoint_tx_id: c.txId,
1768
+ checkpoint_ref: c.checkpointRef,
1482
1769
  merkle_root_ok: c.merkleRootOk,
1483
1770
  on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
1484
- attestations: attByCheckpoint.get(c.txId) ?? [],
1771
+ attestations: attByCheckpoint.get(c.checkpointRef) ?? [],
1485
1772
  }));
1486
1773
 
1487
1774
  let verified = 0;
@@ -1550,7 +1837,7 @@ function projectVerdictForAgreement(v: unknown): unknown {
1550
1837
  const cp = (c ?? {}) as VerdictCheckpoint;
1551
1838
  const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
1552
1839
  return {
1553
- checkpoint_tx_id: cp.checkpoint_tx_id ?? null,
1840
+ checkpoint_ref: cp.checkpoint_ref ?? null,
1554
1841
  merkle_root_ok: cp.merkle_root_ok ?? null,
1555
1842
  // on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
1556
1843
  attestations: atts.map((a) => {
@@ -1588,7 +1875,8 @@ function summarizeExport(
1588
1875
  // gateways is preserved; the derived collapsed `onChainOk` reproduces the old
1589
1876
  // single-boolean exactly for a single gateway.
1590
1877
  async function refetchCheckpoint(
1591
- cp: TraceCheckpoint,
1878
+ cp: AnyTraceCheckpoint,
1879
+ anchorRef: AnchorRef,
1592
1880
  gateways: string[],
1593
1881
  fetchImpl: typeof fetch,
1594
1882
  ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
@@ -1603,15 +1891,18 @@ async function refetchCheckpoint(
1603
1891
 
1604
1892
  const perGateway: PerGatewayOutcome[] = [];
1605
1893
  for (const gw of gateways) {
1606
- perGateway.push({ gateway: gw, outcome: await probeGateway(cp, gw, expectedHex, fetchImpl) });
1894
+ perGateway.push({
1895
+ gateway: gw,
1896
+ outcome: await probeGateway(anchorRef, gw, expectedHex, fetchImpl),
1897
+ });
1607
1898
  }
1608
1899
 
1609
1900
  const onChain = rollupOnChain(perGateway);
1610
1901
  let error: string | undefined;
1611
1902
  if (onChain.rollup === "mismatch") {
1612
- error = `on-chain bytes at ${cp.tx_id} do not match the bundle's checkpoint envelope`;
1903
+ error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
1613
1904
  } else if (onChain.rollup === "unreachable") {
1614
- error = `checkpoint ${cp.tx_id} unreachable on all gateways`;
1905
+ error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
1615
1906
  }
1616
1907
  return { onChain, unavailable: onChain.rollup === "unreachable", error };
1617
1908
  }
@@ -1619,12 +1910,12 @@ async function refetchCheckpoint(
1619
1910
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
1620
1911
  // (bytes disagree), or unreachable (no response / !ok / network error).
1621
1912
  async function probeGateway(
1622
- cp: TraceCheckpoint,
1913
+ anchorRef: AnchorRef,
1623
1914
  gateway: string,
1624
1915
  expectedHex: string | undefined,
1625
1916
  fetchImpl: typeof fetch,
1626
1917
  ): Promise<OnChainOutcome> {
1627
- const url = `${gateway.replace(/\/+$/, "")}/${cp.tx_id}`;
1918
+ const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
1628
1919
  let resp: Awaited<ReturnType<typeof fetch>>;
1629
1920
  try {
1630
1921
  resp = await fetchImpl(url);
@@ -1691,6 +1982,15 @@ interface DisclosedSelection {
1691
1982
  errors: string[];
1692
1983
  }
1693
1984
 
1985
+ // The side-input presence rule lives in `side-content.ts`, stated ONCE.
1986
+ //
1987
+ // It used to live here as a private `lookupSideContent`, and `grading.ts`
1988
+ // carried a second, laxer answer to the same question (`hasEntry`: own +
1989
+ // enumerable, nothing more). On one and the same content map the grader said
1990
+ // PRESENT while this verifier said nothing was offered, and `gradePack` then
1991
+ // returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
1992
+ // and for what each clause of it cost.
1993
+
1694
1994
  function selectDisclosedBytes(
1695
1995
  ev: TraceEvent,
1696
1996
  eventId: string,
@@ -1706,17 +2006,31 @@ function selectDisclosedBytes(
1706
2006
  }
1707
2007
  }
1708
2008
  let side: Uint8Array | undefined;
1709
- const sideRaw = sideContent?.[eventId];
1710
- if (sideRaw instanceof Uint8Array) {
1711
- side = sideRaw;
1712
- } else if (typeof sideRaw === "string") {
2009
+ const sideEntry: SideEntry = sideContentEntry(sideContent, eventId);
2010
+ if (sideEntry.value instanceof Uint8Array) {
2011
+ side = sideEntry.value;
2012
+ } else if (typeof sideEntry.value === "string") {
1713
2013
  try {
1714
- side = hexToBytes(sideRaw);
2014
+ side = hexToBytes(sideEntry.value);
1715
2015
  } catch (e) {
1716
2016
  errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
1717
2017
  }
1718
2018
  }
1719
- return { inBody, side, offered: ev.content !== undefined || sideRaw !== undefined, errors };
2019
+ // `offered` comes from the SAME test that produced the value, never from
2020
+ // `value !== undefined` — that is the conflation this whole guard exists to
2021
+ // remove, and it was removed from ONE of the two operands. The left one
2022
+ // carried it unchanged: an in-body `"content": null` is `!== undefined`, so
2023
+ // the event was marked offered while no bytes were selected, and the kernel
2024
+ // split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
2025
+ // FAILED, on one bundle. `null` is how an optional field's absence
2026
+ // serializes; `inBodyContentOffered` states that once, beside the side-input
2027
+ // rule it mirrors.
2028
+ return {
2029
+ inBody,
2030
+ side,
2031
+ offered: inBodyContentOffered(ev.content) || sideEntry.offered,
2032
+ errors,
2033
+ };
1720
2034
  }
1721
2035
 
1722
2036
  function toHex(bytes: Uint8Array): string {