@sanning/proof 0.5.0 → 0.7.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
@@ -52,6 +73,33 @@ export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v1";
52
73
  // verifyExportBody (§5).
53
74
  export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
54
75
 
76
+ /**
77
+ * The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
78
+ *
79
+ * Its own `body_type` rather than a widened anchor-trace body, and the reason
80
+ * is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
81
+ * `sanning.events/v1` envelope, and a stamp has no producer signature at all —
82
+ * Sanning places it, under DL-52, for a customer who holds no chain identity.
83
+ * The two rejected options were making `envelope` optional and synthesising an
84
+ * envelope that Sanning signs; the second would have made a Sanning
85
+ * attestation indistinguishable ON THE WIRE from a producer's, which is
86
+ * unrecoverable once witnessed.
87
+ */
88
+ export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
89
+
90
+ /**
91
+ * The version string INSIDE a witnessed stamp body — a different string from
92
+ * the `body_type` above, and the two are easy to confuse.
93
+ *
94
+ * `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
95
+ * dispatch key. `sanning.stamp/v1` is the control plane's own version of the
96
+ * bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
97
+ * `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
98
+ * version to exactly this value: two answers to one question inside one
99
+ * signature is the §5.1.2 mixed-vocabulary reject, one layer down.
100
+ */
101
+ export const STAMP_BODY_VERSION = "sanning.stamp/v1";
102
+
55
103
  // ---- Wire types (evidence-bundle.md §2 + §5.1) ------------------------------
56
104
 
