@sanning/proof 0.4.1 → 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
 
@@ -699,26 +981,12 @@ async function verifyAnchorTrace(
699
981
  }
700
982
 
701
983
  // Disclosed bytes, precedence: in-body (signed) → side input. A string
702
- // side input is hex; a Uint8Array is used as-is.
703
- let inBody: Uint8Array | undefined;
704
- if (typeof ev.content === "string") {
705
- try {
706
- inBody = hexToBytes(ev.content);
707
- } catch (e) {
708
- evErrors.push(`event content is not hex: ${stringifyErr(e)}`);
709
- }
710
- }
711
- let side: Uint8Array | undefined;
712
- const sideRaw = options.content?.[eventId];
713
- if (sideRaw instanceof Uint8Array) {
714
- side = sideRaw;
715
- } else if (typeof sideRaw === "string") {
716
- try {
717
- side = hexToBytes(sideRaw);
718
- } catch (e) {
719
- evErrors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
720
- }
721
- }
984
+ // side input is hex; a Uint8Array is used as-is. Resolved by the SHARED
985
+ // selector so `disclosedContent` hands a consumer the very bytes bound
986
+ // here — one decode, not two that can drift apart.
987
+ const selection = selectDisclosedBytes(ev, eventId, options.content);
988
+ const { inBody, side } = selection;
989
+ evErrors.push(...selection.errors);
722
990
 
723
991
  if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
724
992
  // Two disclosures that disagree: a genuine conflict, not undetermined.
@@ -775,9 +1043,26 @@ async function verifyAnchorTrace(
775
1043
  errors.push("on-chain re-fetch requested but no fetch implementation is available");
776
1044
  } else {
777
1045
  for (const cpr of checkpointResults) {
778
- const cp = byTxId.get(cpr.txId);
1046
+ const cp = byKey.get(cpr.checkpointRef);
779
1047
  if (!cp) continue;
780
- 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);
781
1066
  cpr.onChain = outcome.onChain;
782
1067
  cpr.onChainOk = outcome.onChain.onChainOk;
783
1068
  if (outcome.unavailable) gatewayUnavailable = true;
@@ -832,6 +1117,214 @@ async function verifyAnchorTrace(
832
1117
  };
833
1118
  }
834
1119
 
