@sanning/proof 0.9.1 → 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
@@ -1082,15 +1123,20 @@ async function verifyAnchorTrace(
1082
1123
  cpErrors.push("checkpoint envelope signature failed");
1083
1124
  }
1084
1125
  if (res.payloadHashOk === false) cpErrors.push("checkpoint record does not bind to payload_hash");
1126
+ // proof#95: a malformed skeleton field fails the envelope with a valid
1127
+ // signature and binding; name it rather than fail with no reason.
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);
1085
1131
  // WHY the binding is undetermined decides which sentence is true.
1086
- // record_bytes really absent is one thing; record_bytes present and
1132
+ // event_bytes really absent is one thing; event_bytes present and
1087
1133
  // unbindable (a degenerate envelope, unreadable hex) is another, and
1088
1134
  // naming the wrong cause sends a reader to look for a field that is
1089
1135
  // sitting right there (proof#76).
1090
1136
  if (res.payloadHashOk === null) {
1091
1137
  cpErrors.push(
1092
- recordBytes === undefined
1093
- ? "checkpoint record_bytes missing -- root untrusted"
1138
+ eventBytes === undefined
1139
+ ? "checkpoint event_bytes missing -- root untrusted"
1094
1140
  : "checkpoint record binding not checked: root untrusted",
1095
1141
  );
1096
1142
  }