57
105
  export interface EvidenceIssuer {
@@ -70,20 +118,58 @@ export interface EvidenceVerdict {
70
118
  [k: string]: unknown;
71
119
  }
72
120
 
73
- // `sanning.anchor.trace/v1` body (§5.1). Uint8Array fields ride as lowercase hex.
121
+ /**
122
+ * Where a commitment was witnessed (evidence-bundle.md §5.1.1).
123
+ *
124
+ * The same three-part shape the control plane indexes (`kind` / one promoted
125
+ * `id` / a typed remainder), so the signed body, the index row and the SDK
126
+ * artifact stop being three vocabularies for one fact.
127
+ *
128
+ * NOT ONE FIELD HERE NAMES A CHAIN. Arweave's chunk proof, data_path, offsets
129
+ * and packing live in `ref`, opaque to every reader above this seam. A field
130
+ * that would need renaming for a second witness belongs in `ref` by definition.
131
+ */
132
+ export interface AnchorRef {
133
+ kind: string;
134
+ id: string;
135
+ ref?: Record<string, unknown>;
136
+ }
137
+
138
+ // `sanning.anchor.trace/v2` body (§5.1). Uint8Array fields ride as lowercase hex.
74
139
  export interface TraceCheckpoint {
75
- tx_id: string;
140
+ /**
141
+ * The checkpoint's identity and the join key, DERIVED: hex SHA-256 of the
142
+ * decoded `record_bytes`. Recomputed by the verifier — in v1 this was an
143
+ * asserted label matched against another asserted label, so a producer could
144
+ * relabel a checkpoint and the bundle still verified.
145
+ */
146
+ checkpoint_ref: string;
147
+ /**
148
+ * Where it was witnessed, IF IT HAS BEEN. Optional, and absent is honest:
149
+ * under DL-52 a checkpoint is routinely placed before it is witnessed, and a
150
+ * required field that must be filled dishonestly is a defect in the format.
151
+ */
152
+ anchor_ref?: AnchorRef;
76
153
  envelope: Envelope;
77
154
  record_bytes: string; // hex of JCS(checkpoint record)
78
155
  merkle_root: string; // hex
79
156
  }
80
157
 
158
+ /** The frozen `sanning.anchor.trace/v1` checkpoint (§5.1.3). Read, never written. */
159
+ export interface TraceCheckpointV1 {
160
+ tx_id: string;
161
+ envelope: Envelope;
162
+ record_bytes: string;
163
+ merkle_root: string;
164
+ }
165
+
81
166
  export interface TraceInclusion {
82
167
  leaf_hash: string; // hex
83
168
  leaf_index: number;
84
169
  leaf_count: number;
85
170
  audit_path: string[]; // hex[]
86
- checkpoint_tx_id: string;
171
+ /** Resolves against `checkpoints[].checkpoint_ref`. v1 spells this `checkpoint_tx_id`. */
172
+ checkpoint_ref: string;
87
173
  }
88
174
 
89
175
  export interface TraceEvent {
@@ -101,6 +187,64 @@ export interface AnchorTraceBody {
101
187
  events: TraceEvent[];
102
188
  }
103
189
 
190
+ // ---- `sanning.stamp.trace/v1` wire types (evidence-bundle.md §5.2) ---------
191
+
192
+ /**
193
+ * One sub-tree of a stamp, as the WITNESSED bytes carry it.
194
+ *
195
+ * Keyed in the body by a blinded reference (an HMAC under a per-org secret we
196
+ * never publish), so a reader learns that the org runs N agents and their
197
+ * relative volumes and learns no name. The RANGE is deliberately in the clear:
198
+ * it is what makes a later per-agent slice claim checkable by a third party
199
+ * without Sanning, and hiding it would put us back in the trust path.
200
+ */
201
+ export interface StampSubtree {
202
+ first_leaf: number;
203
+ leaf_count: number;
204
+ /** Hex RFC-9162 root over this sub-tree's leaves alone. */
205
+ root: string;
206
+ }
207
+
208
+ /**
209
+ * The stamp body as the control plane witnesses it
210
+ * (`control-plane/src/stamp-tree.ts`, `StampBody`).
211
+ *
212
+ * Never read from the bundle directly — it is parsed out of `stamp_bytes`,
213
+ * which is the only authority. Present as a type so the parse has a shape.
214
+ */
215
+ export interface StampBody {
216
+ stamp_version: string;
217
+ /** The org, BLINDED. Not an org id, and not reversible without the secret. */
218
+ org_ref: string;
219
+ interval_start: string;
220
+ interval_end: string;
221
+ merkle_root: string;
222
+ leaf_count: number;
223
+ subtrees: Record<string, StampSubtree>;
224
+ }
225
+
226
+ /** One entry of a `sanning.stamp.trace/v1` body's `stamps[]` (§5.2). */
227
+ export interface StampEntry {
228
+ /**
229
+ * The stamp's identity within this bundle, DERIVED: hex SHA-256 of the
230
+ * DECODED `stamp_bytes`, not of the hex text. A verifier MUST recompute it.
231
+ */
232
+ stamp_ref: string;
233
+ /** Lowercase hex of the exact JCS bytes that were handed to the witness. */
234
+ stamp_bytes: string;
235
+ /**
236
+ * Where the stamp was witnessed, IF IT HAS BEEN (§5.1.1). Absent is honest:
237
+ * a sealed-but-unconfirmed stamp is witnessed by nobody, and an emitter that
238
+ * filled this in from a submission receipt would be claiming a chain
239
+ * position that does not exist yet.
240
+ */
241
+ anchor_ref?: AnchorRef;
242
+ }
243
+
244
+ export interface StampTraceBody {
245
+ stamps: StampEntry[];
246
+ }
247
+
104
248
  export interface EvidenceBundle {
105
249
  spec_version: string;
106
250
  body_type: string;
@@ -127,7 +271,7 @@ export type EvidenceStatus =
127
271
  | "malformed"; // structurally unparseable / unknown major / body_hash mismatch
128
272
 
129
273
  // 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
274
+ // The on-chain re-fetch resolves each checkpoint's anchor_ref.id against EACH configured
131
275
  // gateway and records that gateway's individual outcome:
132
276
  // confirm — the gateway returned the tx and its bytes match the record.
133
277
  // mismatch — the gateway returned the tx but its bytes disagree — a finding.
@@ -151,7 +295,25 @@ export interface OnChainResult {
151
295
  }
152
296
 
153
297
  export interface CheckpointResult {
154
- txId: string;
298
+ /**
299
+ * The checkpoint's join key AS THE BUNDLE SPELLS IT — the derived
300
+ * `checkpoint_ref` for a v2 body, the legacy `tx_id` for a frozen v1 body.
301
+ * Never re-derived across forms: a v1 bundle's key is its `tx_id`, because
302
+ * that is what its own events resolve against and what an attestation
303
+ * written against it carries.
304
+ */
305
+ checkpointRef: string;
306
+ /**
307
+ * Where this checkpoint was witnessed, normalised to one shape.
308
+ *
309
+ * `null` when a v2 checkpoint carries no `anchor_ref` — placed but not yet
310
+ * witnessed, which is a normal state and NOT a deficiency. For a frozen v1
311
+ * checkpoint this is synthesised as `{ kind: "arweave", id: tx_id }`, which
312
+ * is exactly what v1's spec said the field was ("the Arweave TX the
313
+ * checkpoint envelope was anchored as"). The synthesis is reader-side only
314
+ * and never re-enters a signed artifact or a verdict.
315
+ */
316
+ anchorRef: AnchorRef | null;
155
317
  // The checkpoint envelope verified (signature + payload binding to its record).
156
318
  envelopeOk: boolean;
157
319
  // The committed record's merkle_root equals the checkpoint's claimed merkle_root.
@@ -176,7 +338,7 @@ export interface EventResult {
176
338
  payloadBindingOk: boolean | null;
177
339
  // RFC 9162 inclusion of this event's leaf in its checkpoint's root.
178
340
  inclusionOk: boolean;
179
- // The inclusion's checkpoint_tx_id resolved to a checkpoint in the bundle.
341
+ // The inclusion's checkpoint_ref resolved to a checkpoint in the bundle.
180
342
  checkpointBound: boolean;
181
343
  // Raw-log (content) binding: SHA-256 of the disclosed bytes equals the
182
344
  // committed event.content_hash (in record_bytes) — or, for a promoted
@@ -188,6 +350,56 @@ export interface EventResult {
188
350
  errors: string[];
189
351
  }
190
352
 
353
+ /** One sub-tree's recomputed result (§5.2). */
354
+ export interface StampSubtreeResult {
355
+ /** The blinded key the body spells it under. Meaningless without the org's secret. */
356
+ ref: string;
357
+ firstLeaf: number;
358
+ leafCount: number;
359
+ root: string;
360
+ }
361
+
362
+ /** One `stamps[]` entry's result (§5.2). */
363
+ export interface StampResult {
364
+ /** The stamp's derived identity, as the bundle spells it. "" when unusable. */
365
+ stampRef: string;
366
+ /** The asserted `stamp_ref` equals SHA-256 of the decoded `stamp_bytes`. */
367
+ stampRefOk: boolean;
368
+ /**
369
+ * The witnessed bytes decoded, parsed, and re-canonicalized to themselves.
370
+ *
371
+ * The last clause is not pedantry. The plane stores these bytes as TEXT and
372
+ * replays them verbatim precisely because a round-trip through a normalizing
373
+ * serializer is not guaranteed to reproduce what was signed; a body that is
374
+ * not JCS-canonical is one that every re-deriving reader hashes differently.
375
+ */
376
+ bodyOk: boolean;
377
+ stampVersion: string | null;
378
+ intervalStart: string | null;
379
+ intervalEnd: string | null;
380
+ merkleRoot: string | null;
381
+ leafCount: number | null;
382
+ /**
383
+ * Every sub-tree range accounts for exactly one leaf of the stamp: the
384
+ * ranges sorted by `first_leaf` tile `[0, leaf_count)` with no gap and no
385
+ * overlap, and their counts sum to `leaf_count`.
386
+ */
387
+ subtreesOk: boolean;
388
+ subtrees: StampSubtreeResult[];
389
+ /**
390
+ * Where it was witnessed, or `null` for a stamp nothing has witnessed yet.
391
+ *
392
+ * `null` is NOT a failure and never an accusation — it is the honest state
393
+ * of every stamp for the first minutes of its life and the permanent state
394
+ * of every stamp when no witness is configured. It does hold the bundle at
395
+ * `partial`: a stamp is Sanning's own assertion until something independent
396
+ * carries it.
397
+ */
398
+ anchorRef: AnchorRef | null;
399
+ ok: boolean;
400
+ errors: string[];
401
+ }
402
+
191
403
  export interface EvidenceBundleResult {
192
404
  status: EvidenceStatus;
193
405
  // The wrapper: accepted major, body_hash recompute, Ed25519 wrapper signature.
@@ -207,6 +419,12 @@ export interface EvidenceBundleResult {
207
419
  // above are the recomputed SOURCE-bundle results in that case. Absent for a
208
420
  // plain anchor-trace bundle — additive, backward-compatible.
209
421
  export?: ExportResult;
422
+ // Present ONLY for a `sanning.stamp.trace/v1` body (evidence-bundle.md §5.2):
423
+ // the per-stamp recomputed results. `checkpoints`/`events` stay empty for
424
+ // such a bundle — a stamp body carries neither, and reporting an empty list
425
+ // as "nothing was wrong" is the exact confusion `partial` exists to prevent.
426
+ // Absent for every other body — additive, backward-compatible.
427
+ stamps?: StampResult[];
210
428
  errors: string[];
211
429
  }
212
430
 
@@ -215,7 +433,12 @@ export interface EvidenceBundleResult {
215
433
  // One embedded operator attestation record (§2.2 / §3). RSA-PSS-SHA-256 over
216
434
  // JCS(payload); the operator key is the operator's Arweave RSA wallet.
217
435
  export interface AttestationRecord {
218
- checkpoint_tx_id: string;
436
+ /**
437
+ * The source checkpoint this attestation binds to, spelled in the SOURCE
438
+ * bundle's vocabulary: `checkpoint_ref` for a v2 source, the legacy `tx_id`
439
+ * for a frozen v1 one. Not re-derived across forms.
440
+ */
441
+ checkpoint_ref: string;
219
442
  payload: AttestationPayload;
220
443
  signature_alg: string; // "rsa-pss-sha256"
221
444
  public_key: { kty: "RSA"; n: string; e: string };
@@ -281,7 +504,7 @@ export interface VerdictAttestation {
281
504
  }
282
505
 
283
506
  export interface VerdictCheckpoint {
284
- checkpoint_tx_id: string;
507
+ checkpoint_ref: string;
285
508
  merkle_root_ok: boolean;
286
509
  on_chain: VerdictOnChain | null;
287
510
  attestations: VerdictAttestation[];
@@ -300,7 +523,7 @@ export interface VerdictObject {
300
523
  }
301
524
 
302
525
  export interface AttestationResult {
303
- checkpointTxId: string;
526
+ checkpointRef: string;
304
527
  operator: string;
305
528
  gateway: string | null;
306
529
  // The RSA-PSS-SHA-256 signature over JCS(payload) verified (§5 step 6a).
@@ -310,7 +533,7 @@ export interface AttestationResult {
310
533
  // payload.data_hash == the resolved source checkpoint's committed content hash
311
534
  // (SHA-256(JCS(checkpoint.envelope))) (§5 step 6c).
312
535
  dataHashBound: boolean;
313
- // checkpoint_tx_id resolved to a present source checkpoint.
536
+ // checkpoint_ref resolved to a present source checkpoint.
314
537
  checkpointResolved: boolean;
315
538
  level: number | null;
316
539
  // subject_ref (§3.2) tri-state: null absent/undetermined, true/false only
@@ -340,7 +563,7 @@ export interface ExportResult {
340
563
  }
341
564
 
342
565
  export interface VerifyEvidenceOptions {
343
- // Gateways to re-fetch each checkpoint_tx_id from (first responsive wins).
566
+ // Gateways to re-fetch each anchor_ref.id from (first responsive wins).
344
567
  // When supplied, the on-chain bytes MUST byte-match the bundle's checkpoint
345
568
  // envelope, proving the proof is anchored, not just locally asserted.
346
569
  gateways?: string[];
@@ -349,7 +572,15 @@ export interface VerifyEvidenceOptions {
349
572
  // Uint8Array is used as-is; a string is parsed as lowercase hex. In-body
350
573
  // `events[].content` (signed) takes precedence over this side input; if both
351
574
  // are present for an event and disagree, the event fails (§5.1).
352
- content?: Record<string, Uint8Array | string>;
575
+ //
576
+ // A `Map` is the SAFE shape and the one to prefer — it is what
577
+ // `disclosedContent` hands back, so our output feeds straight back in, and a
578
+ // Map keys on the string itself: no prototype to inherit from and no
579
+ // `__proto__` write that silently lands somewhere else. The plain `Record` is
580
+ // still accepted (it was the original signature and remains supported), and
581
+ // the lookup guards it — see `lookupSideContent`. Widening is additive: every
582
+ // existing caller passing a Record keeps working unchanged.
583
+ content?: Record<string, Uint8Array | string> | ReadonlyMap<string, Uint8Array | string>;
353
584
  // Injectable fetch (Node >=18 global `fetch` by default) — test seam.
354
585
  fetchImpl?: typeof fetch;
355
586
  }
@@ -482,13 +713,20 @@ export async function verifyEvidenceBundle(
482
713
  return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
483
714
  }
484
715
 
485
- if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
716
+ // The Sanning-signed interval stamp body (evidence-bundle.md §5.2). The
717
+ // wrapper is authentic — and for this body that IS "Sanning signed it", since
718
+ // a stamp carries no second signature of its own inside the bundle.
719
+ if (bodyType === STAMP_TRACE_BODY_TYPE) {
720
+ return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
721
+ }
722
+
723
+ if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
486
724
  // The wrapper is authentic but we don't know this body's structure. Per
487
725
  // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
488
726
  // body verdict undetermined) — not a failure.
489
727
  errors.push(
490
728
  `body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
491
- `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
729
+ `(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`,
492
730
  );
493
731
  return {
494
732
  status: "partial",
@@ -507,6 +745,158 @@ export async function verifyEvidenceBundle(
507
745
  return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
508
746
  }
509
747
 
748
+ /**
749
+ * Index a source bundle's checkpoints by the key its attestations will use.
750
+ *
751
+ * The attestation join key follows the SOURCE bundle's own vocabulary — a
752
+ * v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
753
+ * alternative (always deriving a v2-style key) would silently un-resolve every
754
+ * attestation ever written against a v1 export: `data_hash_bound` would go
755
+ * false, the export would stop reaching `verified`, and nothing would say why.
756
+ */
757
+ async function indexSourceCheckpoints(
758
+ bundle: EvidenceBundle,
759
+ ): Promise<Map<string, AnyTraceCheckpoint>> {
760
+ const out = new Map<string, AnyTraceCheckpoint>();
761
+ const sb = bundle.body as AnchorTraceBody | undefined;
762
+ const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
763
+ if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints)) return out;
764
+ for (const cp of sb.checkpoints as AnyTraceCheckpoint[]) {
765
+ if (!cp || typeof cp !== "object") continue;
766
+ let recordBytes: Uint8Array | undefined;
767
+ try {
768
+ if (typeof cp.record_bytes === "string") recordBytes = hexToBytes(cp.record_bytes);
769
+ } catch {
770
+ recordBytes = undefined;
771
+ }
772
+ const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
773
+ if (keyed.key !== null && !out.has(keyed.key)) out.set(keyed.key, cp);
774
+ }
775
+ return out;
776
+ }
777
+
778
+ /** Either shape of checkpoint, before the form is resolved. */
779
+ type AnyTraceCheckpoint = Partial<TraceCheckpoint> & Partial<TraceCheckpointV1>;
780
+
781
+ /**
782
+ * Resolve a checkpoint's join key and its witness reference, for whichever
783
+ * body form we are reading.
784
+ *
785
+ * ── The rule that matters, and why it is a reject rather than a preference ──
786
+ *
787
+ * A checkpoint carrying BOTH vocabularies is two answers to one question
788
+ * inside one signature. A reader that silently prefers one gives a different
789
+ * answer from a reader that prefers the other, over the same signed bytes —
790
+ * and both look like they verified. So a v2 body carrying `tx_id`, or a v1
791
+ * body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
792
+ * resolved. This is cheap to enforce here and impossible to enforce later.
793
+ *
794
+ * ── Why v2's key is recomputed ──
795
+ *
796
+ * In v1 the key was an asserted label matched against another asserted label;
797
+ * nothing tied either to the bytes, so relabelling a checkpoint and every
798
+ * event pointing at it left a bundle that still verified. In v2 the key IS the
799
+ * bytes, so the same relabel fails here. v1's behaviour is preserved exactly
800
+ * as it was — deliberately, because those bundles are already signed and
801
+ * re-interpreting them is not backwards compatibility.
802
+ */
803
+ async function resolveCheckpointKey(
804
+ cp: AnyTraceCheckpoint,
805
+ recordBytes: Uint8Array | undefined,
806
+ isV1: boolean,
807
+ ): Promise<{ key: string | null; anchorRef: AnchorRef | null; errors: string[] }> {
808
+ const errors: string[] = [];
809
+
810
+ if (isV1) {
811
+ if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
812
+ errors.push(
813
+ "sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) — " +
814
+ "one bundle speaks one vocabulary",
815
+ );
816
+ return { key: null, anchorRef: null, errors };
817
+ }
818
+ if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
819
+ errors.push("checkpoint missing tx_id");
820
+ return { key: null, anchorRef: null, errors };
821
+ }
822
+ // v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
823
+ // through the same shape as v2 gives ONE reader-side vocabulary without
824
+ // touching a byte of the signed bundle.
825
+ return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
826
+ }
827
+
828
+ if (cp.tx_id !== undefined) {
829
+ errors.push(
830
+ "sanning.anchor.trace/v2 checkpoint carries legacy tx_id — one bundle speaks one vocabulary",
831
+ );
832
+ return { key: null, anchorRef: null, errors };
833
+ }
834
+ if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
835
+ errors.push("checkpoint missing checkpoint_ref");
836
+ return { key: null, anchorRef: null, errors };
837
+ }
838
+ if (recordBytes === undefined) {
839
+ // Without the bytes the key cannot be recomputed, so it cannot be trusted.
840
+ errors.push("checkpoint_ref cannot be verified — record_bytes missing or not hex");
841
+ return { key: null, anchorRef: null, errors };
842
+ }
843
+ const derived = await sha256Hex(recordBytes);
844
+ if (derived !== cp.checkpoint_ref.toLowerCase()) {
845
+ errors.push(
846
+ `checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`,
847
+ );
848
+ return { key: null, anchorRef: null, errors };
849
+ }
850
+
851
+ const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
852
+ return { key: derived, anchorRef, errors };
853
+ }
854
+
855
+ /**
856
+ * Validate an optional `anchor_ref` (§5.1.1).
857
+ *
858
+ * ── An unrecognised `kind` is NOT a failure, and that is the point ──
859
+ *
860
+ * The offline verdict — signatures, payload binding, Merkle inclusion — is
861
+ * computed with no witness access at all. A reader that cannot resolve a
862
+ * witness has therefore learned NOTHING about whether the evidence was
863
+ * altered, and must not say otherwise. The shipped issuer did the opposite:
864
+ * an unrecognised signature type returned `false` rather than `null`, mapped
865
+ * to `tampered`, and was cached as permanent — a false accusation, produced
866
+ * by a coverage gap, for a company whose product is telling people whether
867
+ * records were altered.
868
+ *
869
+ * So this validates the SHAPE and never the vocabulary. A malformed
870
+ * `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
871
+ * the reader, and only the first is the bundle's fault.
872
+ */
873
+ function parseAnchorRef(raw: unknown, errors: string[]): AnchorRef | null {
874
+ if (raw === undefined || raw === null) return null; // placed, not yet witnessed
875
+ if (typeof raw !== "object" || Array.isArray(raw)) {
876
+ errors.push("anchor_ref must be an object");
877
+ return null;
878
+ }
879
+ const o = raw as Record<string, unknown>;
880
+ if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
881
+ errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
882
+ return null;
883
+ }
884
+ if (typeof o.id !== "string" || o.id === "") {
885
+ errors.push("anchor_ref.id is missing or empty");
886
+ return null;
887
+ }
888
+ const ref = o.ref;
889
+ if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
890
+ errors.push("anchor_ref.ref must be an object when present");
891
+ return null;
892
+ }
893
+ return {
894
+ kind: o.kind,
895
+ id: o.id,
896
+ ...(ref === undefined ? {} : { ref: ref as Record<string, unknown> }),
897
+ };
898
+ }
899
+
510
900
  async function verifyAnchorTrace(
511
901
  b: EvidenceBundle,
512
902
  bodyType: string,
@@ -516,40 +906,55 @@ async function verifyAnchorTrace(
516
906
  options: VerifyEvidenceOptions,
517
907
  ): Promise<EvidenceBundleResult> {
518
908
  const body = b.body as AnchorTraceBody;
909
+ const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
519
910
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
520
- return malformed("sanning.anchor.trace/v1 body must have checkpoints[] and events[]", true);
911
+ return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
521
912
  }
522
913
 
523
914
  const gateways = options.gateways ?? [];
524
915
  const onChainChecked = gateways.length > 0;
525
916
 
526
917
  // --- Step 3: per-checkpoint -------------------------------------------------
527
- const byTxId = new Map<string, TraceCheckpoint>();
918
+ //
919
+ // ── Why the join key is resolved BEFORE anything else ──
920
+ //
921
+ // `byKey` is what binds an event to the root its inclusion proof is checked
922
+ // against. Get it wrong and an event proves inclusion in a tree it was never
923
+ // in — the one failure in this file that produces a WRONG VERIFIED, not a
924
+ // failed verify. Everything below is downstream of it.
925
+ const byKey = new Map<string, AnyTraceCheckpoint>();
926
+ const anchorByKey = new Map<string, AnchorRef | null>();
528
927
  const checkpointResults: CheckpointResult[] = [];
529
- for (const cp of body.checkpoints) {
928
+ for (const cp of body.checkpoints as AnyTraceCheckpoint[]) {
530
929
  const cpErrors: string[] = [];
531
930
  let envelopeOk = false;
532
931
  let merkleRootOk = false;
533
932
 
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).
933
+ // Decode record_bytes first: in v2 the join key is DERIVED from it, so it
934
+ // is an input to identity, not just to the payload binding below.
544
935
  let recordBytes: Uint8Array | undefined;
545
936
  try {
546
937
  if (typeof cp.record_bytes === "string") recordBytes = hexToBytes(cp.record_bytes);
547
938
  } catch (e) {
548
939
  cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
549
940
  }
941
+
942
+ const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
943
+ cpErrors.push(...keyed.errors);
944
+ if (keyed.key !== null) {
945
+ if (byKey.has(keyed.key)) {
946
+ cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
947
+ } else {
948
+ byKey.set(keyed.key, cp);
949
+ anchorByKey.set(keyed.key, keyed.anchorRef);
950
+ }
951
+ }
952
+
953
+ // Verify the checkpoint envelope against its committed record (external
954
+ // commitment — the record is the payload these external envelopes bind to).
550
955
  try {
551
956
  const res = await verifyEnvelope(
552
- cp.envelope,
957
+ cp.envelope as Envelope,
553
958
  recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
554
959
  );
555
960
  // A checkpoint must fully bind: signature + the committed record matching
@@ -583,12 +988,19 @@ async function verifyAnchorTrace(
583
988
  }
584
989
 
585
990
  checkpointResults.push({
586
- txId: typeof cp.tx_id === "string" ? cp.tx_id : "",
991
+ checkpointRef: keyed.key ?? "",
992
+ anchorRef: keyed.anchorRef,
587
993
  envelopeOk,
588
994
  merkleRootOk,
589
995
  onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
590
996
  onChain: null, // per-gateway (§4.2), filled in step 5
591
- ok: envelopeOk && merkleRootOk,
997
+ // `keyed.errors` is load-bearing here, not decorative. A structurally
998
+ // invalid anchor_ref rides INSIDE the signature, so it is a defect we
999
+ // can see rather than a coverage gap we cannot — and this kernel's
1000
+ // standing rule is that malformed is a failure while absent is
1001
+ // undetermined (the same rule disclosed `content` follows). Without
1002
+ // this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
1003
+ ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
592
1004
  errors: cpErrors,
593
1005
  });
594
1006
  }
@@ -622,16 +1034,34 @@ async function verifyAnchorTrace(
622
1034
 
623
1035
  // Bind the event to exactly one checkpoint in the bundle, then verify
624
1036
  // 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;
1037
+ const incl = ev.inclusion as (TraceInclusion & { checkpoint_tx_id?: unknown }) | undefined;
1038
+ // The join key is spelled differently in the two forms and MUST NOT be read
1039
+ // from the other one: an inclusion carrying v1's spelling inside a v2 body
1040
+ // is a mixed bundle, and resolving it anyway is how an event silently binds
1041
+ // to a checkpoint nobody meant.
1042
+ const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
1043
+ const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
1044
+ const inclAsRecord = incl as unknown as Record<string, unknown> | undefined;
1045
+ const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
1046
+ const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
1047
+ let cp: AnyTraceCheckpoint | undefined;
628
1048
  if (!incl || typeof incl !== "object") {
629
1049
  evErrors.push("event missing inclusion proof");
630
- } else if (!checkpointBound) {
1050
+ } else if (strayKey !== undefined) {
631
1051
  evErrors.push(
632
- `event inclusion.checkpoint_tx_id ${JSON.stringify(incl.checkpoint_tx_id)} resolves to no checkpoint`,
1052
+ `event inclusion carries ${wrongField} in a ${bodyType} body — one bundle speaks one vocabulary`,
633
1053
  );
1054
+ } else if (typeof rawKey !== "string") {
1055
+ evErrors.push(`event inclusion missing ${keyField}`);
1056
+ } else {
1057
+ cp = byKey.get(rawKey);
1058
+ if (cp === undefined) {
1059
+ evErrors.push(
1060
+ `event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`,
1061
+ );
1062
+ }
634
1063
  }
1064
+ const checkpointBound = cp !== undefined;
635
1065
 
636
1066
  let inclusionOk = false;
637
1067
  if (incl && checkpointBound && cp) {
@@ -641,7 +1071,7 @@ async function verifyAnchorTrace(
641
1071
  incl.leaf_index,
642
1072
  incl.leaf_count,
643
1073
  (incl.audit_path ?? []).map((h) => hexToBytes(h)),
644
- hexToBytes(cp.merkle_root),
1074
+ hexToBytes(cp.merkle_root ?? ""),
645
1075
  );
646
1076
  if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
647
1077
 
@@ -761,9 +1191,26 @@ async function verifyAnchorTrace(
761
1191
  errors.push("on-chain re-fetch requested but no fetch implementation is available");
762
1192
  } else {
763
1193
  for (const cpr of checkpointResults) {
764
- const cp = byTxId.get(cpr.txId);
1194
+ const cp = byKey.get(cpr.checkpointRef);
765
1195
  if (!cp) continue;
766
- const outcome = await refetchCheckpoint(cp, gateways, fetchImpl);
1196
+ // A checkpoint with no anchor_ref has NOTHING to re-fetch. Under DL-52
1197
+ // that is the normal state inside a stamp interval — placed, readable,
1198
+ // not yet witnessed — so it reports as unreachable (which downgrades
1199
+ // the run to partial) and NEVER as a mismatch. Treating "we never
1200
+ // anchored it yet" as "the chain disagrees" would be the same false
1201
+ // accusation this whole change is removing.
1202
+ if (cpr.anchorRef === null) {
1203
+ gatewayUnavailable = true;
1204
+ cpr.onChain = {
1205
+ perGateway: gateways.map((gw) => ({ gateway: gw, outcome: "unreachable" as const })),
1206
+ rollup: "unreachable",
1207
+ onChainOk: null,
1208
+ };
1209
+ cpr.onChainOk = null;
1210
+ cpr.errors.push("checkpoint has no anchor_ref — not yet witnessed, nothing to re-fetch");
1211
+ continue;
1212
+ }
1213
+ const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
767
1214
  cpr.onChain = outcome.onChain;
768
1215
  cpr.onChainOk = outcome.onChain.onChainOk;
769
1216
  if (outcome.unavailable) gatewayUnavailable = true;
@@ -818,6 +1265,332 @@ async function verifyAnchorTrace(
818
1265
  };
819
1266
  }
820
1267
 
1268
+ // ===========================================================================
1269
+ // `sanning.stamp.trace/v1` — the Sanning-signed interval stamp (§5.2, DL-58)
1270
+ //
1271
+ // ── What this can prove, and what it deliberately does not claim ──
1272
+ //
1273
+ // A stamp is not a producer artifact. Under DL-52 the customer signs an
1274
+ // envelope and POSTs it; the commitment is witnessed later, on an interval, by
1275
+ // a stamp the customer never sees and holds no key for. So there is no
1276
+ // producer signature inside this body to check, and — this is the part worth
1277
+ // being precise about — there is no SECOND Sanning signature either. The
1278
+ // control plane stores the stamp's canonical bytes and hands them to a witness
1279
+ // as a signed chain data item; the signature lives on the chain, not in the
1280
+ // `stamps` table and not in this body.
1281
+ //
1282
+ // What "Sanning signed this" means for a stamp bundle is therefore exactly the
1283
+ // WRAPPER signature, verified generically before this function is reached. And
1284
+ // what makes the stamp evidence rather than an assertion is the witness, which
1285
+ // is on a chain and not checkable offline. Hence the rollup below: a stamp we
1286
+ // can read but nobody has witnessed holds the bundle at `partial`.
1287
+ //
1288
+ // So the checkable surface here is the stamp's INTERNAL consistency against
1289
+ // the one authority in the bundle — the witnessed bytes:
1290
+ //
1291
+ // 1. `stamp_ref` is SHA-256 of the DECODED bytes (derived, not asserted).
1292
+ // 2. Those bytes parse and re-canonicalize to themselves under JCS.
1293
+ // 3. Their `stamp_version` is the one this body_type pins.
1294
+ // 4. Every sub-tree range accounts for exactly one leaf of the stamp.
1295
+ //
1296
+ // (4) is the one that carries customer weight. R-8's whole per-agent story is
1297
+ // "prove this agent was under evidence over window W", answered by a
1298
+ // contiguous `first_leaf`/`leaf_count` slice of the org tree. Ranges that do
1299
+ // not tile `[0, leaf_count)` describe a partition that does not exist, so a
1300
+ // slice claim resting on them is unfounded — and unlike a bad signature,
1301
+ // nothing else in the stack would ever notice.
1302
+ // ===========================================================================
1303
+
1304
+ /** Lowercase hex, even length, non-empty. The family's serialization rule. */
1305
+ function isLowerHex(s: unknown, nibbles?: number): s is string {
1306
+ if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0) return false;
1307
+ if (nibbles !== undefined && s.length !== nibbles) return false;
1308
+ return /^[0-9a-f]+$/.test(s);
1309
+ }
1310
+
1311
+ function isNonNegInt(n: unknown): n is number {
1312
+ return typeof n === "number" && Number.isInteger(n) && n >= 0;
1313
+ }
1314
+
1315
+ /**
1316
+ * Parse and check one stamp's witnessed bytes.
1317
+ *
1318
+ * Errors accumulate rather than short-circuit wherever a later check is still
1319
+ * meaningful, so a bundle with two independent defects reports two — a
1320
+ * verifier that stops at the first one turns a second fix into a second
1321
+ * round-trip.
1322
+ */
1323
+ async function verifyStampEntry(
1324
+ raw: unknown,
1325
+ seen: Set<string>,
1326
+ ): Promise<StampResult> {
1327
+ const errors: string[] = [];
1328
+ const base: StampResult = {
1329
+ stampRef: "",
1330
+ stampRefOk: false,
1331
+ bodyOk: false,
1332
+ stampVersion: null,
1333
+ intervalStart: null,
1334
+ intervalEnd: null,
1335
+ merkleRoot: null,
1336
+ leafCount: null,
1337
+ subtreesOk: false,
1338
+ subtrees: [],
1339
+ anchorRef: null,
1340
+ ok: false,
1341
+ errors,
1342
+ };
1343
+
1344
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1345
+ errors.push("stamp entry is not an object");
1346
+ return base;
1347
+ }
1348
+ const e = raw as Partial<StampEntry> & Record<string, unknown>;
1349
+
1350
+ // --- the witnessed bytes, which are the only authority in this body -------
1351
+ if (!isLowerHex(e.stamp_bytes)) {
1352
+ errors.push("stamp_bytes is missing or not lowercase hex");
1353
+ return base;
1354
+ }
1355
+ let bytes: Uint8Array;
1356
+ try {
1357
+ bytes = hexToBytes(e.stamp_bytes);
1358
+ } catch (err) {
1359
+ errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
1360
+ return base;
1361
+ }
1362
+
1363
+ // --- the derived identity -------------------------------------------------
1364
+ const derived = await sha256Hex(bytes);
1365
+ if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
1366
+ errors.push("stamp entry missing stamp_ref");
1367
+ } else if (e.stamp_ref.toLowerCase() !== derived) {
1368
+ // The bytes were edited and the label was not — or the label was edited
1369
+ // and the bytes were not. Either way the bundle names a stamp it is not
1370
+ // carrying, and quoting that name to a witness would resolve to something
1371
+ // else entirely.
1372
+ errors.push(
1373
+ `stamp_ref does not match SHA-256(stamp_bytes): claimed=${e.stamp_ref} derived=${derived}`,
1374
+ );
1375
+ } else {
1376
+ base.stampRefOk = true;
1377
+ base.stampRef = derived;
1378
+ if (seen.has(derived)) {
1379
+ errors.push(`duplicate stamp_ref ${derived}`);
1380
+ base.stampRefOk = false;
1381
+ } else {
1382
+ seen.add(derived);
1383
+ }
1384
+ }
1385
+
1386
+ // --- the body itself ------------------------------------------------------
1387
+ let body: StampBody;
1388
+ try {
1389
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
1390
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1391
+ errors.push("stamp body is not a JSON object");
1392
+ return base;
1393
+ }
1394
+ body = parsed as StampBody;
1395
+ // Re-canonicalize and compare. The plane replays these bytes verbatim on
1396
+ // every submit attempt for exactly this reason: a non-canonical body makes
1397
+ // a retry a DIFFERENT submission, and makes every independent re-derivation
1398
+ // of the stamp disagree with the one that was witnessed.
1399
+ if (jcs(body as unknown as Record<string, unknown>) !== new TextDecoder().decode(bytes)) {
1400
+ errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
1401
+ return base;
1402
+ }
1403
+ } catch (err) {
1404
+ errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
1405
+ return base;
1406
+ }
1407
+
1408
+ if (body.stamp_version !== STAMP_BODY_VERSION) {
1409
+ // A `sanning.stamp.trace/v1` body whose inner version is something else is
1410
+ // two answers to one question inside one signature. The body_type is the
1411
+ // reader's dispatch key and it said v1; honouring the other spelling is
1412
+ // how a reader half-parses a shape it does not know.
1413
+ errors.push(
1414
+ `unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
1415
+ `${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
1416
+ );
1417
+ return base;
1418
+ }
1419
+ base.stampVersion = body.stamp_version;
1420
+ base.bodyOk = true;
1421
+
1422
+ for (const f of ["org_ref", "interval_start", "interval_end"] as const) {
1423
+ if (typeof body[f] !== "string" || body[f] === "") {
1424
+ errors.push(`stamp body ${f} is missing or not a non-empty string`);
1425
+ base.bodyOk = false;
1426
+ }
1427
+ }
1428
+ base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
1429
+ base.intervalEnd = typeof body.interval_end === "string" ? body.interval_end : null;
1430
+
1431
+ if (!isLowerHex(body.merkle_root, 64)) {
1432
+ errors.push("stamp body merkle_root is not 64 lowercase hex characters");
1433
+ base.bodyOk = false;
1434
+ } else {
1435
+ base.merkleRoot = body.merkle_root;
1436
+ }
1437
+ if (!isNonNegInt(body.leaf_count)) {
1438
+ errors.push("stamp body leaf_count is not a non-negative integer");
1439
+ base.bodyOk = false;
1440
+ } else {
1441
+ base.leafCount = body.leaf_count;
1442
+ }
1443
+
1444
+ // --- the sub-tree ranges --------------------------------------------------
1445
+ base.subtreesOk = checkSubtrees(body, base, errors);
1446
+
1447
+ base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
1448
+
1449
+ base.ok =
1450
+ base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
1451
+ return base;
1452
+ }
1453
+
1454
+ /**
1455
+ * The range arithmetic (§5.2), and the reason it is a hard check.
1456
+ *
1457
+ * `buildStampTree` orders a stamp's leaves agent-major, which is the only
1458
+ * reason `first_leaf` + `leaf_count` can describe an agent's slice at all: the
1459
+ * ranges partition `[0, leaf_count)` exactly, by construction. A body whose
1460
+ * ranges do not is either not a stamp this stack built or one built wrong, and
1461
+ * in both cases the per-agent claim the ranges exist to support is unfounded.
1462
+ *
1463
+ * The sum is checked BEFORE the tiling and reports its own message, so a body
1464
+ * that is merely short does not get filed under a gap it does not have.
1465
+ */
1466
+ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boolean {
1467
+ const raw = body.subtrees;
1468
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1469
+ errors.push("stamp body subtrees is not an object");
1470
+ return false;
1471
+ }
1472
+
1473
+ let shapeOk = true;
1474
+ const subtrees: StampSubtreeResult[] = [];
1475
+ for (const [ref, v] of Object.entries(raw)) {
1476
+ if (v === null || typeof v !== "object" || Array.isArray(v)) {
1477
+ errors.push(`sub-tree ${ref} is not an object`);
1478
+ shapeOk = false;
1479
+ continue;
1480
+ }
1481
+ const s = v as Partial<StampSubtree>;
1482
+ if (!isNonNegInt(s.first_leaf)) {
1483
+ errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
1484
+ shapeOk = false;
1485
+ }
1486
+ // A zero-leaf sub-tree is not a slice of anything, and admitting one lets
1487
+ // an arbitrary number of empty ranges sit inside a tiling that still adds
1488
+ // up.
1489
+ if (!isNonNegInt(s.leaf_count) || s.leaf_count === 0) {
1490
+ errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
1491
+ shapeOk = false;
1492
+ }
1493
+ if (!isLowerHex(s.root, 64)) {
1494
+ errors.push(`sub-tree ${ref} root is not 64 lowercase hex characters`);
1495
+ shapeOk = false;
1496
+ }
1497
+ if (!shapeOk) continue;
1498
+ subtrees.push({
1499
+ ref,
1500
+ firstLeaf: s.first_leaf as number,
1501
+ leafCount: s.leaf_count as number,
1502
+ root: s.root as string,
1503
+ });
1504
+ }
1505
+ subtrees.sort((a, b) => a.firstLeaf - b.firstLeaf);
1506
+ out.subtrees = subtrees;
1507
+ if (!shapeOk) return false;
1508
+
1509
+ const total = body.leaf_count;
1510
+ if (!isNonNegInt(total)) return false; // already reported by the caller
1511
+
1512
+ const sum = subtrees.reduce((n, s) => n + s.leafCount, 0);
1513
+ if (sum !== total) {
1514
+ errors.push(
1515
+ `sub-tree leaf_count sum ${sum} does not equal the stamp's leaf_count ${total}`,
1516
+ );
1517
+ return false;
1518
+ }
1519
+
1520
+ let next = 0;
1521
+ for (const s of subtrees) {
1522
+ if (s.firstLeaf !== next) {
1523
+ errors.push(
1524
+ `sub-tree ranges do not tile [0, ${total}): expected a range at leaf ` +
1525
+ `${next}, found one at ${s.firstLeaf}`,
1526
+ );
1527
+ return false;
1528
+ }
1529
+ next += s.leafCount;
1530
+ }
1531
+ return true;
1532
+ }
1533
+
1534
+ async function verifyStampTrace(
1535
+ b: EvidenceBundle,
1536
+ specVersionOk: boolean,
1537
+ assertedStatus: string | null,
1538
+ errors: string[],
1539
+ ): Promise<EvidenceBundleResult> {
1540
+ const body = b.body as StampTraceBody;
1541
+ if (!Array.isArray(body?.stamps)) {
1542
+ return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
1543
+ }
1544
+
1545
+ const seen = new Set<string>();
1546
+ const stamps: StampResult[] = [];
1547
+ for (const entry of body.stamps) {
1548
+ stamps.push(await verifyStampEntry(entry, seen));
1549
+ }
1550
+
1551
+ // --- Rollup ----------------------------------------------------------------
1552
+ const anyHardFailure = stamps.some((s) => !s.ok);
1553
+ const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
1554
+
1555
+ let status: EvidenceStatus;
1556
+ if (anyHardFailure) {
1557
+ status = "failed";
1558
+ } else if (stamps.length === 0) {
1559
+ // A bundle asserting nothing. Not a failure — nothing here is wrong — but
1560
+ // it must never read `verified`, which a caller would take as a witness it
1561
+ // does not have.
1562
+ status = "partial";
1563
+ errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
1564
+ } else if (anyUnwitnessed) {
1565
+ // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1566
+ // sealed and unwitnessed: `retained-by-sanning`, which is a statement about
1567
+ // our coverage and never an accusation about their evidence. It is also not
1568
+ // `verified`, because for a stamp the witness is the entire independent
1569
+ // part — unlike an anchor-trace checkpoint, which still carries a producer
1570
+ // signature and an inclusion proof that stand on their own offline.
1571
+ status = "partial";
1572
+ errors.push(
1573
+ "one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
1574
+ );
1575
+ } else {
1576
+ status = "verified";
1577
+ }
1578
+
1579
+ return {
1580
+ status,
1581
+ specVersionOk,
1582
+ bodyHashOk: true,
1583
+ signatureOk: true,
1584
+ bodyType: STAMP_TRACE_BODY_TYPE,
1585
+ onChainChecked: false,
1586
+ checkpoints: [],
1587
+ events: [],
1588
+ assertedStatus,
1589
+ stamps,
1590
+ errors,
1591
+ };
1592
+ }
1593
+
821
1594
  // ===========================================================================
822
1595
  // Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
823
1596
  //
@@ -910,7 +1683,7 @@ export async function disclosedContent(
910
1683
  const traceEvents = resolveTraceEvents(bundle);
911
1684
  if (traceEvents === undefined) {
912
1685
  errors.push(
913
- `no ${ANCHOR_TRACE_BODY_TYPE} body to materialize content from ` +
1686
+ `no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")} body to materialize content from ` +
914
1687
  `(body_type ${JSON.stringify(verification.bodyType)})`,
915
1688
  );
916
1689
  return empty();
@@ -1007,13 +1780,19 @@ export async function disclosedContent(
1007
1780
  // wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
1008
1781
  // recurses into `source_bundle`, so the events line up with its results.
1009
1782
  // `undefined` when there is no anchor-trace body to materialize from.
1010
- function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
1783
+ //
1784
+ // Exported for `chain.ts`, which needs the same descent and must not derive it
1785
+ // again: two answers to "which events is this bundle actually about" inside one
1786
+ // kernel is two implementations of one rule, and the one that drifted would be
1787
+ // the one nobody was looking at. Deliberately NOT re-exported from `index.ts` —
1788
+ // same posture as `side-content.ts`, internal and shared.
1789
+ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
1011
1790
  let cur: unknown = bundle;
1012
1791
  for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
1013
1792
  if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
1014
1793
  const b = cur as EvidenceBundle;
1015
1794
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
1016
- if (bodyType === ANCHOR_TRACE_BODY_TYPE) {
1795
+ if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
1017
1796
  const body = b.body as AnchorTraceBody | null | undefined;
1018
1797
  if (body === null || typeof body !== "object" || !Array.isArray(body.events)) return undefined;
1019
1798
  return body.events;
@@ -1118,14 +1897,8 @@ async function verifyExportBody(
1118
1897
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
1119
1898
  const sourceResult = await verifyEvidenceBundle(inline, options);
1120
1899
 
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
- }
1900
+ // Attestation data_hash binding (§5 6c).
1901
+ const sourceCheckpoints = await indexSourceCheckpoints(inline);
1129
1902
 
1130
1903
  // --- Step 6: embedded attestation records ---------------------------------
1131
1904
  const attestations: AttestationResult[] = [];
@@ -1257,15 +2030,9 @@ export async function recomputeExportVerdict(
1257
2030
  ): Promise<ComposeVerdictResult> {
1258
2031
  const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
1259
2032
 
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
- }
2033
+ // Attestation data_hash binding (§5 6c) — the SAME index construction as
2034
+ // verifyExportBody, deliberately shared rather than restated.
2035
+ const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
1269
2036
 
1270
2037
  const attestations: AttestationResult[] = [];
1271
2038
  for (const rec of attestationRecords) {
@@ -1309,12 +2076,12 @@ export async function recomputeExportVerdict(
1309
2076
  // undetermined-tolerant and never gates.
1310
2077
  async function verifyAttestation(
1311
2078
  rec: AttestationRecord,
1312
- sourceCheckpoints: Map<string, TraceCheckpoint>,
2079
+ sourceCheckpoints: Map<string, AnyTraceCheckpoint>,
1313
2080
  ): Promise<{ result: AttestationResult; malformed?: string }> {
1314
2081
  const errs: string[] = [];
1315
2082
  const result: AttestationResult = {
1316
- checkpointTxId:
1317
- rec && typeof rec.checkpoint_tx_id === "string" ? rec.checkpoint_tx_id : "",
2083
+ checkpointRef:
2084
+ rec && typeof rec.checkpoint_ref === "string" ? rec.checkpoint_ref : "",
1318
2085
  operator: "",
1319
2086
  gateway: null,
1320
2087
  signatureOk: false,
@@ -1392,11 +2159,11 @@ async function verifyAttestation(
1392
2159
  // uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
1393
2160
  // against, §4.2), so the checkpoint's committed content hash is
1394
2161
  // SHA-256(JCS(checkpoint.envelope)).
1395
- const cp = sourceCheckpoints.get(result.checkpointTxId);
2162
+ const cp = sourceCheckpoints.get(result.checkpointRef);
1396
2163
  result.checkpointResolved = cp !== undefined;
1397
2164
  if (!cp) {
1398
2165
  errs.push(
1399
- `attestation checkpoint_tx_id ${JSON.stringify(result.checkpointTxId)} resolves to no source checkpoint`,
2166
+ `attestation checkpoint_ref ${JSON.stringify(result.checkpointRef)} resolves to no source checkpoint`,
1400
2167
  );
1401
2168
  } else {
1402
2169
  try {
@@ -1455,7 +2222,7 @@ function buildVerdictObject(
1455
2222
  ): VerdictObject {
1456
2223
  const attByCheckpoint = new Map<string, VerdictAttestation[]>();
1457
2224
  for (const a of attestations) {
1458
- const list = attByCheckpoint.get(a.checkpointTxId) ?? [];
2225
+ const list = attByCheckpoint.get(a.checkpointRef) ?? [];
1459
2226
  list.push({
1460
2227
  operator: a.operator,
1461
2228
  ...(a.gateway ? { gateway: a.gateway } : {}),
@@ -1465,7 +2232,7 @@ function buildVerdictObject(
1465
2232
  ...(a.level !== null ? { level: a.level } : {}),
1466
2233
  subject_ref_ok: a.subjectRefOk,
1467
2234
  });
1468
- attByCheckpoint.set(a.checkpointTxId, list);
2235
+ attByCheckpoint.set(a.checkpointRef, list);
1469
2236
  }
1470
2237
 
1471
2238
  const events: VerdictEvent[] = source.events.map((e) => ({
@@ -1478,10 +2245,10 @@ function buildVerdictObject(
1478
2245
  }));
1479
2246
 
1480
2247
  const checkpoints: VerdictCheckpoint[] = source.checkpoints.map((c) => ({
1481
- checkpoint_tx_id: c.txId,
2248
+ checkpoint_ref: c.checkpointRef,
1482
2249
  merkle_root_ok: c.merkleRootOk,
1483
2250
  on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
1484
- attestations: attByCheckpoint.get(c.txId) ?? [],
2251
+ attestations: attByCheckpoint.get(c.checkpointRef) ?? [],
1485
2252
  }));
1486
2253
 
1487
2254
  let verified = 0;
@@ -1550,7 +2317,7 @@ function projectVerdictForAgreement(v: unknown): unknown {
1550
2317
  const cp = (c ?? {}) as VerdictCheckpoint;
1551
2318
  const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
1552
2319
  return {
1553
- checkpoint_tx_id: cp.checkpoint_tx_id ?? null,
2320
+ checkpoint_ref: cp.checkpoint_ref ?? null,
1554
2321
  merkle_root_ok: cp.merkle_root_ok ?? null,
1555
2322
  // on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
1556
2323
  attestations: atts.map((a) => {
@@ -1588,7 +2355,8 @@ function summarizeExport(
1588
2355
  // gateways is preserved; the derived collapsed `onChainOk` reproduces the old
1589
2356
  // single-boolean exactly for a single gateway.
1590
2357
  async function refetchCheckpoint(
1591
- cp: TraceCheckpoint,
2358
+ cp: AnyTraceCheckpoint,
2359
+ anchorRef: AnchorRef,
1592
2360
  gateways: string[],
1593
2361
  fetchImpl: typeof fetch,
1594
2362
  ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
@@ -1603,15 +2371,18 @@ async function refetchCheckpoint(
1603
2371
 
1604
2372
  const perGateway: PerGatewayOutcome[] = [];
1605
2373
  for (const gw of gateways) {
1606
- perGateway.push({ gateway: gw, outcome: await probeGateway(cp, gw, expectedHex, fetchImpl) });
2374
+ perGateway.push({
2375
+ gateway: gw,
2376
+ outcome: await probeGateway(anchorRef, gw, expectedHex, fetchImpl),
2377
+ });
1607
2378
  }
1608
2379
 
1609
2380
  const onChain = rollupOnChain(perGateway);
1610
2381
  let error: string | undefined;
1611
2382
  if (onChain.rollup === "mismatch") {
1612
- error = `on-chain bytes at ${cp.tx_id} do not match the bundle's checkpoint envelope`;
2383
+ error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
1613
2384
  } else if (onChain.rollup === "unreachable") {
1614
- error = `checkpoint ${cp.tx_id} unreachable on all gateways`;
2385
+ error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
1615
2386
  }
1616
2387
  return { onChain, unavailable: onChain.rollup === "unreachable", error };
1617
2388
  }
@@ -1619,12 +2390,12 @@ async function refetchCheckpoint(
1619
2390
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
1620
2391
  // (bytes disagree), or unreachable (no response / !ok / network error).
1621
2392
  async function probeGateway(
1622
- cp: TraceCheckpoint,
2393
+ anchorRef: AnchorRef,
1623
2394
  gateway: string,
1624
2395
  expectedHex: string | undefined,
1625
2396
  fetchImpl: typeof fetch,
1626
2397
  ): Promise<OnChainOutcome> {
1627
- const url = `${gateway.replace(/\/+$/, "")}/${cp.tx_id}`;
2398
+ const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
1628
2399
  let resp: Awaited<ReturnType<typeof fetch>>;
1629
2400
  try {
1630
2401
  resp = await fetchImpl(url);
@@ -1691,6 +2462,15 @@ interface DisclosedSelection {
1691
2462
  errors: string[];
1692
2463
  }
1693
2464
 
2465
+ // The side-input presence rule lives in `side-content.ts`, stated ONCE.
2466
+ //
2467
+ // It used to live here as a private `lookupSideContent`, and `grading.ts`
2468
+ // carried a second, laxer answer to the same question (`hasEntry`: own +
2469
+ // enumerable, nothing more). On one and the same content map the grader said
2470
+ // PRESENT while this verifier said nothing was offered, and `gradePack` then
2471
+ // returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
2472
+ // and for what each clause of it cost.
2473
+
1694
2474
  function selectDisclosedBytes(
1695
2475
  ev: TraceEvent,
1696
2476
  eventId: string,
@@ -1706,17 +2486,31 @@ function selectDisclosedBytes(
1706
2486
  }
1707
2487
  }
1708
2488
  let side: Uint8Array | undefined;
1709
- const sideRaw = sideContent?.[eventId];
1710
- if (sideRaw instanceof Uint8Array) {
1711
- side = sideRaw;
1712
- } else if (typeof sideRaw === "string") {
2489
+ const sideEntry: SideEntry = sideContentEntry(sideContent, eventId);
2490
+ if (sideEntry.value instanceof Uint8Array) {
2491
+ side = sideEntry.value;
2492
+ } else if (typeof sideEntry.value === "string") {
1713
2493
  try {
1714
- side = hexToBytes(sideRaw);
2494
+ side = hexToBytes(sideEntry.value);
1715
2495
  } catch (e) {
1716
2496
  errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
1717
2497
  }
1718
2498
  }
1719
- return { inBody, side, offered: ev.content !== undefined || sideRaw !== undefined, errors };
2499
+ // `offered` comes from the SAME test that produced the value, never from
2500
+ // `value !== undefined` — that is the conflation this whole guard exists to
2501
+ // remove, and it was removed from ONE of the two operands. The left one
2502
+ // carried it unchanged: an in-body `"content": null` is `!== undefined`, so
2503
+ // the event was marked offered while no bytes were selected, and the kernel
2504
+ // split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
2505
+ // FAILED, on one bundle. `null` is how an optional field's absence
2506
+ // serializes; `inBodyContentOffered` states that once, beside the side-input
2507
+ // rule it mirrors.
2508
+ return {
2509
+ inBody,
2510
+ side,
2511
+ offered: inBodyContentOffered(ev.content) || sideEntry.offered,
2512
+ errors,
2513
+ };
1720
2514
  }
1721
2515
 
1722
2516
  function toHex(bytes: Uint8Array): string {