1120
+ // ===========================================================================
1121
+ // Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
1122
+ //
1123
+ // The receiving half of a handoff. `events[].content` rides as lowercase hex
1124
+ // (JSON cannot carry arbitrary bytes), and until now every consumer that wanted
1125
+ // the actual log bytes back had to know that and decode them itself —
1126
+ // re-implementing an encoding convention the kernel already applies internally.
1127
+ // A consumer that copied `content` VERBATIM into a file shipped packs that
1128
+ // failed verification on genuine, untampered evidence, because the hash of the
1129
+ // hex text is not the hash of the bytes it encodes.
1130
+ //
1131
+ // `disclosedContent` closes that gap with one rule: bytes come back only when
1132
+ // they are BOUND. It verifies the bundle first and hands back a decoded
1133
+ // Uint8Array only for events whose whole chain held AND whose disclosed bytes
1134
+ // hash to the committed `content_hash`. Everything else comes back as a status
1135
+ // with a reason and NO bytes — in particular a minimal-disclosure event reports
1136
+ // `not_disclosed` rather than empty bytes, because a caller that cannot tell
1137
+ // those apart writes an empty log file and believes it succeeded.
1138
+ // ===========================================================================
1139
+
1140
+ // Per-event outcome of an ingest attempt. Only `verified` carries bytes.
1141
+ export type DisclosureStatus =
1142
+ | "verified" // bytes decoded AND bound to the committed content_hash
1143
+ | "not_disclosed" // nothing disclosed for this event — minimal disclosure, nothing to write
1144
+ | "undetermined" // bytes disclosed but nothing to bind them to (record withheld / no commitment)
1145
+ | "failed"; // the event's chain did not verify, or the disclosure did not bind/decode
1146
+
1147
+ export interface DisclosedEventContent {
1148
+ eventId: string;
1149
+ status: DisclosureStatus;
1150
+ // The decoded raw bytes. Non-null ONLY when status === "verified" — an
1151
+ // unbound or unverified disclosure is withheld, never handed back.
1152
+ bytes: Uint8Array | null;
1153
+ // Why, whenever bytes are absent for a reason other than plain non-disclosure.
1154
+ errors: string[];
1155
+ }
1156
+
1157
+ export interface DisclosedContentResult {
1158
+ // The bundle-level rollup, verbatim from verifyEvidenceBundle.
1159
+ status: EvidenceStatus;
1160
+ // The full verification result, so a caller verifies ONCE: render the verdict
1161
+ // from this and materialize from `content` in the same pass.
1162
+ verification: EvidenceBundleResult;
1163
+ // One entry per event in the bundle body, in body order.
1164
+ events: DisclosedEventContent[];
1165
+ // event_id → verified raw bytes: the materialization map. Contains ONLY
1166
+ // entries whose status is "verified", so `for (const [id, bytes] of
1167
+ // result.content)` is always safe to write straight to disk.
1168
+ content: Map<string, Uint8Array>;
1169
+ // Bundle-level reasons no content could be materialized at all.
1170
+ errors: string[];
1171
+ }
1172
+
1173
+ // How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
1174
+ // nested before this path gives up. verifyEvidenceBundle's canonicalization
1175
+ // depth bound already rejects a pathological bundle, but the unwrap must be
1176
+ // independently bounded rather than trusting that it ran first.
1177
+ const MAX_EXPORT_UNWRAP = 4;
1178
+
1179
+ /**
1180
+ * Materialize the disclosed raw bytes of a received bundle (evidence-bundle §5.1).
1181
+ *
1182
+ * Verify-then-return: the bundle is verified with {@link verifyEvidenceBundle}
1183
+ * (same options — gateways, out-of-band `content`, `fetchImpl`), and an event's
1184
+ * bytes are returned ONLY when that verification bound them to the committed
1185
+ * `content_hash`. There is no path through this function that yields bytes
1186
+ * whose SHA-256 is not the commitment.
1187
+ *
1188
+ * Returns `Uint8Array`, never a string: the spec commits to raw bytes, and a
1189
+ * UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding
1190
+ * such a string would produce different bytes and a different hash. Callers who
1191
+ * know their payload is text decode it themselves and own that assumption.
1192
+ */
1193
+ export async function disclosedContent(
1194
+ bundle: unknown,
1195
+ options: VerifyEvidenceOptions = {},
1196
+ ): Promise<DisclosedContentResult> {
1197
+ const verification = await verifyEvidenceBundle(bundle, options);
1198
+ const events: DisclosedEventContent[] = [];
1199
+ const content = new Map<string, Uint8Array>();
1200
+ const errors: string[] = [];
1201
+ const empty = (): DisclosedContentResult => ({
1202
+ status: verification.status,
1203
+ verification,
1204
+ events: [],
1205
+ content: new Map(),
1206
+ errors: [...errors, ...verification.errors],
1207
+ });
1208
+
1209
+ const traceEvents = resolveTraceEvents(bundle);
1210
+ if (traceEvents === undefined) {
1211
+ errors.push(
1212
+ `no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")} body to materialize content from ` +
1213
+ `(body_type ${JSON.stringify(verification.bodyType)})`,
1214
+ );
1215
+ return empty();
1216
+ }
1217
+
1218
+ // Fail closed on any drift between the body we are decoding and the results
1219
+ // we are trusting. Both must be the same events in the same order, or the
1220
+ // "these bytes are the bytes that verified" guarantee is void — which is the
1221
+ // only guarantee this function makes. A broken wrapper lands here too:
1222
+ // verifyEvidenceBundle returns zero event results, so nothing is emitted.
1223
+ if (traceEvents.length !== verification.events.length) {
1224
+ errors.push(
1225
+ "the bundle did not verify far enough to materialize content: " +
1226
+ `${verification.events.length} verified event result(s) for ${traceEvents.length} body event(s)`,
1227
+ );
1228
+ return empty();
1229
+ }
1230
+ for (let i = 0; i < traceEvents.length; i++) {
1231
+ const te = traceEvents[i];
1232
+ const envelope = te !== null && typeof te === "object" ? te.envelope : undefined;
1233
+ if (getEventId(envelope) !== verification.events[i]!.eventId) {
1234
+ errors.push(
1235
+ `event alignment drift at index ${i}: the body event and its verification result ` +
1236
+ "are not the same event; refusing to hand back any content",
1237
+ );
1238
+ return empty();
1239
+ }
1240
+ }
1241
+
1242
+ for (let i = 0; i < traceEvents.length; i++) {
1243
+ const ev = traceEvents[i] as TraceEvent;
1244
+ const res = verification.events[i]!;
1245
+ const selection = selectDisclosedBytes(ev, res.eventId, options.content);
1246
+ const bytes = selection.inBody ?? selection.side;
1247
+ const evErrors: string[] = [];
1248
+ let status: DisclosureStatus;
1249
+
1250
+ if (!res.ok || res.contentOk === false) {
1251
+ // The event itself is not trustworthy (bad signature, broken inclusion,
1252
+ // unbound checkpoint) or its disclosure contradicts the commitment.
1253
+ status = "failed";
1254
+ evErrors.push(
1255
+ res.contentOk === false
1256
+ ? "disclosed content does not bind to the committed content_hash — withheld"
1257
+ : "the event's own proof chain did not verify — nothing here is safe to materialize",
1258
+ );
1259
+ evErrors.push(...res.errors);
1260
+ } else if (!selection.offered) {
1261
+ // Minimal disclosure: the committed hash is there, the bytes are not.
1262
+ // Distinct from empty bytes, and never an error.
1263
+ status = "not_disclosed";
1264
+ } else if (bytes === undefined) {
1265
+ status = "failed";
1266
+ evErrors.push(
1267
+ "content was disclosed for this event but is not a decodable lowercase-hex string",
1268
+ );
1269
+ evErrors.push(...selection.errors);
1270
+ } else if (res.contentOk !== true) {
1271
+ // Disclosed and decodable, but there is no commitment to check it against
1272
+ // (withheld record, or a record committing no content_hash). Unbound
1273
+ // bytes are exactly what a consumer must not silently write out.
1274
+ status = "undetermined";
1275
+ evErrors.push(
1276
+ "disclosed bytes are not bound to any committed content_hash — withheld rather than returned",
1277
+ );
1278
+ evErrors.push(...res.errors);
1279
+ } else {
1280
+ status = "verified";
1281
+ }
1282
+
1283
+ const verified = status === "verified" && bytes !== undefined;
1284
+ if (verified) {
1285
+ if (content.has(res.eventId)) {
1286
+ errors.push(
1287
+ `duplicate event_id ${JSON.stringify(res.eventId)}: the content map keeps the first ` +
1288
+ "occurrence — read events[] for every event",
1289
+ );
1290
+ } else {
1291
+ content.set(res.eventId, bytes);
1292
+ }
1293
+ }
1294
+ events.push({
1295
+ eventId: res.eventId,
1296
+ status,
1297
+ bytes: verified ? bytes : null,
1298
+ errors: evErrors,
1299
+ });
1300
+ }
1301
+
1302
+ return { status: verification.status, verification, events, content, errors };
1303
+ }
1304
+
1305
+ // The anchor-trace events a bundle carries, unwrapping any evidence-export
1306
+ // wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
1307
+ // recurses into `source_bundle`, so the events line up with its results.
1308
+ // `undefined` when there is no anchor-trace body to materialize from.
1309
+ function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
1310
+ let cur: unknown = bundle;
1311
+ for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
1312
+ if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
1313
+ const b = cur as EvidenceBundle;
1314
+ const bodyType = typeof b.body_type === "string" ? b.body_type : null;
1315
+ if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
1316
+ const body = b.body as AnchorTraceBody | null | undefined;
1317
+ if (body === null || typeof body !== "object" || !Array.isArray(body.events)) return undefined;
1318
+ return body.events;
1319
+ }
1320
+ if (bodyType !== EXPORT_BODY_TYPE) return undefined;
1321
+ const body = b.body as ExportBody | null | undefined;
1322
+ if (body === null || typeof body !== "object") return undefined;
1323
+ cur = body.source_bundle;
1324
+ }
1325
+ return undefined;
1326
+ }
1327
+
835
1328
  // ===========================================================================