@@ -1100,9 +1146,9 @@ async function verifyAnchorTrace(
1100
1146
 
1101
1147
  // The committed record's merkle_root must equal the checkpoint's claimed
1102
1148
  // merkle_root (recompute, don't trust the bundle's top-level claim).
1103
- if (recordBytes !== undefined) {
1149
+ if (eventBytes !== undefined) {
1104
1150
  try {
1105
- const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
1151
+ const record = JSON.parse(new TextDecoder().decode(eventBytes)) as {
1106
1152
  event?: { merkle_root?: unknown };
1107
1153
  };
1108
1154
  const committedRoot = record.event?.merkle_root;
@@ -1161,16 +1207,21 @@ async function verifyAnchorTrace(
1161
1207
  let envelopeOk = false;
1162
1208
  let payloadBindingOk: boolean | null = null;
1163
1209
 
1164
- 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;
1165
1216
  try {
1166
- if (typeof ev.record_bytes === "string") recordBytes = hexToBytes(ev.record_bytes);
1217
+ if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
1167
1218
  } catch (e) {
1168
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1219
+ evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
1169
1220
  }
1170
1221
  try {
1171
1222
  const res = await verifyEnvelope(
1172
1223
  ev.envelope,
1173
- recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
1224
+ eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1174
1225
  );
1175
1226
  envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
1176
1227
  payloadBindingOk = res.payloadHashOk;
@@ -1187,6 +1238,11 @@ async function verifyAnchorTrace(
1187
1238
  evErrors.push("event envelope signature failed");
1188
1239
  }
1189
1240
  if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
1241
+ // A malformed skeleton field fails the envelope with a valid signature
1242
+ // and binding (proof#95), so without this the event fails with no reason.
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);
1190
1246
  } catch (e) {
1191
1247
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1192
1248
  }
@@ -1194,12 +1250,11 @@ async function verifyAnchorTrace(
1194
1250
  // Bind the event to exactly one checkpoint in the bundle, then verify
1195
1251
  // RFC 9162 inclusion against THAT checkpoint's merkle_root.
1196
1252
  const incl = ev.inclusion as (TraceInclusion & { checkpoint_tx_id?: unknown }) | undefined;
1197
- // The join key is spelled differently in the two forms and MUST NOT be read
1198
- // from the other one: an inclusion carrying v1's spelling inside a v2 body
1199
- // is a mixed bundle, and resolving it anyway is how an event silently binds
1200
- // to a checkpoint nobody meant.
1201
- const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
1202
- 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";
1203
1258
  const inclAsRecord = incl as unknown as Record<string, unknown> | undefined;
1204
1259
  const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
1205
1260
  const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
@@ -1257,7 +1312,7 @@ async function verifyAnchorTrace(
1257
1312
  }
1258
1313
 
1259
1314
  // Content (raw-log) binding — shared with every other event-carrying body.
1260
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1315
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
1261
1316
  const contentOk = contentRes.contentOk;
1262
1317
  evErrors.push(...contentRes.errors);
1263
1318
 
@@ -1273,7 +1328,7 @@ async function verifyAnchorTrace(
1273
1328
  // present checkpoint, and its disclosed content did not MISMATCH. A
1274
1329
  // withheld record or undisclosed content (binding/content null) does not
1275
1330
  // fail it.
1276
- ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false,
1331
+ ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false && legacyError === null,
1277
1332
  errors: evErrors,
1278
1333
  });
1279
1334
  }
@@ -1281,6 +1336,8 @@ async function verifyAnchorTrace(
1281
1336
  // --- Step 5: optional on-chain re-fetch ------------------------------------
1282
1337
  const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
1283
1338
  let gatewayUnavailable = false;
1339
+ let uncheckedKind = false;
1340
+ let uncheckedId = false;
1284
1341
  if (onChainChecked) {
1285
1342
  if (typeof fetchImpl !== "function") {
1286
1343
  gatewayUnavailable = true;
@@ -1308,8 +1365,10 @@ async function verifyAnchorTrace(
1308
1365
  }
1309
1366
  const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
1310
1367
  cpr.onChain = outcome.onChain;
1311
- cpr.onChainOk = outcome.onChain.onChainOk;
1368
+ cpr.onChainOk = outcome.onChain?.onChainOk ?? null;
1312
1369
  if (outcome.unavailable) gatewayUnavailable = true;
1370
+ if (outcome.unchecked === "kind") uncheckedKind = true;
1371
+ if (outcome.unchecked === "id") uncheckedId = true;
1313
1372
  if (outcome.error) cpr.errors.push(outcome.error);
1314
1373
  // Fold the on-chain dimension in: a MISMATCH (false) fails the
1315
1374
  // checkpoint — the proof was not anchored as claimed. An UNREACHABLE
@@ -1341,12 +1400,16 @@ async function verifyAnchorTrace(
1341
1400
  let status: EvidenceStatus;
1342
1401
  if (anyHardFailure) {
1343
1402
  status = "failed";
1344
- } else if (gatewayUnavailable) {
1403
+ } else if (gatewayUnavailable || uncheckedKind || uncheckedId) {
1345
1404
  // Every offline check passed and nothing MISMATCHED, but a requested
1346
1405
  // on-chain confirmation could not be made — the bundle is offline-sound,
1347
1406
  // its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
1348
1407
  status = "partial";
1349
- 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
+ });
1350
1413
  } else if (anyWithheldRecord) {
1351
1414
  // Everything that COULD be checked passed; some record bytes were withheld
1352
1415
  // so their semantic binding is undetermined (not a failure) — "partial".
@@ -1438,7 +1501,7 @@ function isNonNegInt(n: unknown): n is number {
1438
1501
  // ===========================================================================
1439
1502
  // Disclosed-content binding, shared by every body that carries events.
1440
1503
  //
1441
- // 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
1442
1505
  // rawLog -> content_hash link is the one step the chain
1443
1506
  // (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
1444
1507
  // not otherwise cover, and a second copy of it is a second place for the
@@ -1450,7 +1513,7 @@ function isNonNegInt(n: unknown): n is number {
1450
1513
  async function verifyDisclosedContent(
1451
1514
  ev: { envelope?: unknown; content?: unknown },
1452
1515
  eventId: string,
1453
- recordBytes: Uint8Array | undefined,
1516
+ eventBytes: Uint8Array | undefined,
1454
1517
  sideContent: VerifyEvidenceOptions["content"],
1455
1518
  ): Promise<{ contentOk: boolean | null; errors: string[] }> {
1456
1519
  const evErrors: string[] = [];
@@ -1458,9 +1521,9 @@ async function verifyDisclosedContent(
1458
1521
 
1459
1522
  const committed: string[] = [];
1460
1523
  let recordContentHash: string | undefined;
1461
- if (recordBytes !== undefined) {
1524
+ if (eventBytes !== undefined) {
1462
1525
  try {
1463
- const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
1526
+ const record = JSON.parse(new TextDecoder().decode(eventBytes)) as {
1464
1527
  event?: { content_hash?: unknown };
1465
1528
  };
1466
1529
  const ch = record.event?.content_hash;
@@ -1775,7 +1838,7 @@ async function verifyStampTrace(
1775
1838
  // other — asks every consumer to remember which wrapper it is holding before
1776
1839
  // it can trust `onChain: null`, and that is exactly the kind of per-body
1777
1840
  // exception a reader eventually gets wrong.
1778
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(
1841
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
1779
1842
  stamps,
1780
1843
  witnessed,
1781
1844
  options,
@@ -1795,13 +1858,15 @@ async function verifyStampTrace(
1795
1858
  // does not have.
1796
1859
  status = "partial";
1797
1860
  errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
1798
- } else if (gatewayUnavailable || anyUnwitnessed) {
1861
+ } else if (gatewayUnavailable || uncheckedKind || uncheckedId || anyUnwitnessed) {
1799
1862
  // Both hold the bundle at `partial`, and both get said — see §5.3's rollup
1800
1863
  // for why one note is not enough when there are two different gaps.
1801
1864
  status = "partial";
1802
- if (gatewayUnavailable) {
1803
- errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1804
- }
1865
+ pushRefetchGapNotes(errors, "stamps", {
1866
+ unavailable: gatewayUnavailable,
1867
+ kind: uncheckedKind,
1868
+ id: uncheckedId,
1869
+ });
1805
1870
  if (anyUnwitnessed) {
1806
1871
  // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1807
1872
  // sealed and unwitnessed: `retained-by-sanning`, which is a statement
@@ -1834,7 +1899,7 @@ async function verifyStampTrace(
1834
1899
  }
1835
1900
 
1836
1901
  // ===========================================================================
1837
- // `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)
1838
1903
  //
1839
1904
  // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
1840
1905
  // the sub-tree partition check). The event half reuses the same primitives the
@@ -1912,16 +1977,21 @@ async function verifyStampEvidence(
1912
1977
  let envelopeOk = false;
1913
1978
  let payloadBindingOk: boolean | null = null;
1914
1979
 
1915
- 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;
1916
1986
  try {
1917
- if (typeof ev.record_bytes === "string") recordBytes = hexToBytes(ev.record_bytes);
1987
+ if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
1918
1988
  } catch (e) {
1919
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1989
+ evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
1920
1990
  }
1921
1991
  try {
1922
1992
  const res = await verifyEnvelope(
1923
1993
  ev.envelope,
1924
- recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
1994
+ eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1925
1995
  );
1926
1996
  envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1927
1997
  payloadBindingOk = res.payloadHashOk;
@@ -1938,6 +2008,11 @@ async function verifyStampEvidence(
1938
2008
  evErrors.push("event envelope signature failed");
1939
2009
  }
1940
2010
  if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
2011
+ // A malformed skeleton field fails the envelope with a valid signature
2012
+ // and binding (proof#95), so without this the event fails with no reason.
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);
1941
2016
  } catch (e) {
1942
2017
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1943
2018
  }
@@ -2007,7 +2082,7 @@ async function verifyStampEvidence(
2007
2082
  }
2008
2083
  }
2009
2084
 
2010
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
2085
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
2011
2086
  const contentOk = contentRes.contentOk;
2012
2087
  evErrors.push(...contentRes.errors);
2013
2088
 
@@ -2019,13 +2094,13 @@ async function verifyStampEvidence(
2019
2094
  // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
2020
2095
  checkpointBound: stampBound,
2021
2096
  contentOk,
2022
- ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
2097
+ ok: envelopeOk && inclusionOk && stampBound && contentOk !== false && legacyError === null,
2023
2098
  errors: evErrors,
2024
2099
  });
2025
2100
  }
2026
2101
 
2027
2102
  // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
2028
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(
2103
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
2029
2104
  stamps,
2030
2105
  witnessed,
2031
2106
  options,
@@ -2045,16 +2120,31 @@ async function verifyStampEvidence(
2045
2120
  } else if (stamps.length === 0 && eventResults.length === 0) {
2046
2121
  status = "partial";
2047
2122
  errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
2048
- } else if (gatewayUnavailable || anyUnwitnessed) {
2049
- // Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
2050
- // witnessed and a witness we could not reach are different gaps with
2051
- // different fixes — one waits for the interval, the other for the network —
2052
- // and an auditor told only about the second would read the first as fine.
2053
- // 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.
2054
2135
  status = "partial";
2055
- if (gatewayUnavailable) {
2056
- 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
+ );
2057
2142
  }
2143
+ pushRefetchGapNotes(errors, "stamps", {
2144
+ unavailable: gatewayUnavailable,
2145
+ kind: uncheckedKind,
2146
+ id: uncheckedId,
2147
+ });
2058
2148
  if (anyUnwitnessed) {
2059
2149
  // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
2060
2150
  // the independent part, so the events resting on it inherit `partial`.
@@ -2120,18 +2210,21 @@ async function refetchStamps(
2120
2210
  witnessed: (string | undefined)[],
2121
2211
  options: VerifyEvidenceOptions,
2122
2212
  errors: string[],
2123
- ): Promise<{ onChainChecked: boolean; gatewayUnavailable: boolean }> {
2213
+ ): Promise<StampRefetchSummary> {
2124
2214
  const gateways = options.gateways ?? [];
2125
2215
  const onChainChecked = gateways.length > 0;
2126
- if (!onChainChecked) return { onChainChecked, gatewayUnavailable: false };
2216
+ const none = { gatewayUnavailable: false, uncheckedKind: false, uncheckedId: false };
2217
+ if (!onChainChecked) return { onChainChecked, ...none };
2127
2218
 
2128
2219
  const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
2129
2220
  if (typeof fetchImpl !== "function") {
2130
2221
  errors.push("on-chain re-fetch requested but no fetch implementation is available");
2131
- return { onChainChecked, gatewayUnavailable: true };
2222
+ return { onChainChecked, ...none, gatewayUnavailable: true };
2132
2223
  }
2133
2224
 
2134
2225
  let gatewayUnavailable = false;
2226
+ let uncheckedKind = false;
2227
+ let uncheckedId = false;
2135
2228
  for (let i = 0; i < stamps.length; i++) {
2136
2229
  const st = stamps[i]!;
2137
2230
  const witnessedHex = witnessed[i];
@@ -2141,8 +2234,10 @@ async function refetchStamps(
2141
2234
 
2142
2235
  const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
2143
2236
  st.onChain = outcome.onChain;
2144
- st.onChainOk = outcome.onChain.onChainOk;
2237
+ st.onChainOk = outcome.onChain?.onChainOk ?? null;
2145
2238
  if (outcome.unavailable) gatewayUnavailable = true;
2239
+ if (outcome.unchecked === "kind") uncheckedKind = true;
2240
+ if (outcome.unchecked === "id") uncheckedId = true;
2146
2241
  if (outcome.error) st.errors.push(outcome.error);
2147
2242
  // Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
2148
2243
  // (false) fails the stamp — the chain does not hold what the bundle says
@@ -2155,7 +2250,14 @@ async function refetchStamps(
2155
2250
  // unreachable note would fail a stamp for a gateway being down.
2156
2251
  st.ok = st.ok && st.onChainOk !== false;
2157
2252
  }
2158
- 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;
2159
2261
  }
2160
2262
 
2161
2263
  // ===========================================================================
@@ -2211,7 +2313,7 @@ export interface DisclosedContentResult {
2211
2313
  errors: string[];
2212
2314
  }
2213
2315
 
2214
- // 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
2215
2317
  // nested before this path gives up. verifyEvidenceBundle's canonicalization
2216
2318
  // depth bound already rejects a pathological bundle, but the unwrap must be
2217
2319
  // independently bounded rather than trusting that it ran first.
@@ -2250,7 +2352,7 @@ export async function disclosedContent(
2250
2352
  const traceEvents = resolveTraceEvents(bundle);
2251
2353
  if (traceEvents === undefined) {
2252
2354
  errors.push(
2253
- `no ${[...ANCHOR_TRACE_BODY_TYPES, STAMP_EVIDENCE_BODY_TYPE].join(" / ")} ` +
2355
+ `no ${ANCHOR_TRACE_BODY_TYPE} / ${STAMP_EVIDENCE_BODY_TYPE} ` +
2254
2356
  `body to materialize content from ` +
2255
2357
  `(body_type ${JSON.stringify(verification.bodyType)})`,
2256
2358
  );
@@ -2351,7 +2453,7 @@ export async function disclosedContent(
2351
2453
  // a caller must distinguish from a body that has events and no links.
2352
2454
  //
2353
2455
  // BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
2354
- // `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
2355
2457
  // event shape.
2356
2458
  //
2357
2459
  // Exported for `chain.ts`, which needs the same descent and must not derive it
@@ -2366,8 +2468,8 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
2366
2468
  const b = cur as EvidenceBundle;
2367
2469
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
2368
2470
  // Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
2369
- // `sanning.stamp.evidence/v1` presents the SAME event shape (a
2370
- // 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),
2371
2473
  // and it is the HAND-OVER PACK: the only artifact an auditor is ever
2372
2474
  // handed. Dispatching on anchor-trace alone meant `readChains` reported
2373
2475
  // zero chains on every pack, however well-formed the links inside its
@@ -2375,7 +2477,6 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
2375
2477
  // that matters. The writers have been emitting those links the whole time.
2376
2478
  if (
2377
2479
  bodyType === ANCHOR_TRACE_BODY_TYPE ||
2378
- bodyType === ANCHOR_TRACE_BODY_TYPE_V1 ||
2379
2480
  bodyType === STAMP_EVIDENCE_BODY_TYPE
2380
2481
  ) {
2381
2482
  const body = b.body as AnchorTraceBody | null | undefined;
@@ -2480,7 +2581,7 @@ async function verifyExportBody(
2480
2581
  // The existing evidence path over the inline source bundle — same primitives
2481
2582
  // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
2482
2583
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
2483
- const sourceResult = await verifyEvidenceBundle(inline, options);
2584
+ const sourceResult = await verifyExportSource(inline, options);
2484
2585
 
2485
2586
  // Attestation data_hash binding (§5 6c).
2486
2587
  const sourceCheckpoints = await indexSourceCheckpoints(inline);
@@ -2613,7 +2714,7 @@ export async function recomputeExportVerdict(
2613
2714
  generatedAt: string,
2614
2715
  options: VerifyEvidenceOptions = {},
2615
2716
  ): Promise<ComposeVerdictResult> {
2616
- const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
2717
+ const sourceResult = await verifyExportSource(sourceBundle, options);
2617
2718
 
2618
2719
  // Attestation data_hash binding (§5 6c) — the SAME index construction as
2619
2720
  // verifyExportBody, deliberately shared rather than restated.
@@ -2659,6 +2760,52 @@ export async function recomputeExportVerdict(
2659
2760
  // field; otherwise a per-dimension AttestationResult whose `ok` gates on the
2660
2761
  // three binding checks (sig / operator-address / data_hash). subject_ref is
2661
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
+
2662
2809
  async function verifyAttestation(
2663
2810
  rec: AttestationRecord,
2664
2811
  sourceCheckpoints: Map<string, AnyTraceCheckpoint>,
@@ -2705,6 +2852,8 @@ async function verifyAttestation(
2705
2852
  if (typeof rec.signature !== "string") {
2706
2853
  return { result, malformed: "attestation record missing signature" };
2707
2854
  }
2855
+ const fieldError = payloadFieldError(payload as unknown as Record<string, unknown>);
2856
+ if (fieldError !== null) return { result, malformed: fieldError };
2708
2857
  const rsaKey = { kty: "RSA" as const, n: pub.n, e: pub.e };
2709
2858
 
2710
2859
  result.operator = typeof payload.operator === "string" ? payload.operator : "";
@@ -2944,7 +3093,7 @@ async function refetchCheckpoint(
2944
3093
  anchorRef: AnchorRef,
2945
3094
  gateways: string[],
2946
3095
  fetchImpl: typeof fetch,
2947
- ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
3096
+ ): Promise<RefetchOutcome> {
2948
3097
  let expectedHex: string | undefined;
2949
3098
  try {
2950
3099
  // A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
@@ -2990,7 +3139,7 @@ async function refetchStamp(
2990
3139
  anchorRef: AnchorRef,
2991
3140
  gateways: string[],
2992
3141
  fetchImpl: typeof fetch,
2993
- ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
3142
+ ): Promise<RefetchOutcome> {
2994
3143
  return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
2995
3144
  }
2996
3145
 
@@ -3005,7 +3154,36 @@ async function refetchAnchored(
3005
3154
  anchorRef: AnchorRef,
3006
3155
  gateways: string[],
3007
3156
  fetchImpl: typeof fetch,
3008
- ): 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
+ }
3009
3187
  const perGateway: PerGatewayOutcome[] = [];
3010
3188
  for (const gw of gateways) {
3011
3189
  perGateway.push({
@@ -3024,7 +3202,51 @@ async function refetchAnchored(
3024
3202
  } else if (onChain.rollup === "unreachable") {
3025
3203
  error = `${subject} ${anchorRef.id} unreachable on all gateways`;
3026
3204
  }
3027
- 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
+ }
3028
3250
  }
3029
3251
 
3030
3252
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch