@sanning/proof 0.9.2 → 0.10.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
@@ -1,7 +1,7 @@
1
1
  // Evidence-bundle verification (sanning-io/proof specs/evidence-bundle.md).
2
2
  //
3
3
  // A `sanning.evidence/v1` bundle is a SIGNED wrapper around a producer-specific
4
- // `body`. This module verifies the `sanning.anchor.trace/v1` body — the
4
+ // `body`. This module verifies the `sanning.anchor.trace/v3` body, the
5
5
  // @sanning/anchor SDK trace: a self-verifying serialization of InclusionReceipt[]
6
6
  // (per-event signed envelopes + their checkpoints + RFC 9162 inclusion proofs).
7
7
  //
@@ -48,37 +48,73 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
48
48
  // body_type is not a hard failure of the WRAPPER (the signature + body_hash
49
49
  // still verify), but its body cannot be re-derived, so the per-body verdict is
50
50
  // "undetermined" rather than "verified".
51
- export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
51
+ //
52
+ // The producer-to-agent cut (W-3, DL-73) renamed `record_bytes` to
53
+ // `event_bytes` and moved every body that carries it to a new major. This
54
+ // kernel reads ONLY those majors: `sanning.anchor.trace/v1` and `/v2`,
55
+ // `sanning.stamp.evidence/v1` and `sanning.evidence.export/v1` are unknown body
56
+ // types here and answer `partial`. DL-73 releases evidence made before the cut
57
+ // from any obligation to keep verifying, so there is no second field name to
58
+ // read and no dual-accept path.
59
+ export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v3";
52
60
 
53
61
  /**
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.
62
+ * The name `event_bytes` replaced. Under the new majors a body entry that still
63
+ * carries it is a hard reject, never an ignored unknown field: ignored, it
64
+ * would read as a withheld record and the event would verify with nothing
65
+ * checked, the fail-open the new majors exist to prevent (governance.md §8,
66
+ * W-3).
60
67
  */
61
- export const ANCHOR_TRACE_BODY_TYPE_V1 = "sanning.anchor.trace/v1";
68
+ export const LEGACY_EVENT_BYTES_FIELD = "record_bytes";
69
+
70
+ /**
71
+ * Name an envelope the accept-set refused. Without this a bundle whose
72
+ * envelopes carry a retired profile major (a half-migrated producer still
73
+ * writing `sanning.events/v1`) fails with no reason at all. Spelled with
74
+ * `JSON.stringify` so both kernels write the same sentence.
75
+ */
76
+ function specVersionFinding(
77
+ what: "checkpoint" | "event",
78
+ envelope: unknown,
79
+ res: { specVersionOk: boolean },
80
+ ): string | null {
81
+ if (!isJsonObject(envelope) || res.specVersionOk) return null;
82
+ if (!Object.prototype.hasOwnProperty.call(envelope, "spec_version")) {
83
+ return `${what} envelope has no spec_version`;
84
+ }
85
+ const sv = (envelope as Record<string, unknown>).spec_version;
86
+ return `${what} envelope spec_version ${JSON.stringify(sv)} is not accepted by this kernel`;
87
+ }
62
88
 
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;
89
+ /**
90
+ * The reject for an entry that still carries `record_bytes`. One sentence,
91
+ * spelled the same in both kernels, so a negative vector can assert it.
92
+ */
93
+ function legacyEventBytesError(
94
+ entry: object,
95
+ what: "checkpoint" | "event",
96
+ bodyType: string,
97
+ ): string | null {
98
+ if (!Object.prototype.hasOwnProperty.call(entry, LEGACY_EVENT_BYTES_FIELD)) return null;
99
+ return (
100
+ `${what} carries ${LEGACY_EVENT_BYTES_FIELD} in a ${bodyType} body ` +
101
+ "(renamed event_bytes) -- one bundle speaks one vocabulary"
102
+ );
103
+ }
68
104
 
69
105
  // The issuer-composed attested-evidence-export body (specs/evidence-export.md
70
106
  // §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
71
107
  // carries a cached verdict + the inline source bundle + embedded RSA-PSS
72
108
  // operator attestations. verifyEvidenceBundle dispatches on it to
73
109
  // verifyExportBody (§5).
74
- export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
110
+ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v2";
75
111
 
76
112
  /**
77
113
  * The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
78
114
  *
79
115
  * Its own `body_type` rather than a widened anchor-trace body, and the reason
80
116
  * 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 —
117
+ * `sanning.events/v2` envelope, and a stamp has no producer signature at all:
82
118
  * Sanning places it, under DL-52, for a customer who holds no chain identity.
83
119
  * The two rejected options were making `envelope` optional and synthesising an
84
120
  * envelope that Sanning signs; the second would have made a Sanning
@@ -86,7 +122,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
86
122
  * unrecoverable once witnessed.
87
123
  */
88
124
  export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
89
- export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
125
+ export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
90
126
 
91
127
  /**
92
128
  * The version string INSIDE a witnessed stamp body — a different string from
@@ -107,7 +143,6 @@ export interface EvidenceIssuer {
107
143
  kind: string;
108
144
  tenant_id?: string;
109
145
  agent_id?: string;
110
- producer_id?: string;
111
146
  [k: string]: unknown;
112
147
  }
113
148
 
@@ -136,13 +171,12 @@ export interface AnchorRef {
136
171
  ref?: Record<string, unknown>;
137
172
  }
138
173
 
139
- // `sanning.anchor.trace/v2` body (§5.1). Uint8Array fields ride as lowercase hex.
174
+ // `sanning.anchor.trace/v3` body (§5.1). Uint8Array fields ride as lowercase hex.
140
175
  export interface TraceCheckpoint {
141
176
  /**
142
177
  * The checkpoint's identity and the join key, DERIVED: hex SHA-256 of the
143
- * decoded `record_bytes`. Recomputed by the verifier — in v1 this was an
144
- * asserted label matched against another asserted label, so a producer could
145
- * relabel a checkpoint and the bundle still verified.
178
+ * decoded `event_bytes`. Recomputed by the verifier, never taken from the
179
+ * label.
146
180
  */
147
181
  checkpoint_ref: string;
148
182
  /**
@@ -152,32 +186,24 @@ export interface TraceCheckpoint {
152
186
  */
153
187
  anchor_ref?: AnchorRef;
154
188
  envelope: Envelope;
155
- record_bytes: string; // hex of JCS(checkpoint record)
189
+ event_bytes: string; // hex of JCS(checkpoint record)
156
190
  merkle_root: string; // hex
157
191
  }
158
192
 
159
- /** The frozen `sanning.anchor.trace/v1` checkpoint (§5.1.3). Read, never written. */
160
- export interface TraceCheckpointV1 {
161
- tx_id: string;
162
- envelope: Envelope;
163
- record_bytes: string;
164
- merkle_root: string;
165
- }
166
-
167
193
  export interface TraceInclusion {
168
194
  leaf_hash: string; // hex
169
195
  leaf_index: number;
170
196
  leaf_count: number;
171
197
  audit_path: string[]; // hex[]
172
- /** Resolves against `checkpoints[].checkpoint_ref`. v1 spells this `checkpoint_tx_id`. */
198
+ /** Resolves against `checkpoints[].checkpoint_ref`. */
173
199
  checkpoint_ref: string;
174
200
  }
175
201
 