836
1329
  // Attested-evidence-export verification (evidence-export.md §5, steps 3–9).
837
1330
  // Reached from verifyEvidenceBundle AFTER the wrapper (Ed25519 issuer signature
@@ -924,14 +1417,8 @@ async function verifyExportBody(
924
1417
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
925
1418
  const sourceResult = await verifyEvidenceBundle(inline, options);
926
1419
 
927
- // Map source checkpoints by tx_id for attestation data_hash binding (§5 6c).
928
- const sourceCheckpoints = new Map<string, TraceCheckpoint>();
929
- const sb = inline.body as AnchorTraceBody | undefined;
930
- if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
931
- for (const cp of sb.checkpoints) {
932
- if (cp && typeof cp.tx_id === "string") sourceCheckpoints.set(cp.tx_id, cp);
933
- }
934
- }
1420
+ // Attestation data_hash binding (§5 6c).
1421
+ const sourceCheckpoints = await indexSourceCheckpoints(inline);
935
1422
 
936
1423
  // --- Step 6: embedded attestation records ---------------------------------
937
1424
  const attestations: AttestationResult[] = [];
@@ -1063,15 +1550,9 @@ export async function recomputeExportVerdict(
1063
1550
  ): Promise<ComposeVerdictResult> {
1064
1551
  const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
1065
1552
 
1066
- // Map source checkpoints by tx_id for attestation data_hash binding (§5 6c) —
1067
- // identical to verifyExportBody's map construction.
1068
- const sourceCheckpoints = new Map<string, TraceCheckpoint>();
1069
- const sb = sourceBundle.body as AnchorTraceBody | undefined;
1070
- if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
1071
- for (const cp of sb.checkpoints) {
1072
- if (cp && typeof cp.tx_id === "string") sourceCheckpoints.set(cp.tx_id, cp);
1073
- }
1074
- }
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);
1075
1556
 
1076
1557
  const attestations: AttestationResult[] = [];
1077
1558
  for (const rec of attestationRecords) {
@@ -1115,12 +1596,12 @@ export async function recomputeExportVerdict(
1115
1596
  // undetermined-tolerant and never gates.
1116
1597
  async function verifyAttestation(
1117
1598
  rec: AttestationRecord,
1118
- sourceCheckpoints: Map<string, TraceCheckpoint>,
1599
+ sourceCheckpoints: Map<string, AnyTraceCheckpoint>,
1119
1600
  ): Promise<{ result: AttestationResult; malformed?: string }> {
1120
1601
  const errs: string[] = [];
1121
1602
  const result: AttestationResult = {
1122
- checkpointTxId:
1123
- rec && typeof rec.checkpoint_tx_id === "string" ? rec.checkpoint_tx_id : "",
1603
+ checkpointRef:
1604
+ rec && typeof rec.checkpoint_ref === "string" ? rec.checkpoint_ref : "",
1124
1605
  operator: "",
1125
1606
  gateway: null,
1126
1607
  signatureOk: false,
@@ -1198,11 +1679,11 @@ async function verifyAttestation(
1198
1679
  // uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
1199
1680
  // against, §4.2), so the checkpoint's committed content hash is
1200
1681
  // SHA-256(JCS(checkpoint.envelope)).
1201
- const cp = sourceCheckpoints.get(result.checkpointTxId);
1682
+ const cp = sourceCheckpoints.get(result.checkpointRef);
1202
1683
  result.checkpointResolved = cp !== undefined;
1203
1684
  if (!cp) {
1204
1685
  errs.push(
1205
- `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`,
1206
1687
  );
1207
1688
  } else {
1208
1689
  try {
@@ -1261,7 +1742,7 @@ function buildVerdictObject(
1261
1742
  ): VerdictObject {
1262
1743
  const attByCheckpoint = new Map<string, VerdictAttestation[]>();
1263
1744
  for (const a of attestations) {
1264
- const list = attByCheckpoint.get(a.checkpointTxId) ?? [];
1745
+ const list = attByCheckpoint.get(a.checkpointRef) ?? [];
1265
1746
  list.push({
1266
1747
  operator: a.operator,
1267
1748
  ...(a.gateway ? { gateway: a.gateway } : {}),
@@ -1271,7 +1752,7 @@ function buildVerdictObject(
1271
1752
  ...(a.level !== null ? { level: a.level } : {}),
1272
1753
  subject_ref_ok: a.subjectRefOk,
1273
1754
  });
1274
- attByCheckpoint.set(a.checkpointTxId, list);
1755
+ attByCheckpoint.set(a.checkpointRef, list);
1275
1756
  }
1276
1757
 
1277
1758
  const events: VerdictEvent[] = source.events.map((e) => ({
@@ -1284,10 +1765,10 @@ function buildVerdictObject(
1284
1765
  }));
1285
1766
 
1286
1767
  const checkpoints: VerdictCheckpoint[] = source.checkpoints.map((c) => ({
1287
- checkpoint_tx_id: c.txId,
1768
+ checkpoint_ref: c.checkpointRef,
1288
1769
  merkle_root_ok: c.merkleRootOk,
1289
1770
  on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
1290
- attestations: attByCheckpoint.get(c.txId) ?? [],
1771
+ attestations: attByCheckpoint.get(c.checkpointRef) ?? [],
1291
1772
  }));
1292
1773
 
1293
1774
  let verified = 0;
@@ -1356,7 +1837,7 @@ function projectVerdictForAgreement(v: unknown): unknown {
1356
1837
  const cp = (c ?? {}) as VerdictCheckpoint;
1357
1838
  const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
1358
1839
  return {
1359
- checkpoint_tx_id: cp.checkpoint_tx_id ?? null,
1840
+ checkpoint_ref: cp.checkpoint_ref ?? null,
1360
1841
  merkle_root_ok: cp.merkle_root_ok ?? null,
1361
1842
  // on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
1362
1843
  attestations: atts.map((a) => {
@@ -1394,7 +1875,8 @@ function summarizeExport(
1394
1875
  // gateways is preserved; the derived collapsed `onChainOk` reproduces the old
1395
1876
  // single-boolean exactly for a single gateway.
1396
1877
  async function refetchCheckpoint(
1397
- cp: TraceCheckpoint,
1878
+ cp: AnyTraceCheckpoint,
1879
+ anchorRef: AnchorRef,
1398
1880
  gateways: string[],
1399
1881
  fetchImpl: typeof fetch,
1400
1882
  ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
@@ -1409,15 +1891,18 @@ async function refetchCheckpoint(
1409
1891
 
1410
1892
  const perGateway: PerGatewayOutcome[] = [];
1411
1893
  for (const gw of gateways) {
1412
- 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
+ });
1413
1898
  }
1414
1899
 
1415
1900
  const onChain = rollupOnChain(perGateway);
1416
1901
  let error: string | undefined;
1417
1902
  if (onChain.rollup === "mismatch") {
1418
- 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`;
1419
1904
  } else if (onChain.rollup === "unreachable") {
1420
- error = `checkpoint ${cp.tx_id} unreachable on all gateways`;
1905
+ error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
1421
1906
  }
1422
1907
  return { onChain, unavailable: onChain.rollup === "unreachable", error };
1423
1908
  }
@@ -1425,12 +1910,12 @@ async function refetchCheckpoint(
1425
1910
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
1426
1911
  // (bytes disagree), or unreachable (no response / !ok / network error).
1427
1912
  async function probeGateway(
1428
- cp: TraceCheckpoint,
1913
+ anchorRef: AnchorRef,
1429
1914
  gateway: string,
1430
1915
  expectedHex: string | undefined,
1431
1916
  fetchImpl: typeof fetch,
1432
1917
  ): Promise<OnChainOutcome> {
1433
- const url = `${gateway.replace(/\/+$/, "")}/${cp.tx_id}`;
1918
+ const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
1434
1919
  let resp: Awaited<ReturnType<typeof fetch>>;
1435
1920
  try {
1436
1921
  resp = await fetchImpl(url);
@@ -1478,6 +1963,76 @@ function getEventId(env: unknown): string {
1478
1963
  return "<unknown>";
1479
1964
  }
1480
1965
 
1966
+ // The disclosed bytes offered for ONE trace event (evidence-bundle §5.1),
1967
+ // decoded once. Shared by the verifier's content check and the `disclosedContent`
1968
+ // ingest API: the bytes a consumer receives are then, by construction, the bytes
1969
+ // the verifier hashed against the commitment rather than a second decode of the
1970
+ // same field that could drift from it.
1971
+ interface DisclosedSelection {
1972
+ // In-body `events[].content` (the signed disclosure), when it decoded.
1973
+ inBody?: Uint8Array;
1974
+ // Out-of-band `options.content[eventId]`, when it decoded.
1975
+ side?: Uint8Array;
1976
+ // A disclosure was OFFERED for this event — in-body or side input — whether or
1977
+ // not it decoded. This is what separates "minimal disclosure, nothing to
1978
+ // materialize" from "disclosed, but unusable"; conflating those is how a
1979
+ // consumer writes an empty log file and believes it succeeded.
1980
+ offered: boolean;
1981
+ // Decode failures, in the verifier's existing wording.
1982
+ errors: string[];
1983
+ }
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
+
1994
+ function selectDisclosedBytes(
1995
+ ev: TraceEvent,
1996
+ eventId: string,
1997
+ sideContent: VerifyEvidenceOptions["content"],
1998
+ ): DisclosedSelection {
1999
+ const errors: string[] = [];
2000
+ let inBody: Uint8Array | undefined;
2001
+ if (typeof ev.content === "string") {
2002
+ try {
2003
+ inBody = hexToBytes(ev.content);
2004
+ } catch (e) {
2005
+ errors.push(`event content is not hex: ${stringifyErr(e)}`);
2006
+ }
2007
+ }
2008
+ let side: Uint8Array | undefined;
2009
+ const sideEntry: SideEntry = sideContentEntry(sideContent, eventId);
2010
+ if (sideEntry.value instanceof Uint8Array) {
2011
+ side = sideEntry.value;
2012
+ } else if (typeof sideEntry.value === "string") {
2013
+ try {
2014
+ side = hexToBytes(sideEntry.value);
2015
+ } catch (e) {
2016
+ errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
2017
+ }
2018
+ }
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
+ };
2034
+ }
2035
+
1481
2036
  function toHex(bytes: Uint8Array): string {
1482
2037
  let out = "";
1483
2038
  for (const x of bytes) out += x.toString(16).padStart(2, "0");