176
202
  export interface TraceEvent {
177
203
  envelope: Envelope;
178
- record_bytes?: string; // hex of JCS(event record); omitted == record withheld
204
+ event_bytes?: string; // hex of JCS(event record); omitted == record withheld
179
205
  // Opt-in disclosed raw bytes (lowercase hex) whose SHA-256 MUST equal the
180
- // committed event.content_hash inside record_bytes (evidence-bundle §5.1).
206
+ // committed event.content_hash inside event_bytes (evidence-bundle §5.1).
181
207
  // Default-absent (minimal disclosure); absent ⇒ undetermined, not failed.
182
208
  content?: string;
183
209
  inclusion: TraceInclusion;
@@ -246,7 +272,7 @@ export interface StampTraceBody {
246
272
  stamps: StampEntry[];
247
273
  }
248
274
 
249
- // ---- `sanning.stamp.evidence/v1` wire types (evidence-bundle.md §5.3) -----
275
+ // ---- `sanning.stamp.evidence/v2` wire types (evidence-bundle.md §5.3) -----
250
276
  //
251
277
  // Both halves of a DL-52 record, bound: the producer signs the envelope, and
252
278
  // Sanning signs and witnesses the stamp whose root commits to it. Neither
@@ -266,7 +292,7 @@ export interface StampEvidenceInclusion {
266
292
 
267
293
  export interface StampEvidenceEvent {
268
294
  envelope: Envelope;
269
- record_bytes?: string; // hex of JCS(event record); omitted == withheld
295
+ event_bytes?: string; // hex of JCS(event record); omitted == withheld
270
296
  content?: string; // opt-in disclosed raw bytes, lowercase hex
271
297
  inclusion: StampEvidenceInclusion;
272
298
  }
@@ -327,23 +353,12 @@ export interface OnChainResult {
327
353
  }
328
354
 
329
355
  export interface CheckpointResult {
330
- /**
331
- * The checkpoint's join key AS THE BUNDLE SPELLS IT — the derived
332
- * `checkpoint_ref` for a v2 body, the legacy `tx_id` for a frozen v1 body.
333
- * Never re-derived across forms: a v1 bundle's key is its `tx_id`, because
334
- * that is what its own events resolve against and what an attestation
335
- * written against it carries.
336
- */
356
+ /** The derived `checkpoint_ref`, empty when it could not be derived. */
337
357
  checkpointRef: string;
338
358
  /**
339
- * Where this checkpoint was witnessed, normalised to one shape.
340
- *
341
- * `null` when a v2 checkpoint carries no `anchor_ref` — placed but not yet
342
- * witnessed, which is a normal state and NOT a deficiency. For a frozen v1
343
- * checkpoint this is synthesised as `{ kind: "arweave", id: tx_id }`, which
344
- * is exactly what v1's spec said the field was ("the Arweave TX the
345
- * checkpoint envelope was anchored as"). The synthesis is reader-side only
346
- * and never re-enters a signed artifact or a verdict.
359
+ * Where this checkpoint was witnessed. `null` when the checkpoint carries no
360
+ * `anchor_ref`: placed but not yet witnessed, which is a normal state and NOT
361
+ * a deficiency.
347
362
  */
348
363
  anchorRef: AnchorRef | null;
349
364
  // The checkpoint envelope verified (signature + payload binding to its record).
@@ -351,11 +366,15 @@ export interface CheckpointResult {
351
366
  // The committed record's merkle_root equals the checkpoint's claimed merkle_root.
352
367
  merkleRootOk: boolean;
353
368
  // If gateways were supplied: the on-chain bytes at tx_id matched the envelope.
354
- // null when no gateway re-fetch was requested. Derived collapse of `onChain`
355
- // (§4.2), retained so existing consumers keep the single-boolean field.
369
+ // null when no gateway re-fetch was requested, or when `anchor_ref.kind` has
370
+ // no witness adapter, or its id is not one the adapter accepts; the witness
371
+ // was not checked. Derived collapse of `onChain` (§4.2), retained so existing
372
+ // consumers keep the single-boolean field.
356
373
  onChainOk: boolean | null;
357
374
  // Per-gateway on-chain outcomes (§4.2), ADDITIVE to `onChainOk`. null when no
358
- // gateway re-fetch was requested (offline-only verification).
375
+ // gateway re-fetch was requested (offline-only verification), or when
376
+ // `anchor_ref.kind` has no witness adapter, or its id is not one the adapter
377
+ // accepts; the witness was not checked.
359
378
  onChain: OnChainResult | null;
360
379
  ok: boolean;
361
380
  errors: string[];
@@ -373,7 +392,7 @@ export interface EventResult {
373
392
  // The inclusion's checkpoint_ref resolved to a checkpoint in the bundle.
374
393
  checkpointBound: boolean;
375
394
  // Raw-log (content) binding: SHA-256 of the disclosed bytes equals the
376
- // committed event.content_hash (in record_bytes) — or, for a promoted
395
+ // committed event.content_hash (in event_bytes), or, for a promoted
377
396
  // envelope, a hash it commits to. true matched, false mismatch (→ event
378
397
  // fails), null undetermined (nothing disclosed OR nothing committed OR record
379
398
  // withheld — NOT a failure, mirroring payloadBindingOk).
@@ -435,12 +454,14 @@ export interface StampResult {
435
454
  * ── The four states, and why none of them collapses ──
436
455
  *
437
456
  * `null` means not checked — no gateways were supplied, or this stamp names
438
- * no witness to check. It is a statement about the RUN, never about the
439
- * stamp. The other three are statements about the stamp and live in
440
- * `onChain.rollup`: `confirm` (a gateway returned the witnessed bytes),
441
- * `mismatch` (a gateway returned DIFFERENT bytes — a finding, and the only
442
- * one of the four that fails anything), `unreachable` (we asked and nobody
443
- * answered — an availability gap, never an accusation).
457
+ * no witness to check, or its `anchor_ref.kind` has no witness adapter, or
458
+ * its id is not one the adapter accepts; the witness was not checked. It is
459
+ * a statement about the RUN, never about the stamp. The other three are
460
+ * statements about the stamp and live in `onChain.rollup`: `confirm` (a
461
+ * gateway returned the witnessed bytes), `mismatch` (a gateway returned
462
+ * DIFFERENT bytes: a finding, and the only one of the four that fails
463
+ * anything), `unreachable` (we asked and nobody answered: an availability
464
+ * gap, never an accusation).
444
465
  *
445
466
  * Keeping "not checked" distinct from "unreachable" is the whole point. A
446
467
  * surface handed a collapsed boolean cannot tell "no gateway could be
@@ -474,7 +495,7 @@ export interface EvidenceBundleResult {
474
495
  events: EventResult[];
475
496
  // The producer's asserted verdict.status (for display only — never trusted).
476
497
  assertedStatus: string | null;
477
- // Present ONLY for a `sanning.evidence.export/v1` body (evidence-export.md §5):
498
+ // Present ONLY for a `sanning.evidence.export/v2` body (evidence-export.md §5):
478
499
  // the export-specific dimensions (source linkage, verdict agreement, embedded
479
500
  // attestations, recomputed §4 verdict). The top-level `checkpoints`/`events`
480
501
  // above are the recomputed SOURCE-bundle results in that case. Absent for a
@@ -494,11 +515,7 @@ export interface EvidenceBundleResult {
494
515
  // One embedded operator attestation record (§2.2 / §3). RSA-PSS-SHA-256 over
495
516
  // JCS(payload); the operator key is the operator's Arweave RSA wallet.
496
517
  export interface AttestationRecord {
497
- /**
498
- * The source checkpoint this attestation binds to, spelled in the SOURCE
499
- * bundle's vocabulary: `checkpoint_ref` for a v2 source, the legacy `tx_id`
500
- * for a frozen v1 one. Not re-derived across forms.
501
- */
518
+ /** The source checkpoint's derived `checkpoint_ref` this attestation binds to. */
502
519
  checkpoint_ref: string;
503
520
  payload: AttestationPayload;
504
521
  signature_alg: string; // "rsa-pss-sha256"
@@ -689,7 +706,7 @@ function jsonStr(value: unknown): string {
689
706
  return JSON.stringify(value === undefined ? null : value) ?? "null";
690
707
  }
691
708
 
692
- // Verify a `sanning.evidence/v1` bundle carrying a `sanning.anchor.trace/v1` body.
709
+ // Verify a `sanning.evidence/v1` bundle and dispatch on its `body_type`.
693
710
  //
694
711
  // Returns a structured per-checkpoint + per-event result and a recomputed
695
712
  // rollup `status`. The producer's asserted `verdict.status` is surfaced
@@ -698,6 +715,16 @@ function jsonStr(value: unknown): string {
698
715
  export async function verifyEvidenceBundle(
699
716
  bundle: unknown,
700
717
  options: VerifyEvidenceOptions = {},
718
+ ): Promise<EvidenceBundleResult> {
719
+ return verifyBundle(bundle, options, false);
720
+ }
721
+
722
+ // `exportSource`: read the bundle as an export's source (evidence-export.md
723
+ // §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
724
+ async function verifyBundle(
725
+ bundle: unknown,
726
+ options: VerifyEvidenceOptions,
727
+ exportSource: boolean,
701
728
  ): Promise<EvidenceBundleResult> {
702
729
  // --- Step 1: parse + reject unknown spec_version major ---------------------
703
730
  if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
@@ -781,6 +808,28 @@ export async function verifyEvidenceBundle(
781
808
  // --- Body dispatch ---------------------------------------------------------
782
809
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
783
810
 
811
+ // Read as an export's source (evidence-export.md §2.4), the only body this
812
+ // kernel recognises is sanning.anchor.trace/v3. Any other body, even one it
813
+ // verifies on its own, is undetermined here, after the wrapper checks above.
814
+ if (exportSource && bodyType !== ANCHOR_TRACE_BODY_TYPE) {
815
+ errors.push(
816
+ `body_type ${JSON.stringify(bodyType)} is not an export source ` +
817
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
818
+ );
819
+ return {
820
+ status: "partial",
821
+ specVersionOk,
822
+ bodyHashOk,
823
+ signatureOk,
824
+ bodyType,
825
+ onChainChecked: false,
826
+ checkpoints: [],
827
+ events: [],
828
+ assertedStatus,
829
+ errors,
830
+ };
831
+ }
832
+
784
833
  // Attested-evidence-export body (evidence-export.md §5). The wrapper is
785
834
  // authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
786
835
  // hand off to the export verifier for steps 3–9.
@@ -800,13 +849,13 @@ export async function verifyEvidenceBundle(
800
849
  return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
801
850
  }
802
851
 
803
- if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
852
+ if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
804
853
  // The wrapper is authentic but we don't know this body's structure. Per
805
854
  // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
806
855
  // body verdict undetermined) — not a failure.
807
856
  errors.push(
808
857
  `body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
809
- `(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`,
858
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
810
859
  );
811
860
  return {
812
861
  status: "partial",
@@ -822,108 +871,101 @@ export async function verifyEvidenceBundle(
822
871
  };
823
872
  }
824
873
 
825
- return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
874
+ return verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options);
826
875
  }
827
876
 
828
877
  /**
829
- * Index a source bundle's checkpoints by the key its attestations will use.
878
+ * Recompute an export's source verdict (evidence-export.md §5 step 4).
830
879
  *
831
- * The attestation join key follows the SOURCE bundle's own vocabulary — a
832
- * v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
833
- * alternative (always deriving a v2-style key) would silently un-resolve every
834
- * attestation ever written against a v1 export: `data_hash_bound` would go
835
- * false, the export would stop reaching `verified`, and nothing would say why.
880
+ * The only export source is `sanning.anchor.trace/v3` (§2.2, §2.4). Any other
881
+ * inline source body is unrecognised *as a source*, even one this kernel
882
+ * verifies on its own, such as a `sanning.stamp.evidence/v2` pack: §2.4 makes
883
+ * its source verdict undetermined, so the export cannot reach `verified`. The
884
+ * source wrapper is still checked first, because a forged source is a failure
885
+ * whatever its body.
886
+ */
887
+ async function verifyExportSource(
888
+ source: unknown,
889
+ options: VerifyEvidenceOptions,
890
+ ): Promise<EvidenceBundleResult> {
891
+ return verifyBundle(source, options, true);
892
+ }
893
+
894
+ /**
895
+ * Index a source bundle's checkpoints by the key its attestations use: the
896
+ * derived `checkpoint_ref`. Only a `sanning.anchor.trace/v3` source has
897
+ * checkpoints an attestation can resolve (evidence-export.md §2.4).
836
898
  */
837
899
  async function indexSourceCheckpoints(
838
900
  bundle: EvidenceBundle,
839
901
  ): Promise<Map<string, AnyTraceCheckpoint>> {
840
902
  const out = new Map<string, AnyTraceCheckpoint>();
903
+ if (!isJsonObject(bundle) || bundle.body_type !== ANCHOR_TRACE_BODY_TYPE) return out;
841
904
  const sb = bundle.body as AnchorTraceBody | undefined;
842
- const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
843
905
  if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints)) return out;
844
906
  for (const cp of sb.checkpoints as AnyTraceCheckpoint[]) {
845
907
  if (!cp || typeof cp !== "object") continue;
846
- let recordBytes: Uint8Array | undefined;
908
+ let eventBytes: Uint8Array | undefined;
847
909
  try {
848
- if (typeof cp.record_bytes === "string") recordBytes = hexToBytes(cp.record_bytes);
910
+ if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
849
911
  } catch {
850
- recordBytes = undefined;
912
+ eventBytes = undefined;
851
913
  }
852
- const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
914
+ const keyed = await resolveCheckpointKey(cp, eventBytes);
853
915
  if (keyed.key !== null && !out.has(keyed.key)) out.set(keyed.key, cp);
854
916
  }
855
917
  return out;
856
918
  }
857
919
 
858
- /** Either shape of checkpoint, before the form is resolved. */
859
- type AnyTraceCheckpoint = Partial<TraceCheckpoint> & Partial<TraceCheckpointV1>;
920
+ /** A checkpoint as it arrives, before any field is trusted. */
921
+ type AnyTraceCheckpoint = Partial<TraceCheckpoint> & { tx_id?: unknown };
860
922
 
861
923
  /**
862
- * Resolve a checkpoint's join key and its witness reference, for whichever
863
- * body form we are reading.
924
+ * Resolve a checkpoint's join key and its witness reference.
864
925
  *
865
- * ── The rule that matters, and why it is a reject rather than a preference ──
926
+ * ── Why a retired spelling is a reject rather than a preference ──
866
927
  *
867
- * A checkpoint carrying BOTH vocabularies is two answers to one question
868
- * inside one signature. A reader that silently prefers one gives a different
869
- * answer from a reader that prefers the other, over the same signed bytes —
870
- * and both look like they verified. So a v2 body carrying `tx_id`, or a v1
871
- * body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
872
- * resolved. This is cheap to enforce here and impossible to enforce later.
928
+ * A checkpoint carrying a retired spelling beside the current one is two
929
+ * answers to one question inside one signature. A reader that silently prefers
930
+ * one gives a different answer from a reader that prefers the other, over the
931
+ * same signed bytes, and both look like they verified. So `tx_id` and
932
+ * `record_bytes` are rejected rather than resolved.
873
933
  *
874
- * ── Why v2's key is recomputed ──
934
+ * ── Why the key is recomputed ──
875
935
  *
876
- * In v1 the key was an asserted label matched against another asserted label;
877
- * nothing tied either to the bytes, so relabelling a checkpoint and every
878
- * event pointing at it left a bundle that still verified. In v2 the key IS the
879
- * bytes, so the same relabel fails here. v1's behaviour is preserved exactly
880
- * as it was — deliberately, because those bundles are already signed and
881
- * re-interpreting them is not backwards compatibility.
936
+ * The key IS the bytes: relabelling a checkpoint and every event pointing at it
937
+ * fails here, because nothing is taken from the label.
882
938
  */
883
939
  async function resolveCheckpointKey(
884
940
  cp: AnyTraceCheckpoint,
885
- recordBytes: Uint8Array | undefined,
886
- isV1: boolean,
941
+ eventBytes: Uint8Array | undefined,
887
942
  ): Promise<{ key: string | null; anchorRef: AnchorRef | null; errors: string[] }> {
888
943
  const errors: string[] = [];
889
944
 
890
- if (isV1) {
891
- if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
892
- errors.push(
893
- "sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) -- " +
894
- "one bundle speaks one vocabulary",
895
- );
896
- return { key: null, anchorRef: null, errors };
897
- }
898
- if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
899
- errors.push("checkpoint missing tx_id");
900
- return { key: null, anchorRef: null, errors };
901
- }
902
- // v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
903
- // through the same shape as v2 gives ONE reader-side vocabulary without
904
- // touching a byte of the signed bundle.
905
- return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
906
- }
907
-
908
945
  if (cp.tx_id !== undefined) {
909
946
  errors.push(
910
- "sanning.anchor.trace/v2 checkpoint carries legacy tx_id -- one bundle speaks one vocabulary",
947
+ `${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`,
911
948
  );
912
949
  return { key: null, anchorRef: null, errors };
913
950
  }
951
+ const legacy = legacyEventBytesError(cp, "checkpoint", ANCHOR_TRACE_BODY_TYPE);
952
+ if (legacy !== null) {
953
+ errors.push(legacy);
954
+ return { key: null, anchorRef: null, errors };
955
+ }
914
956
  if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
915
957
  errors.push("checkpoint missing checkpoint_ref");
916
958
  return { key: null, anchorRef: null, errors };
917
959
  }
918
- if (recordBytes === undefined) {
960
+ if (eventBytes === undefined) {
919
961
  // Without the bytes the key cannot be recomputed, so it cannot be trusted.
920
- errors.push("checkpoint_ref cannot be verified -- record_bytes missing or not hex");
962
+ errors.push("checkpoint_ref cannot be verified -- event_bytes missing or not hex");
921
963
  return { key: null, anchorRef: null, errors };
922
964
  }
923
- const derived = await sha256Hex(recordBytes);
965
+ const derived = await sha256Hex(eventBytes);
924
966
  if (derived !== cp.checkpoint_ref.toLowerCase()) {
925
967
  errors.push(
926
- `checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`,
968
+ `checkpoint_ref does not match SHA-256(event_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`,
927
969
  );
928
970
  return { key: null, anchorRef: null, errors };
929
971
  }
@@ -979,14 +1021,13 @@ function parseAnchorRef(raw: unknown, errors: string[]): AnchorRef | null {
979
1021
 
980
1022
  async function verifyAnchorTrace(
981
1023
  b: EvidenceBundle,
982
- bodyType: string,
983
1024
  specVersionOk: boolean,
984
1025
  assertedStatus: string | null,
985
1026
  errors: string[],
986
1027
  options: VerifyEvidenceOptions,
987
1028
  ): Promise<EvidenceBundleResult> {
988
1029
  const body = b.body as AnchorTraceBody;
989
- const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
1030
+ const bodyType = ANCHOR_TRACE_BODY_TYPE;
990
1031
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
991
1032
  return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
992
1033
  }
@@ -1032,16 +1073,16 @@ async function verifyAnchorTrace(
1032
1073
  let envelopeOk = false;
1033
1074
  let merkleRootOk = false;
1034
1075
 
1035
- // Decode record_bytes first: in v2 the join key is DERIVED from it, so it
1076
+ // Decode event_bytes first: in v2 the join key is DERIVED from it, so it
1036
1077
  // is an input to identity, not just to the payload binding below.
1037
- let recordBytes: Uint8Array | undefined;
1078
+ let eventBytes: Uint8Array | undefined;
1038
1079
  try {
1039
- if (typeof cp.record_bytes === "string") recordBytes = hexToBytes(cp.record_bytes);
1080
+ if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
1040
1081
  } catch (e) {
1041
- cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
1082
+ cpErrors.push(`checkpoint event_bytes is not hex: ${stringifyErr(e)}`);
1042
1083
  }
1043
1084
 
1044
- const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
1085
+ const keyed = await resolveCheckpointKey(cp, eventBytes);
1045
1086
  cpErrors.push(...keyed.errors);
1046
1087
  // A duplicate key is a HARD failure, not a note. `byKey` below keeps the
1047
1088
  // FIRST occurrence, so a second checkpoint claiming the same key is
@@ -1055,7 +1096,7 @@ async function verifyAnchorTrace(
1055
1096
  let duplicateKey = false;
1056
1097
  if (keyed.key !== null) {
1057
1098
  if (byKey.has(keyed.key)) {
1058
- cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
1099
+ cpErrors.push(`duplicate checkpoint checkpoint_ref ${keyed.key}`);
1059
1100
  duplicateKey = true;
1060
1101
  } else {
1061
1102
  byKey.set(keyed.key, cp);
@@ -1068,7 +1109,7 @@ async function verifyAnchorTrace(
1068
1109
  try {
1069
1110
  const res = await verifyEnvelope(
1070
1111
  cp.envelope as Envelope,
1071
- recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
1112
+ eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1072
1113
  );
1073
1114
  // A checkpoint must fully bind: signature + the committed record matching
1074
1115
  // payload_hash. An undetermined binding (no record) is not enough to
@@ -1085,15 +1126,17 @@ async function verifyAnchorTrace(
1085
1126
  // proof#95: a malformed skeleton field fails the envelope with a valid
1086
1127
  // signature and binding; name it rather than fail with no reason.
1087
1128
  for (const m of res.errors) if (m.startsWith("malformed ")) cpErrors.push(`checkpoint envelope ${m}`);
1129
+ const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
1130
+ if (specFinding !== null) cpErrors.push(specFinding);
1088
1131
  // WHY the binding is undetermined decides which sentence is true.
1089
- // record_bytes really absent is one thing; record_bytes present and
1132
+ // event_bytes really absent is one thing; event_bytes present and
1090
1133
  // unbindable (a degenerate envelope, unreadable hex) is another, and
1091
1134
  // naming the wrong cause sends a reader to look for a field that is
1092
1135
  // sitting right there (proof#76).
1093
1136
  if (res.payloadHashOk === null) {
1094
1137
  cpErrors.push(
1095
- recordBytes === undefined
1096
- ? "checkpoint record_bytes missing -- root untrusted"
1138
+ eventBytes === undefined
1139
+ ? "checkpoint event_bytes missing -- root untrusted"
1097
1140
  : "checkpoint record binding not checked: root untrusted",
1098
1141
  );
1099
1142
  }
@@ -1103,9 +1146,9 @@ async function verifyAnchorTrace(
1103
1146
 
1104
1147
  // The committed record's merkle_root must equal the checkpoint's claimed
1105
1148
  // merkle_root (recompute, don't trust the bundle's top-level claim).
1106
- if (recordBytes !== undefined) {
1149
+ if (eventBytes !== undefined) {
1107
1150
  try {
1108
- const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
1151
+ const record = JSON.parse(new TextDecoder().decode(eventBytes)) as {
1109
1152
  event?: { merkle_root?: unknown };
1110
1153
  };
1111
1154
  const committedRoot = record.event?.merkle_root;
@@ -1164,16 +1207,21 @@ async function verifyAnchorTrace(
1164
1207
  let envelopeOk = false;
1165
1208
  let payloadBindingOk: boolean | null = null;
1166
1209
 
1167
- let recordBytes: Uint8Array | undefined;
1210
+ // A retired field name under this major is a reject, never an ignored
1211
+ // unknown field: ignored, it reads as a withheld record (W-3).
1212
+ const legacyError = legacyEventBytesError(ev, "event", bodyType);
1213
+ if (legacyError !== null) evErrors.push(legacyError);
1214
+
1215
+ let eventBytes: Uint8Array | undefined;
1168
1216
  try {
1169
- if (typeof ev.record_bytes === "string") recordBytes = hexToBytes(ev.record_bytes);
1217
+ if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
1170
1218
  } catch (e) {
1171
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1219
+ evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
1172
1220
  }
1173
1221
  try {
1174
1222
  const res = await verifyEnvelope(
1175
1223
  ev.envelope,
1176
- recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
1224
+ eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1177
1225
  );
1178
1226
  envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
1179
1227
  payloadBindingOk = res.payloadHashOk;
@@ -1193,6 +1241,8 @@ async function verifyAnchorTrace(
1193
1241
  // A malformed skeleton field fails the envelope with a valid signature
1194
1242
  // and binding (proof#95), so without this the event fails with no reason.
1195
1243
  for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
1244
+ const specFinding = specVersionFinding("event", ev.envelope, res);
1245
+ if (specFinding !== null) evErrors.push(specFinding);
1196
1246
  } catch (e) {
1197
1247
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1198
1248
  }
@@ -1200,12 +1250,11 @@ async function verifyAnchorTrace(
1200
1250
  // Bind the event to exactly one checkpoint in the bundle, then verify
1201
1251
  // RFC 9162 inclusion against THAT checkpoint's merkle_root.
1202
1252
  const incl = ev.inclusion as (TraceInclusion & { checkpoint_tx_id?: unknown }) | undefined;
1203
- // The join key is spelled differently in the two forms and MUST NOT be read
1204
- // from the other one: an inclusion carrying v1's spelling inside a v2 body
1205
- // is a mixed bundle, and resolving it anyway is how an event silently binds
1206
- // to a checkpoint nobody meant.
1207
- const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
1208
- const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
1253
+ // An inclusion carrying the retired `checkpoint_tx_id` spelling is a mixed
1254
+ // bundle, and resolving it anyway is how an event silently binds to a
1255
+ // checkpoint nobody meant.
1256
+ const keyField = "checkpoint_ref";
1257
+ const wrongField = "checkpoint_tx_id";
1209
1258
  const inclAsRecord = incl as unknown as Record<string, unknown> | undefined;
1210
1259
  const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
1211
1260
  const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
@@ -1263,7 +1312,7 @@ async function verifyAnchorTrace(
1263
1312
  }
1264
1313
 
1265
1314
  // Content (raw-log) binding — shared with every other event-carrying body.
1266
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1315
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
1267
1316
  const contentOk = contentRes.contentOk;
1268
1317
  evErrors.push(...contentRes.errors);
1269
1318
 
@@ -1279,7 +1328,7 @@ async function verifyAnchorTrace(
1279
1328
  // present checkpoint, and its disclosed content did not MISMATCH. A
1280
1329
  // withheld record or undisclosed content (binding/content null) does not
1281
1330
  // fail it.
1282
- ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false,
1331
+ ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false && legacyError === null,
1283
1332
  errors: evErrors,
1284
1333
  });
1285
1334
  }
@@ -1287,6 +1336,8 @@ async function verifyAnchorTrace(
1287
1336
  // --- Step 5: optional on-chain re-fetch ------------------------------------
1288
1337
  const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
1289
1338
  let gatewayUnavailable = false;
1339
+ let uncheckedKind = false;
1340
+ let uncheckedId = false;
1290
1341
  if (onChainChecked) {
1291
1342
  if (typeof fetchImpl !== "function") {
1292
1343
  gatewayUnavailable = true;
@@ -1314,8 +1365,10 @@ async function verifyAnchorTrace(
1314
1365
  }
1315
1366
  const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
1316
1367
  cpr.onChain = outcome.onChain;
1317
- cpr.onChainOk = outcome.onChain.onChainOk;
1368
+ cpr.onChainOk = outcome.onChain?.onChainOk ?? null;
1318
1369
  if (outcome.unavailable) gatewayUnavailable = true;
1370
+ if (outcome.unchecked === "kind") uncheckedKind = true;
1371
+ if (outcome.unchecked === "id") uncheckedId = true;
1319
1372
  if (outcome.error) cpr.errors.push(outcome.error);
1320
1373
  // Fold the on-chain dimension in: a MISMATCH (false) fails the
1321
1374
  // checkpoint — the proof was not anchored as claimed. An UNREACHABLE
@@ -1347,12 +1400,16 @@ async function verifyAnchorTrace(
1347
1400
  let status: EvidenceStatus;
1348
1401
  if (anyHardFailure) {
1349
1402
  status = "failed";
1350
- } else if (gatewayUnavailable) {
1403
+ } else if (gatewayUnavailable || uncheckedKind || uncheckedId) {
1351
1404
  // Every offline check passed and nothing MISMATCHED, but a requested
1352
1405
  // on-chain confirmation could not be made — the bundle is offline-sound,
1353
1406
  // its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
1354
1407
  status = "partial";
1355
- errors.push("one or more checkpoints could not be re-fetched from the supplied gateways");
1408
+ pushRefetchGapNotes(errors, "checkpoints", {
1409
+ unavailable: gatewayUnavailable,
1410
+ kind: uncheckedKind,
1411
+ id: uncheckedId,
1412
+ });
1356
1413
  } else if (anyWithheldRecord) {
1357
1414
  // Everything that COULD be checked passed; some record bytes were withheld
1358
1415
  // so their semantic binding is undetermined (not a failure) — "partial".
@@ -1444,7 +1501,7 @@ function isNonNegInt(n: unknown): n is number {
1444
1501
  // ===========================================================================
1445
1502
  // Disclosed-content binding, shared by every body that carries events.
1446
1503
  //
1447
- // Extracted for `sanning.stamp.evidence/v1` (§5.3) rather than copied. The
1504
+ // Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
1448
1505
  // rawLog -> content_hash link is the one step the chain
1449
1506
  // (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
1450
1507
  // not otherwise cover, and a second copy of it is a second place for the
@@ -1456,7 +1513,7 @@ function isNonNegInt(n: unknown): n is number {
1456
1513
  async function verifyDisclosedContent(
1457
1514
  ev: { envelope?: unknown; content?: unknown },
1458
1515
  eventId: string,
1459
- recordBytes: Uint8Array | undefined,
1516
+ eventBytes: Uint8Array | undefined,
1460
1517
  sideContent: VerifyEvidenceOptions["content"],
1461
1518
  ): Promise<{ contentOk: boolean | null; errors: string[] }> {
1462
1519
  const evErrors: string[] = [];
@@ -1464,9 +1521,9 @@ async function verifyDisclosedContent(
1464
1521
 
1465
1522
  const committed: string[] = [];
1466
1523
  let recordContentHash: string | undefined;
1467
- if (recordBytes !== undefined) {
1524
+ if (eventBytes !== undefined) {
1468
1525
  try {
1469
- const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
1526
+ const record = JSON.parse(new TextDecoder().decode(eventBytes)) as {
1470
1527
  event?: { content_hash?: unknown };
1471
1528
  };
1472
1529
  const ch = record.event?.content_hash;
@@ -1781,7 +1838,7 @@ async function verifyStampTrace(
1781
1838
  // other — asks every consumer to remember which wrapper it is holding before
1782
1839
  // it can trust `onChain: null`, and that is exactly the kind of per-body
1783
1840
  // exception a reader eventually gets wrong.
1784
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(
1841
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
1785
1842
  stamps,
1786
1843
  witnessed,
1787
1844
  options,
@@ -1801,13 +1858,15 @@ async function verifyStampTrace(
1801
1858
  // does not have.
1802
1859
  status = "partial";
1803
1860
  errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
1804
- } else if (gatewayUnavailable || anyUnwitnessed) {
1861
+ } else if (gatewayUnavailable || uncheckedKind || uncheckedId || anyUnwitnessed) {
1805
1862
  // Both hold the bundle at `partial`, and both get said — see §5.3's rollup
1806
1863
  // for why one note is not enough when there are two different gaps.
1807
1864
  status = "partial";
1808
- if (gatewayUnavailable) {
1809
- errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1810
- }
1865
+ pushRefetchGapNotes(errors, "stamps", {
1866
+ unavailable: gatewayUnavailable,
1867
+ kind: uncheckedKind,
1868
+ id: uncheckedId,
1869
+ });
1811
1870
  if (anyUnwitnessed) {
1812
1871
  // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1813
1872
  // sealed and unwitnessed: `retained-by-sanning`, which is a statement
@@ -1840,7 +1899,7 @@ async function verifyStampTrace(
1840
1899
  }
1841
1900
 
1842
1901
  // ===========================================================================
1843
- // `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
1902
+ // `sanning.stamp.evidence/v2`: the two halves, bound (evidence-bundle.md §5.3)
1844
1903
  //
1845
1904
  // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
1846
1905
  // the sub-tree partition check). The event half reuses the same primitives the
@@ -1918,16 +1977,21 @@ async function verifyStampEvidence(
1918
1977
  let envelopeOk = false;
1919
1978
  let payloadBindingOk: boolean | null = null;
1920
1979
 
1921
- let recordBytes: Uint8Array | undefined;
1980
+ // A retired field name under this major is a reject, never an ignored
1981
+ // unknown field: ignored, it reads as a withheld record (W-3).
1982
+ const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
1983
+ if (legacyError !== null) evErrors.push(legacyError);
1984
+
1985
+ let eventBytes: Uint8Array | undefined;
1922
1986
  try {
1923
- if (typeof ev.record_bytes === "string") recordBytes = hexToBytes(ev.record_bytes);
1987
+ if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
1924
1988
  } catch (e) {
1925
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1989
+ evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
1926
1990
  }
1927
1991
  try {
1928
1992
  const res = await verifyEnvelope(
1929
1993
  ev.envelope,
1930
- recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
1994
+ eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1931
1995
  );
1932
1996
  envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1933
1997
  payloadBindingOk = res.payloadHashOk;
@@ -1947,6 +2011,8 @@ async function verifyStampEvidence(
1947
2011
  // A malformed skeleton field fails the envelope with a valid signature
1948
2012
  // and binding (proof#95), so without this the event fails with no reason.
1949
2013
  for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
2014
+ const specFinding = specVersionFinding("event", ev.envelope, res);
2015
+ if (specFinding !== null) evErrors.push(specFinding);
1950
2016
  } catch (e) {
1951
2017
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1952
2018
  }
@@ -2016,7 +2082,7 @@ async function verifyStampEvidence(
2016
2082
  }
2017
2083
  }
2018
2084
 
2019
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
2085
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
2020
2086
  const contentOk = contentRes.contentOk;
2021
2087
  evErrors.push(...contentRes.errors);
2022
2088
 
@@ -2028,13 +2094,13 @@ async function verifyStampEvidence(
2028
2094
  // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
2029
2095
  checkpointBound: stampBound,
2030
2096
  contentOk,
2031
- ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
2097
+ ok: envelopeOk && inclusionOk && stampBound && contentOk !== false && legacyError === null,
2032
2098
  errors: evErrors,
2033
2099
  });
2034
2100
  }
2035
2101
 
2036
2102
  // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
2037
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(
2103
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
2038
2104
  stamps,
2039
2105
  witnessed,
2040
2106
  options,
@@ -2054,16 +2120,31 @@ async function verifyStampEvidence(
2054
2120
  } else if (stamps.length === 0 && eventResults.length === 0) {
2055
2121
  status = "partial";
2056
2122
  errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
2057
- } else if (gatewayUnavailable || anyUnwitnessed) {
2058
- // Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
2059
- // witnessed and a witness we could not reach are different gaps with
2060
- // different fixes — one waits for the interval, the other for the network —
2061
- // and an auditor told only about the second would read the first as fine.
2062
- // The order is fixed so the two kernels emit the same list.
2123
+ } else if (
2124
+ eventResults.length === 0 ||
2125
+ gatewayUnavailable ||
2126
+ uncheckedKind ||
2127
+ uncheckedId ||
2128
+ anyUnwitnessed
2129
+ ) {
2130
+ // Each holds the bundle at `partial`, and EACH gets said. A pack with no
2131
+ // events, a stamp nobody witnessed and a witness we could not reach are
2132
+ // different gaps with different fixes, and an auditor told only about one
2133
+ // would read the others as fine. The order is fixed so the two kernels
2134
+ // emit the same list.
2063
2135
  status = "partial";
2064
- if (gatewayUnavailable) {
2065
- errors.push("one or more stamps could not be re-fetched from the supplied gateways");
2136
+ if (eventResults.length === 0) {
2137
+ // §5.3.2: an events-empty body is exactly a §5.2 stamp trace, and is
2138
+ // partial, never verified. It says nothing about any event.
2139
+ errors.push(
2140
+ `${STAMP_EVIDENCE_BODY_TYPE} body carries no events -- it evidences no event, only its stamps`,
2141
+ );
2066
2142
  }
2143
+ pushRefetchGapNotes(errors, "stamps", {
2144
+ unavailable: gatewayUnavailable,
2145
+ kind: uncheckedKind,
2146
+ id: uncheckedId,
2147
+ });
2067
2148
  if (anyUnwitnessed) {
2068
2149
  // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
2069
2150
  // the independent part, so the events resting on it inherit `partial`.
@@ -2129,18 +2210,21 @@ async function refetchStamps(
2129
2210
  witnessed: (string | undefined)[],
2130
2211
  options: VerifyEvidenceOptions,
2131
2212
  errors: string[],
2132
- ): Promise<{ onChainChecked: boolean; gatewayUnavailable: boolean }> {
2213
+ ): Promise<StampRefetchSummary> {
2133
2214
  const gateways = options.gateways ?? [];
2134
2215
  const onChainChecked = gateways.length > 0;
2135
- if (!onChainChecked) return { onChainChecked, gatewayUnavailable: false };
2216
+ const none = { gatewayUnavailable: false, uncheckedKind: false, uncheckedId: false };
2217
+ if (!onChainChecked) return { onChainChecked, ...none };
2136
2218
 
2137
2219
  const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
2138
2220
  if (typeof fetchImpl !== "function") {
2139
2221
  errors.push("on-chain re-fetch requested but no fetch implementation is available");
2140
- return { onChainChecked, gatewayUnavailable: true };
2222
+ return { onChainChecked, ...none, gatewayUnavailable: true };
2141
2223
  }
2142
2224
 
2143
2225
  let gatewayUnavailable = false;
2226
+ let uncheckedKind = false;
2227
+ let uncheckedId = false;
2144
2228
  for (let i = 0; i < stamps.length; i++) {
2145
2229
  const st = stamps[i]!;
2146
2230
  const witnessedHex = witnessed[i];
@@ -2150,8 +2234,10 @@ async function refetchStamps(
2150
2234
 
2151
2235
  const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
2152
2236
  st.onChain = outcome.onChain;
2153
- st.onChainOk = outcome.onChain.onChainOk;
2237
+ st.onChainOk = outcome.onChain?.onChainOk ?? null;
2154
2238
  if (outcome.unavailable) gatewayUnavailable = true;
2239
+ if (outcome.unchecked === "kind") uncheckedKind = true;
2240
+ if (outcome.unchecked === "id") uncheckedId = true;
2155
2241
  if (outcome.error) st.errors.push(outcome.error);
2156
2242
  // Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
2157
2243
  // (false) fails the stamp — the chain does not hold what the bundle says
@@ -2164,7 +2250,14 @@ async function refetchStamps(
2164
2250
  // unreachable note would fail a stamp for a gateway being down.
2165
2251
  st.ok = st.ok && st.onChainOk !== false;
2166
2252
  }
2167
- return { onChainChecked, gatewayUnavailable };
2253
+ return { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId };
2254
+ }
2255
+
2256
+ interface StampRefetchSummary {
2257
+ onChainChecked: boolean;
2258
+ gatewayUnavailable: boolean;
2259
+ uncheckedKind: boolean;
2260
+ uncheckedId: boolean;
2168
2261
  }
2169
2262
 
2170
2263
  // ===========================================================================
@@ -2220,7 +2313,7 @@ export interface DisclosedContentResult {
2220
2313
  errors: string[];
2221
2314
  }
2222
2315
 
2223
- // How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
2316
+ // How many `sanning.evidence.export/v2` wrappers deep the source bundle may be
2224
2317
  // nested before this path gives up. verifyEvidenceBundle's canonicalization
2225
2318
  // depth bound already rejects a pathological bundle, but the unwrap must be
2226
2319
  // independently bounded rather than trusting that it ran first.
@@ -2259,7 +2352,7 @@ export async function disclosedContent(
2259
2352
  const traceEvents = resolveTraceEvents(bundle);
2260
2353
  if (traceEvents === undefined) {
2261
2354
  errors.push(
2262
- `no ${[...ANCHOR_TRACE_BODY_TYPES, STAMP_EVIDENCE_BODY_TYPE].join(" / ")} ` +
2355
+ `no ${ANCHOR_TRACE_BODY_TYPE} / ${STAMP_EVIDENCE_BODY_TYPE} ` +
2263
2356
  `body to materialize content from ` +
2264
2357
  `(body_type ${JSON.stringify(verification.bodyType)})`,
2265
2358
  );
@@ -2360,7 +2453,7 @@ export async function disclosedContent(
2360
2453
  // a caller must distinguish from a body that has events and no links.
2361
2454
  //
2362
2455
  // BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
2363
- // `sanning.stamp.evidence/v1` is the hand-over pack, and it presents the same
2456
+ // `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
2364
2457
  // event shape.
2365
2458
  //
2366
2459
  // Exported for `chain.ts`, which needs the same descent and must not derive it
@@ -2375,8 +2468,8 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
2375
2468
  const b = cur as EvidenceBundle;
2376
2469
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
2377
2470
  // Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
2378
- // `sanning.stamp.evidence/v1` presents the SAME event shape (a
2379
- // producer-signed envelope, optional `record_bytes`, an inclusion proof),
2471
+ // `sanning.stamp.evidence/v2` presents the SAME event shape (a
2472
+ // producer-signed envelope, optional `event_bytes`, an inclusion proof),
2380
2473
  // and it is the HAND-OVER PACK: the only artifact an auditor is ever
2381
2474
  // handed. Dispatching on anchor-trace alone meant `readChains` reported
2382
2475
  // zero chains on every pack, however well-formed the links inside its
@@ -2384,7 +2477,6 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
2384
2477
  // that matters. The writers have been emitting those links the whole time.
2385
2478
  if (
2386
2479
  bodyType === ANCHOR_TRACE_BODY_TYPE ||
2387
- bodyType === ANCHOR_TRACE_BODY_TYPE_V1 ||
2388
2480
  bodyType === STAMP_EVIDENCE_BODY_TYPE
2389
2481
  ) {
2390
2482
  const body = b.body as AnchorTraceBody | null | undefined;
@@ -2489,7 +2581,7 @@ async function verifyExportBody(
2489
2581
  // The existing evidence path over the inline source bundle — same primitives
2490
2582
  // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
2491
2583
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
2492
- const sourceResult = await verifyEvidenceBundle(inline, options);
2584
+ const sourceResult = await verifyExportSource(inline, options);
2493
2585
 
2494
2586
  // Attestation data_hash binding (§5 6c).
2495
2587
  const sourceCheckpoints = await indexSourceCheckpoints(inline);
@@ -2622,7 +2714,7 @@ export async function recomputeExportVerdict(
2622
2714
  generatedAt: string,
2623
2715
  options: VerifyEvidenceOptions = {},
2624
2716
  ): Promise<ComposeVerdictResult> {
2625
- const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
2717
+ const sourceResult = await verifyExportSource(sourceBundle, options);
2626
2718
 
2627
2719
  // Attestation data_hash binding (§5 6c) — the SAME index construction as
2628
2720
  // verifyExportBody, deliberately shared rather than restated.
@@ -2668,6 +2760,52 @@ export async function recomputeExportVerdict(
2668
2760
  // field; otherwise a per-dimension AttestationResult whose `ok` gates on the
2669
2761
  // three binding checks (sig / operator-address / data_hash). subject_ref is
2670
2762
  // undetermined-tolerant and never gates.
2763
+ // The attestation payload fields evidence-export.md §3.1 marks Required, with
2764
+ // the type it gives each. A record whose payload lacks one, or carries another
2765
+ // type (null included), is malformed: §5 step 9 and §4.5 make "a missing
2766
+ // required field" an export the verifier cannot render (exit 2). §5 step 6
2767
+ // gates a WELL-FORMED record on signature, operator and data_hash only; these
2768
+ // checks decide whether the record is well-formed at all. `level` is the §3.1
2769
+ // enum 1 | 2 | 3. The integers are non-negative and within 2**53 - 1 (the
2770
+ // §3.1 clarification).
2771
+ const PAYLOAD_REQUIRED: readonly (readonly [string, "string" | "boolean" | "integer" | "level"])[] = [
2772
+ ["attested_at", "string"],
2773
+ ["tx_id", "string"],
2774
+ ["data_hash", "string"],
2775
+ ["data_size", "integer"],
2776
+ ["block_height", "integer"],
2777
+ ["block_timestamp", "integer"],
2778
+ ["operator", "string"],
2779
+ ["owner_address", "string"],
2780
+ ["gateway", "string"],
2781
+ ["signature_verified", "boolean"],
2782
+ ["level", "level"],
2783
+ ];
2784
+
2785
+ function payloadFieldError(payload: Record<string, unknown>): string | null {
2786
+ for (const [name, kind] of PAYLOAD_REQUIRED) {
2787
+ const v = Object.prototype.hasOwnProperty.call(payload, name) ? payload[name] : undefined;
2788
+ let ok: boolean;
2789
+ let want: string;
2790
+ if (kind === "string") {
2791
+ ok = typeof v === "string";
2792
+ want = "a string";
2793
+ } else if (kind === "boolean") {
2794
+ ok = typeof v === "boolean";
2795
+ want = "a boolean";
2796
+ } else if (kind === "integer") {
2797
+ // §3.1 (clarified): a non-negative integer no larger than 2**53 - 1.
2798
+ ok = typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
2799
+ want = "a non-negative integer";
2800
+ } else {
2801
+ ok = v === 1 || v === 2 || v === 3;
2802
+ want = "1, 2 or 3";
2803
+ }
2804
+ if (!ok) return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
2805
+ }
2806
+ return null;
2807
+ }
2808
+
2671
2809
  async function verifyAttestation(
2672
2810
  rec: AttestationRecord,
2673
2811
  sourceCheckpoints: Map<string, AnyTraceCheckpoint>,
@@ -2714,6 +2852,8 @@ async function verifyAttestation(
2714
2852
  if (typeof rec.signature !== "string") {
2715
2853
  return { result, malformed: "attestation record missing signature" };
2716
2854
  }
2855
+ const fieldError = payloadFieldError(payload as unknown as Record<string, unknown>);
2856
+ if (fieldError !== null) return { result, malformed: fieldError };
2717
2857
  const rsaKey = { kty: "RSA" as const, n: pub.n, e: pub.e };
2718
2858
 
2719
2859
  result.operator = typeof payload.operator === "string" ? payload.operator : "";
@@ -2953,7 +3093,7 @@ async function refetchCheckpoint(
2953
3093
  anchorRef: AnchorRef,
2954
3094
  gateways: string[],
2955
3095
  fetchImpl: typeof fetch,
2956
- ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
3096
+ ): Promise<RefetchOutcome> {
2957
3097
  let expectedHex: string | undefined;
2958
3098
  try {
2959
3099
  // A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
@@ -2999,7 +3139,7 @@ async function refetchStamp(
2999
3139
  anchorRef: AnchorRef,
3000
3140
  gateways: string[],
3001
3141
  fetchImpl: typeof fetch,
3002
- ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
3142
+ ): Promise<RefetchOutcome> {
3003
3143
  return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
3004
3144
  }
3005
3145
 
@@ -3014,7 +3154,36 @@ async function refetchAnchored(
3014
3154
  anchorRef: AnchorRef,
3015
3155
  gateways: string[],
3016
3156
  fetchImpl: typeof fetch,
3017
- ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
3157
+ ): Promise<RefetchOutcome> {
3158
+ // Only a `kind` this kernel has a witness adapter for is re-fetched
3159
+ // (evidence-bundle.md §5.1: "where the verifier has an adapter for its
3160
+ // `kind`"; §5.1.1: an unrecognised kind MUST NOT fail verification). An `id`
3161
+ // of any other kind is not an Arweave id, so whatever a gateway serves at
3162
+ // that path says nothing about the witness, and comparing it would turn an
3163
+ // unrelated 200 into a false mismatch. No fetch, `onChain: null`, and the
3164
+ // run goes `partial`: the caller asked for the witness check and it was not
3165
+ // made.
3166
+ if (!WITNESS_ADAPTER_KINDS.has(anchorRef.kind)) {
3167
+ return {
3168
+ onChain: null,
3169
+ unavailable: false,
3170
+ unchecked: "kind",
3171
+ error: `no witness adapter for kind '${anchorRef.kind}'; the witness was not checked`,
3172
+ };
3173
+ }
3174
+ // The id becomes a URL path segment. Anything but a 43-character base64url
3175
+ // transaction id can leave the gateway's base path once URL parsing
3176
+ // normalises it (`../x`, `%2e%2e/x`), or drop part of the path (`a#b`,
3177
+ // `a?b`), and the two kernels would then request different URLs. Such an id
3178
+ // is never fetched and never failed: the witness is not checked.
3179
+ if (!ARWEAVE_ID_RE.test(anchorRef.id)) {
3180
+ return {
3181
+ onChain: null,
3182
+ unavailable: false,
3183
+ unchecked: "id",
3184
+ error: "arweave witness id is not a 43-character base64url transaction id; the witness was not checked",
3185
+ };
3186
+ }
3018
3187
  const perGateway: PerGatewayOutcome[] = [];
3019
3188
  for (const gw of gateways) {
3020
3189
  perGateway.push({
@@ -3033,7 +3202,51 @@ async function refetchAnchored(
3033
3202
  } else if (onChain.rollup === "unreachable") {
3034
3203
  error = `${subject} ${anchorRef.id} unreachable on all gateways`;
3035
3204
  }
3036
- return { onChain, unavailable: onChain.rollup === "unreachable", error };
3205
+ return { onChain, unavailable: onChain.rollup === "unreachable", unchecked: null, error };
3206
+ }
3207
+
3208
+ // The `anchor_ref.kind` values this kernel can re-fetch from a gateway
3209
+ // (evidence-bundle.md §5.1.1 registers exactly `arweave`). Any other kind is
3210
+ // left unchecked by `refetchAnchored`, never probed.
3211
+ const WITNESS_ADAPTER_KINDS: ReadonlySet<string> = new Set(["arweave"]);
3212
+
3213
+ // The only `arweave` ids the adapter looks up: a 43-character base64url
3214
+ // transaction or data-item id.
3215
+ const ARWEAVE_ID_RE = /^[A-Za-z0-9_-]{43}$/;
3216
+
3217
+ // Why a re-fetch was not made: `kind` (no adapter for the witness kind) or
3218
+ // `id` (an arweave id the adapter cannot look up). null when it was made.
3219
+ type Unchecked = "kind" | "id" | null;
3220
+
3221
+ interface RefetchOutcome {
3222
+ onChain: OnChainResult | null;
3223
+ // Every gateway was unreachable.
3224
+ unavailable: boolean;
3225
+ unchecked: Unchecked;
3226
+ error?: string;
3227
+ }
3228
+
3229
+ // The bundle-level notes for a run where requested re-fetches were not made.
3230
+ // Each note keeps the phrase "could not be re-fetched", which is what both
3231
+ // CLIs map to exit 3. The order is fixed so the two kernels emit the same list.
3232
+ function pushRefetchGapNotes(
3233
+ errors: string[],
3234
+ subject: "stamps" | "checkpoints",
3235
+ gaps: { unavailable: boolean; kind: boolean; id: boolean },
3236
+ ): void {
3237
+ if (gaps.unavailable) {
3238
+ errors.push(`one or more ${subject} could not be re-fetched from the supplied gateways`);
3239
+ }
3240
+ if (gaps.kind) {
3241
+ errors.push(
3242
+ `one or more ${subject} name a witness kind this verifier has no adapter for, so it could not be re-fetched`,
3243
+ );
3244
+ }
3245
+ if (gaps.id) {
3246
+ errors.push(
3247
+ `one or more ${subject} name an Arweave witness id this verifier cannot look up, so it could not be re-fetched`,
3248
+ );
3249
+ }
3037
3250
  }
3038
3251
 
3039
3252
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch