@sanning/proof 0.9.2 → 0.11.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
  //
@@ -20,6 +20,11 @@ import {
20
20
  deriveOperatorAddress,
21
21
  ed25519Verify,
22
22
  hexToBytes,
23
+ // The one hex-field test, which lives in `crypto.ts` beside the one hex
24
+ // reader (proof#146). It was a second copy here until then, with the same
25
+ // body: two copies of one rule is how two kernels drift apart one edit at a
26
+ // time, which is the whole defect class this fix is about.
27
+ isHexField,
23
28
  sha256Hex,
24
29
  utf8,
25
30
  verifyRsaPssSha256,
@@ -30,6 +35,7 @@ import {
30
35
  contentHashes,
31
36
  exceedsDepth,
32
37
  jcs,
38
+ jcsParse,
33
39
  verifyEnvelope,
34
40
  } from "./verifier.js";
35
41
  import {
@@ -48,37 +54,73 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
48
54
  // body_type is not a hard failure of the WRAPPER (the signature + body_hash
49
55
  // still verify), but its body cannot be re-derived, so the per-body verdict is
50
56
  // "undetermined" rather than "verified".
51
- export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
57
+ //
58
+ // The producer-to-agent cut (W-3, DL-73) renamed `record_bytes` to
59
+ // `event_bytes` and moved every body that carries it to a new major. This
60
+ // kernel reads ONLY those majors: `sanning.anchor.trace/v1` and `/v2`,
61
+ // `sanning.stamp.evidence/v1` and `sanning.evidence.export/v1` are unknown body
62
+ // types here and answer `partial`. DL-73 releases evidence made before the cut
63
+ // from any obligation to keep verifying, so there is no second field name to
64
+ // read and no dual-accept path.
65
+ export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v3";
52
66
 
53
67
  /**
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.
68
+ * The name `event_bytes` replaced. Under the new majors a body entry that still
69
+ * carries it is a hard reject, never an ignored unknown field: ignored, it
70
+ * would read as a withheld record and the event would verify with nothing
71
+ * checked, the fail-open the new majors exist to prevent (governance.md §8,
72
+ * W-3).
73
+ */
74
+ export const LEGACY_EVENT_BYTES_FIELD = "record_bytes";
75
+
76
+ /**
77
+ * Name an envelope the accept-set refused. Without this a bundle whose
78
+ * envelopes carry a retired profile major (a half-migrated producer still
79
+ * writing `sanning.events/v1`) fails with no reason at all. Spelled with
80
+ * `JSON.stringify` so both kernels write the same sentence.
60
81
  */
61
- export const ANCHOR_TRACE_BODY_TYPE_V1 = "sanning.anchor.trace/v1";
82
+ function specVersionFinding(
83
+ what: "checkpoint" | "event",
84
+ envelope: unknown,
85
+ res: { specVersionOk: boolean },
86
+ ): string | null {
87
+ if (!isJsonObject(envelope) || res.specVersionOk) return null;
88
+ if (!Object.prototype.hasOwnProperty.call(envelope, "spec_version")) {
89
+ return `${what} envelope has no spec_version`;
90
+ }
91
+ const sv = (envelope as Record<string, unknown>).spec_version;
92
+ return `${what} envelope spec_version ${JSON.stringify(sv)} is not accepted by this kernel`;
93
+ }
62
94
 
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;
95
+ /**
96
+ * The reject for an entry that still carries `record_bytes`. One sentence,
97
+ * spelled the same in both kernels, so a negative vector can assert it.
98
+ */
99
+ function legacyEventBytesError(
100
+ entry: object,
101
+ what: "checkpoint" | "event",
102
+ bodyType: string,
103
+ ): string | null {
104
+ if (!Object.prototype.hasOwnProperty.call(entry, LEGACY_EVENT_BYTES_FIELD)) return null;
105
+ return (
106
+ `${what} carries ${LEGACY_EVENT_BYTES_FIELD} in a ${bodyType} body ` +
107
+ "(renamed event_bytes) -- one bundle speaks one vocabulary"
108
+ );
109
+ }
68
110
 
69
111
  // The issuer-composed attested-evidence-export body (specs/evidence-export.md
70
112
  // §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
71
113
  // carries a cached verdict + the inline source bundle + embedded RSA-PSS
72
114
  // operator attestations. verifyEvidenceBundle dispatches on it to
73
115
  // verifyExportBody (§5).
74
- export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
116
+ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v2";
75
117
 
76
118
  /**
77
119
  * The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
78
120
  *
79
121
  * Its own `body_type` rather than a widened anchor-trace body, and the reason
80
122
  * 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 —
123
+ * `sanning.events/v2` envelope, and a stamp has no producer signature at all:
82
124
  * Sanning places it, under DL-52, for a customer who holds no chain identity.
83
125
  * The two rejected options were making `envelope` optional and synthesising an
84
126
  * envelope that Sanning signs; the second would have made a Sanning
@@ -86,7 +128,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
86
128
  * unrecoverable once witnessed.
87
129
  */
88
130
  export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
89
- export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
131
+ export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
90
132
 
91
133
  /**
92
134
  * The version string INSIDE a witnessed stamp body — a different string from
@@ -107,7 +149,6 @@ export interface EvidenceIssuer {
107
149
  kind: string;
108
150
  tenant_id?: string;
109
151
  agent_id?: string;
110
- producer_id?: string;
111
152
  [k: string]: unknown;
112
153
  }
113
154
 
@@ -136,13 +177,12 @@ export interface AnchorRef {
136
177
  ref?: Record<string, unknown>;
137
178
  }
138
179
 
139
- // `sanning.anchor.trace/v2` body (§5.1). Uint8Array fields ride as lowercase hex.
180
+ // `sanning.anchor.trace/v3` body (§5.1). Uint8Array fields ride as lowercase hex.
140
181
  export interface TraceCheckpoint {
141
182
  /**
142
183
  * 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.
184
+ * decoded `event_bytes`. Recomputed by the verifier, never taken from the
185
+ * label.
146
186
  */
147
187
  checkpoint_ref: string;
148
188
  /**
@@ -152,32 +192,24 @@ export interface TraceCheckpoint {
152
192
  */
153
193
  anchor_ref?: AnchorRef;
154
194
  envelope: Envelope;
155
- record_bytes: string; // hex of JCS(checkpoint record)
195
+ event_bytes: string; // hex of JCS(checkpoint record)
156
196
  merkle_root: string; // hex
157
197
  }
158
198
 
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
199
  export interface TraceInclusion {
168
200
  leaf_hash: string; // hex
169
201
  leaf_index: number;
170
202
  leaf_count: number;
171
203
  audit_path: string[]; // hex[]
172
- /** Resolves against `checkpoints[].checkpoint_ref`. v1 spells this `checkpoint_tx_id`. */
204
+ /** Resolves against `checkpoints[].checkpoint_ref`. */
173
205
  checkpoint_ref: string;
174
206
  }
175
207
 
176
208
  export interface TraceEvent {
177
209
  envelope: Envelope;
178
- record_bytes?: string; // hex of JCS(event record); omitted == record withheld
210
+ event_bytes?: string; // hex of JCS(event record); omitted == record withheld
179
211
  // 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).
212
+ // committed event.content_hash inside event_bytes (evidence-bundle §5.1).
181
213
  // Default-absent (minimal disclosure); absent ⇒ undetermined, not failed.
182
214
  content?: string;
183
215
  inclusion: TraceInclusion;
@@ -246,7 +278,7 @@ export interface StampTraceBody {
246
278
  stamps: StampEntry[];
247
279
  }
248
280
 
249
- // ---- `sanning.stamp.evidence/v1` wire types (evidence-bundle.md §5.3) -----
281
+ // ---- `sanning.stamp.evidence/v2` wire types (evidence-bundle.md §5.3) -----
250
282
  //
251
283
  // Both halves of a DL-52 record, bound: the producer signs the envelope, and
252
284
  // Sanning signs and witnesses the stamp whose root commits to it. Neither
@@ -266,7 +298,7 @@ export interface StampEvidenceInclusion {
266
298
 
267
299
  export interface StampEvidenceEvent {
268
300
  envelope: Envelope;
269
- record_bytes?: string; // hex of JCS(event record); omitted == withheld
301
+ event_bytes?: string; // hex of JCS(event record); omitted == withheld
270
302
  content?: string; // opt-in disclosed raw bytes, lowercase hex
271
303
  inclusion: StampEvidenceInclusion;
272
304
  }
@@ -327,23 +359,12 @@ export interface OnChainResult {
327
359
  }
328
360
 
329
361
  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
- */
362
+ /** The derived `checkpoint_ref`, empty when it could not be derived. */
337
363
  checkpointRef: string;
338
364
  /**
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.
365
+ * Where this checkpoint was witnessed. `null` when the checkpoint carries no
366
+ * `anchor_ref`: placed but not yet witnessed, which is a normal state and NOT
367
+ * a deficiency.
347
368
  */
348
369
  anchorRef: AnchorRef | null;
349
370
  // The checkpoint envelope verified (signature + payload binding to its record).
@@ -351,11 +372,15 @@ export interface CheckpointResult {
351
372
  // The committed record's merkle_root equals the checkpoint's claimed merkle_root.
352
373
  merkleRootOk: boolean;
353
374
  // 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.
375
+ // null when no gateway re-fetch was requested, or when `anchor_ref.kind` has
376
+ // no witness adapter, or its id is not one the adapter accepts; the witness
377
+ // was not checked. Derived collapse of `onChain` (§4.2), retained so existing
378
+ // consumers keep the single-boolean field.
356
379
  onChainOk: boolean | null;
357
380
  // Per-gateway on-chain outcomes (§4.2), ADDITIVE to `onChainOk`. null when no
358
- // gateway re-fetch was requested (offline-only verification).
381
+ // gateway re-fetch was requested (offline-only verification), or when
382
+ // `anchor_ref.kind` has no witness adapter, or its id is not one the adapter
383
+ // accepts; the witness was not checked.
359
384
  onChain: OnChainResult | null;
360
385
  ok: boolean;
361
386
  errors: string[];
@@ -373,7 +398,7 @@ export interface EventResult {
373
398
  // The inclusion's checkpoint_ref resolved to a checkpoint in the bundle.
374
399
  checkpointBound: boolean;
375
400
  // Raw-log (content) binding: SHA-256 of the disclosed bytes equals the
376
- // committed event.content_hash (in record_bytes) — or, for a promoted
401
+ // committed event.content_hash (in event_bytes), or, for a promoted
377
402
  // envelope, a hash it commits to. true matched, false mismatch (→ event
378
403
  // fails), null undetermined (nothing disclosed OR nothing committed OR record
379
404
  // withheld — NOT a failure, mirroring payloadBindingOk).
@@ -435,12 +460,14 @@ export interface StampResult {
435
460
  * ── The four states, and why none of them collapses ──
436
461
  *
437
462
  * `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).
463
+ * no witness to check, or its `anchor_ref.kind` has no witness adapter, or
464
+ * its id is not one the adapter accepts; the witness was not checked. It is
465
+ * a statement about the RUN, never about the stamp. The other three are
466
+ * statements about the stamp and live in `onChain.rollup`: `confirm` (a
467
+ * gateway returned the witnessed bytes), `mismatch` (a gateway returned
468
+ * DIFFERENT bytes: a finding, and the only one of the four that fails
469
+ * anything), `unreachable` (we asked and nobody answered: an availability
470
+ * gap, never an accusation).
444
471
  *
445
472
  * Keeping "not checked" distinct from "unreachable" is the whole point. A
446
473
  * surface handed a collapsed boolean cannot tell "no gateway could be
@@ -474,7 +501,7 @@ export interface EvidenceBundleResult {
474
501
  events: EventResult[];
475
502
  // The producer's asserted verdict.status (for display only — never trusted).
476
503
  assertedStatus: string | null;
477
- // Present ONLY for a `sanning.evidence.export/v1` body (evidence-export.md §5):
504
+ // Present ONLY for a `sanning.evidence.export/v2` body (evidence-export.md §5):
478
505
  // the export-specific dimensions (source linkage, verdict agreement, embedded
479
506
  // attestations, recomputed §4 verdict). The top-level `checkpoints`/`events`
480
507
  // above are the recomputed SOURCE-bundle results in that case. Absent for a
@@ -494,11 +521,7 @@ export interface EvidenceBundleResult {
494
521
  // One embedded operator attestation record (§2.2 / §3). RSA-PSS-SHA-256 over
495
522
  // JCS(payload); the operator key is the operator's Arweave RSA wallet.
496
523
  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
- */
524
+ /** The source checkpoint's derived `checkpoint_ref` this attestation binds to. */
502
525
  checkpoint_ref: string;
503
526
  payload: AttestationPayload;
504
527
  signature_alg: string; // "rsa-pss-sha256"
@@ -656,12 +679,23 @@ function isEvidenceMajorAccepted(specVersion: unknown): boolean {
656
679
  });
657
680
  }
658
681
 
659
- function malformed(error: string, specVersionOk = false): EvidenceBundleResult {
682
+ /**
683
+ * A bundle this kernel cannot read.
684
+ *
685
+ * `wrapperOk` is true only where the wrapper signature and `body_hash` were
686
+ * CHECKED and held before the body was found unreadable. It is then reported
687
+ * as held, because it did: `signatureOk` and `bodyHashOk` are statements about
688
+ * the wrapper, and a body this kernel cannot parse does not unsign it. The
689
+ * case reader decides whose pack a bundle is on exactly those two fields
690
+ * (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
691
+ * broke its own body shape read as a forger.
692
+ */
693
+ function malformed(error: string, specVersionOk = false, wrapperOk = false): EvidenceBundleResult {
660
694
  return {
661
695
  status: "malformed",
662
696
  specVersionOk,
663
- bodyHashOk: false,
664
- signatureOk: false,
697
+ bodyHashOk: wrapperOk,
698
+ signatureOk: wrapperOk,
665
699
  bodyType: null,
666
700
  onChainChecked: false,
667
701
  checkpoints: [],
@@ -671,8 +705,79 @@ function malformed(error: string, specVersionOk = false): EvidenceBundleResult {
671
705
  };
672
706
  }
673
707
 
674
- function stringifyErr(e: unknown): string {
675
- return e instanceof Error ? e.message : String(e);
708
+ /**
709
+ * The prefixes of the exception text this kernel writes ITSELF, in the words
710
+ * the Python kernel writes too. Anything outside this set comes from the host:
711
+ * a JSON parser, a codec, a crypto library. See `thrownDetail`.
712
+ */
713
+ const KERNEL_WORDING = ["hexToBytes: "];
714
+
715
+ /**
716
+ * The detail a finding may append about a caught exception.
717
+ *
718
+ * THE RULE: a finding carries the text of a caught exception only where this
719
+ * kernel wrote that text. A finding travels into a case reading through `case`
720
+ * `reasons`, and both kernels promise one reading and one rendered sentence for
721
+ * one set of bytes; a host JSON parser says `Unexpected token 'h', "hello" is
722
+ * not valid JSON` here and `Expecting value: line 1 column 1 (char 0)` there,
723
+ * for one pack anyone can build (gate 4 on proof#140, finding 1).
724
+ *
725
+ * Returns `": <text>"` for this kernel's own wording and the empty string for
726
+ * anything else. The label at the call site already names the check that
727
+ * failed and both kernels agree on it character for character; what is dropped
728
+ * is the host's parse offset, which no reader can act on and which was never
729
+ * the same in the two kernels.
730
+ */
731
+ function thrownDetail(e: unknown): string {
732
+ const text = e instanceof Error ? e.message : String(e);
733
+ return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
734
+ }
735
+
736
+ /**
737
+ * Decode `bytes` as UTF-8 the way every other kernel does: a byte sequence that
738
+ * is not valid UTF-8 THROWS, and a leading byte-order mark is kept rather than
739
+ * eaten.
740
+ *
741
+ * ONE CALLER LEFT, and it is not a parse. `jcsParse` (`verifier.ts`) reads
742
+ * every set of signed bytes in this kernel now; this function survives only for
743
+ * the canonicality comparison on the stamp path, which needs the DECODED TEXT
744
+ * of bytes `jcsParse` has already accepted, to compare against `jcs(body)`. It
745
+ * performs exactly the decode `jcsParse` performs, so the comparison cannot
746
+ * read the bytes differently from the parse that produced `body`.
747
+ *
748
+ * `new TextDecoder()` alone is neither strict nor BOM-preserving: it replaces
749
+ * every invalid sequence with U+FFFD and strips a BOM. A stamp body carrying
750
+ * one raw `0x80` inside a string value read `verified` here and `failed` there
751
+ * because of it (gate 4 on proof#140). The symmetric claim the first version of
752
+ * this comment made, that "Python's `json.loads` raises on both", is FALSE and
753
+ * is what hid gate 5's defect for a round: `json.loads(<bytes>)` strips a BOM
754
+ * too. Python's strict reader is `jcs_loads`, and the rule both kernels hold to
755
+ * is stated once, in `jcsParse`.
756
+ */
757
+ function decodeUtf8Strict(bytes: Uint8Array): string {
758
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
759
+ }
760
+
761
+ /**
762
+ * Name an envelope `public_key` or `signature` spelled outside the hex charset
763
+ * or at the wrong length (proof#142). This kernel's envelope layer already
764
+ * fails such an envelope, and the Python one's decoder accepts ASCII
765
+ * whitespace (proof#135), so both bundle verifiers name it here, in the same
766
+ * words, and the two kernels give one reading. Only a STRING is checked: a
767
+ * missing or non-string field is the envelope layer's to fail, and it does.
768
+ */
769
+ function envelopeHexError(what: string, envelope: unknown): string | null {
770
+ if (!isJsonObject(envelope)) return null;
771
+ for (const [name, nibbles] of [
772
+ ["public_key", 64],
773
+ ["signature", 128],
774
+ ] as const) {
775
+ const value = envelope[name];
776
+ if (typeof value === "string" && !isHexField(value, nibbles)) {
777
+ return `${what} envelope ${name} is not ${nibbles} hex characters`;
778
+ }
779
+ }
780
+ return null;
676
781
  }
677
782
 
678
783
  /**
@@ -689,7 +794,7 @@ function jsonStr(value: unknown): string {
689
794
  return JSON.stringify(value === undefined ? null : value) ?? "null";
690
795
  }
691
796
 
692
- // Verify a `sanning.evidence/v1` bundle carrying a `sanning.anchor.trace/v1` body.
797
+ // Verify a `sanning.evidence/v1` bundle and dispatch on its `body_type`.
693
798
  //
694
799
  // Returns a structured per-checkpoint + per-event result and a recomputed
695
800
  // rollup `status`. The producer's asserted `verdict.status` is surfaced
@@ -698,6 +803,16 @@ function jsonStr(value: unknown): string {
698
803
  export async function verifyEvidenceBundle(
699
804
  bundle: unknown,
700
805
  options: VerifyEvidenceOptions = {},
806
+ ): Promise<EvidenceBundleResult> {
807
+ return verifyBundle(bundle, options, false);
808
+ }
809
+
810
+ // `exportSource`: read the bundle as an export's source (evidence-export.md
811
+ // §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
812
+ async function verifyBundle(
813
+ bundle: unknown,
814
+ options: VerifyEvidenceOptions,
815
+ exportSource: boolean,
701
816
  ): Promise<EvidenceBundleResult> {
702
817
  // --- Step 1: parse + reject unknown spec_version major ---------------------
703
818
  if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
@@ -737,26 +852,44 @@ export async function verifyEvidenceBundle(
737
852
  // --- Step 2: verify the wrapper signature + recompute body_hash ------------
738
853
  const errors: string[] = [];
739
854
 
855
+ // Every wrapper hex field is read STRICTLY, before any decoder sees it
856
+ // (proof#142). The Python decoder skipped ASCII whitespace, and the wrapper
857
+ // `signature` sits outside the signed scope, so one space in it, added by
858
+ // anyone and with no key, made a pack verify there and fail here. The same
859
+ // named failure in both kernels, for each field: `public_key` and
860
+ // `signature` are 64 and 128 hex characters in either case (proof#135,
861
+ // option B), and `body_hash` is 64 LOWER-case hex characters, the form it is
862
+ // recomputed in and compared against.
740
863
  let bodyHashOk = false;
741
- try {
742
- const recomputed = await sha256Hex(utf8(jcs(b.body)));
743
- bodyHashOk = recomputed === b.body_hash;
744
- if (!bodyHashOk) {
745
- errors.push(`body_hash mismatch: bundle=${b.body_hash} recomputed=${recomputed}`);
864
+ if (!isHexField(b.body_hash, 64, true)) {
865
+ errors.push("wrapper body_hash is not 64 lowercase hex characters");
866
+ } else {
867
+ try {
868
+ const recomputed = await sha256Hex(utf8(jcs(b.body)));
869
+ bodyHashOk = recomputed === b.body_hash;
870
+ if (!bodyHashOk) {
871
+ errors.push(`body_hash mismatch: bundle=${b.body_hash} recomputed=${recomputed}`);
872
+ }
873
+ } catch (e) {
874
+ errors.push(`body canonicalization failed${thrownDetail(e)}`);
746
875
  }
747
- } catch (e) {
748
- errors.push(`body canonicalization failed: ${stringifyErr(e)}`);
749
876
  }
750
877
 
751
878
  let signatureOk = false;
752
- try {
753
- const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
754
- co_signatures?: unknown;
755
- };
756
- signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
757
- if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
758
- } catch (e) {
759
- errors.push(`wrapper signature verification error: ${stringifyErr(e)}`);
879
+ const keyFormOk = isHexField(b.public_key, 64);
880
+ const signatureFormOk = isHexField(b.signature, 128);
881
+ if (!keyFormOk) errors.push("wrapper public_key is not 64 hex characters");
882
+ if (!signatureFormOk) errors.push("wrapper signature is not 128 hex characters");
883
+ if (keyFormOk && signatureFormOk) {
884
+ try {
885
+ const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
886
+ co_signatures?: unknown;
887
+ };
888
+ signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
889
+ if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
890
+ } catch (e) {
891
+ errors.push(`wrapper signature verification error${thrownDetail(e)}`);
892
+ }
760
893
  }
761
894
 
762
895
  const assertedStatus = typeof b.verdict?.status === "string" ? b.verdict.status : null;
@@ -781,6 +914,28 @@ export async function verifyEvidenceBundle(
781
914
  // --- Body dispatch ---------------------------------------------------------
782
915
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
783
916
 
917
+ // Read as an export's source (evidence-export.md §2.4), the only body this
918
+ // kernel recognises is sanning.anchor.trace/v3. Any other body, even one it
919
+ // verifies on its own, is undetermined here, after the wrapper checks above.
920
+ if (exportSource && bodyType !== ANCHOR_TRACE_BODY_TYPE) {
921
+ errors.push(
922
+ `body_type ${JSON.stringify(bodyType)} is not an export source ` +
923
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
924
+ );
925
+ return {
926
+ status: "partial",
927
+ specVersionOk,
928
+ bodyHashOk,
929
+ signatureOk,
930
+ bodyType,
931
+ onChainChecked: false,
932
+ checkpoints: [],
933
+ events: [],
934
+ assertedStatus,
935
+ errors,
936
+ };
937
+ }
938
+
784
939
  // Attested-evidence-export body (evidence-export.md §5). The wrapper is
785
940
  // authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
786
941
  // hand off to the export verifier for steps 3–9.
@@ -800,13 +955,13 @@ export async function verifyEvidenceBundle(
800
955
  return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
801
956
  }
802
957
 
803
- if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
958
+ if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
804
959
  // The wrapper is authentic but we don't know this body's structure. Per
805
960
  // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
806
961
  // body verdict undetermined) — not a failure.
807
962
  errors.push(
808
963
  `body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
809
- `(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`,
964
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
810
965
  );
811
966
  return {
812
967
  status: "partial",
@@ -822,114 +977,120 @@ export async function verifyEvidenceBundle(
822
977
  };
823
978
  }
824
979
 
825
- return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
980
+ return verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options);
826
981
  }
827
982
 
828
983
  /**
829
- * Index a source bundle's checkpoints by the key its attestations will use.
984
+ * Recompute an export's source verdict (evidence-export.md §5 step 4).
830
985
  *
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.
986
+ * The only export source is `sanning.anchor.trace/v3` (§2.2, §2.4). Any other
987
+ * inline source body is unrecognised *as a source*, even one this kernel
988
+ * verifies on its own, such as a `sanning.stamp.evidence/v2` pack: §2.4 makes
989
+ * its source verdict undetermined, so the export cannot reach `verified`. The
990
+ * source wrapper is still checked first, because a forged source is a failure
991
+ * whatever its body.
992
+ */
993
+ async function verifyExportSource(
994
+ source: unknown,
995
+ options: VerifyEvidenceOptions,
996
+ ): Promise<EvidenceBundleResult> {
997
+ return verifyBundle(source, options, true);
998
+ }
999
+
1000
+ /**
1001
+ * Index a source bundle's checkpoints by the key its attestations use: the
1002
+ * derived `checkpoint_ref`. Only a `sanning.anchor.trace/v3` source has
1003
+ * checkpoints an attestation can resolve (evidence-export.md §2.4).
836
1004
  */
837
1005
  async function indexSourceCheckpoints(
838
1006
  bundle: EvidenceBundle,
839
1007
  ): Promise<Map<string, AnyTraceCheckpoint>> {
840
1008
  const out = new Map<string, AnyTraceCheckpoint>();
1009
+ if (!isJsonObject(bundle) || bundle.body_type !== ANCHOR_TRACE_BODY_TYPE) return out;
841
1010
  const sb = bundle.body as AnchorTraceBody | undefined;
842
- const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
843
1011
  if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints)) return out;
844
1012
  for (const cp of sb.checkpoints as AnyTraceCheckpoint[]) {
845
1013
  if (!cp || typeof cp !== "object") continue;
846
- let recordBytes: Uint8Array | undefined;
1014
+ let eventBytes: Uint8Array | undefined;
847
1015
  try {
848
- if (typeof cp.record_bytes === "string") recordBytes = hexToBytes(cp.record_bytes);
1016
+ if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
849
1017
  } catch {
850
- recordBytes = undefined;
1018
+ eventBytes = undefined;
851
1019
  }
852
- const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
1020
+ const keyed = await resolveCheckpointKey(cp, eventBytes);
853
1021
  if (keyed.key !== null && !out.has(keyed.key)) out.set(keyed.key, cp);
854
1022
  }
855
1023
  return out;
856
1024
  }
857
1025
 
858
- /** Either shape of checkpoint, before the form is resolved. */
859
- type AnyTraceCheckpoint = Partial<TraceCheckpoint> & Partial<TraceCheckpointV1>;
1026
+ /** A checkpoint as it arrives, before any field is trusted. */
1027
+ type AnyTraceCheckpoint = Partial<TraceCheckpoint> & { tx_id?: unknown };
860
1028
 
861
1029
  /**
862
- * Resolve a checkpoint's join key and its witness reference, for whichever
863
- * body form we are reading.
1030
+ * Resolve a checkpoint's join key and its witness reference.
864
1031
  *
865
- * ── The rule that matters, and why it is a reject rather than a preference ──
1032
+ * ── Why a retired spelling is a reject rather than a preference ──
866
1033
  *
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.
1034
+ * A checkpoint carrying a retired spelling beside the current one is two
1035
+ * answers to one question inside one signature. A reader that silently prefers
1036
+ * one gives a different answer from a reader that prefers the other, over the
1037
+ * same signed bytes, and both look like they verified. So `tx_id` and
1038
+ * `record_bytes` are rejected rather than resolved.
873
1039
  *
874
- * ── Why v2's key is recomputed ──
1040
+ * ── Why the key is recomputed ──
875
1041
  *
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.
1042
+ * The key IS the bytes: relabelling a checkpoint and every event pointing at it
1043
+ * fails here, because nothing is taken from the label.
882
1044
  */
883
1045
  async function resolveCheckpointKey(
884
1046
  cp: AnyTraceCheckpoint,
885
- recordBytes: Uint8Array | undefined,
886
- isV1: boolean,
887
- ): Promise<{ key: string | null; anchorRef: AnchorRef | null; errors: string[] }> {
1047
+ eventBytes: Uint8Array | undefined,
1048
+ ): Promise<{
1049
+ key: string | null;
1050
+ anchorRef: AnchorRef | null;
1051
+ anchorRefMalformed: boolean;
1052
+ errors: string[];
1053
+ }> {
888
1054
  const errors: string[] = [];
889
-
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
- }
1055
+ // Every early return below leaves before `anchor_ref` is ever looked at, so
1056
+ // nothing there is "malformed": it is unexamined, which is not the same claim.
1057
+ const unexamined = { key: null, anchorRef: null, anchorRefMalformed: false, errors };
907
1058
 
908
1059
  if (cp.tx_id !== undefined) {
909
1060
  errors.push(
910
- "sanning.anchor.trace/v2 checkpoint carries legacy tx_id -- one bundle speaks one vocabulary",
1061
+ `${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`,
911
1062
  );
912
- return { key: null, anchorRef: null, errors };
1063
+ return unexamined;
1064
+ }
1065
+ const legacy = legacyEventBytesError(cp, "checkpoint", ANCHOR_TRACE_BODY_TYPE);
1066
+ if (legacy !== null) {
1067
+ errors.push(legacy);
1068
+ return unexamined;
913
1069
  }
914
1070
  if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
915
1071
  errors.push("checkpoint missing checkpoint_ref");
916
- return { key: null, anchorRef: null, errors };
1072
+ return unexamined;
917
1073
  }
918
- if (recordBytes === undefined) {
1074
+ if (eventBytes === undefined) {
919
1075
  // 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");
921
- return { key: null, anchorRef: null, errors };
1076
+ errors.push("checkpoint_ref cannot be verified -- event_bytes missing or not hex");
1077
+ return unexamined;
922
1078
  }
923
- const derived = await sha256Hex(recordBytes);
1079
+ const derived = await sha256Hex(eventBytes);
924
1080
  if (derived !== cp.checkpoint_ref.toLowerCase()) {
925
1081
  errors.push(
926
- `checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`,
1082
+ `checkpoint_ref does not match SHA-256(event_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`,
927
1083
  );
928
- return { key: null, anchorRef: null, errors };
1084
+ return unexamined;
929
1085
  }
930
1086
 
931
- const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
932
- return { key: derived, anchorRef, errors };
1087
+ const parsed = parseAnchorRef(cp.anchor_ref, errors);
1088
+ return {
1089
+ key: derived,
1090
+ anchorRef: parsed.ref,
1091
+ anchorRefMalformed: parsed.malformed,
1092
+ errors,
1093
+ };
933
1094
  }
934
1095
 
935
1096
  /**
@@ -949,46 +1110,64 @@ async function resolveCheckpointKey(
949
1110
  * So this validates the SHAPE and never the vocabulary. A malformed
950
1111
  * `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
951
1112
  * the reader, and only the first is the bundle's fault.
1113
+ *
1114
+ * ── Absent and malformed are not the same null (proof#120) ──
1115
+ *
1116
+ * Both leave no `AnchorRef` to re-fetch, and both used to be reported the same
1117
+ * way downstream: an all-`unreachable` on-chain result, although no gateway was
1118
+ * asked in either case. For the ABSENT case that is at least the note
1119
+ * `evidence-bundle.md` §5.1.1 reserves for "a checkpoint with no `anchor_ref`".
1120
+ * For the MALFORMED case it is a manufactured outage: the bundle named a
1121
+ * witness, the name was unreadable, and the reader was told the public record
1122
+ * could not be reached. So the two come back as different facts, and `malformed`
1123
+ * carries the difference.
952
1124
  */
953
- function parseAnchorRef(raw: unknown, errors: string[]): AnchorRef | null {
954
- if (raw === undefined || raw === null) return null; // placed, not yet witnessed
1125
+ function parseAnchorRef(
1126
+ raw: unknown,
1127
+ errors: string[],
1128
+ ): { ref: AnchorRef | null; malformed: boolean } {
1129
+ if (raw === undefined || raw === null) {
1130
+ return { ref: null, malformed: false }; // placed, not yet witnessed
1131
+ }
955
1132
  if (typeof raw !== "object" || Array.isArray(raw)) {
956
1133
  errors.push("anchor_ref must be an object");
957
- return null;
1134
+ return { ref: null, malformed: true };
958
1135
  }
959
1136
  const o = raw as Record<string, unknown>;
960
1137
  if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
961
1138
  errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
962
- return null;
1139
+ return { ref: null, malformed: true };
963
1140
  }
964
1141
  if (typeof o.id !== "string" || o.id === "") {
965
1142
  errors.push("anchor_ref.id is missing or empty");
966
- return null;
1143
+ return { ref: null, malformed: true };
967
1144
  }
968
1145
  const ref = o.ref;
969
1146
  if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
970
1147
  errors.push("anchor_ref.ref must be an object when present");
971
- return null;
1148
+ return { ref: null, malformed: true };
972
1149
  }
973
1150
  return {
974
- kind: o.kind,
975
- id: o.id,
976
- ...(ref === undefined ? {} : { ref: ref as Record<string, unknown> }),
1151
+ ref: {
1152
+ kind: o.kind,
1153
+ id: o.id,
1154
+ ...(ref === undefined ? {} : { ref: ref as Record<string, unknown> }),
1155
+ },
1156
+ malformed: false,
977
1157
  };
978
1158
  }
979
1159
 
980
1160
  async function verifyAnchorTrace(
981
1161
  b: EvidenceBundle,
982
- bodyType: string,
983
1162
  specVersionOk: boolean,
984
1163
  assertedStatus: string | null,
985
1164
  errors: string[],
986
1165
  options: VerifyEvidenceOptions,
987
1166
  ): Promise<EvidenceBundleResult> {
988
1167
  const body = b.body as AnchorTraceBody;
989
- const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
1168
+ const bodyType = ANCHOR_TRACE_BODY_TYPE;
990
1169
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
991
- return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
1170
+ return malformed(`${bodyType} body must have checkpoints[] and events[]`, true, true);
992
1171
  }
993
1172
 
994
1173
  const gateways = options.gateways ?? [];
@@ -1005,6 +1184,11 @@ async function verifyAnchorTrace(
1005
1184
  const byKey = new Map<string, AnyTraceCheckpoint>();
1006
1185
  const anchorByKey = new Map<string, AnchorRef | null>();
1007
1186
  const checkpointResults: CheckpointResult[] = [];
1187
+ // proof#120. Kept beside the results rather than on them: "the bundle named
1188
+ // a witness we could not read" is an input defect the checkpoint's own
1189
+ // `errors` already states, not a new dimension of the published verdict, and
1190
+ // `CheckpointResult` is a shape downstream surfaces render.
1191
+ const malformedAnchorRef = new Set<CheckpointResult>();
1008
1192
  for (const rawCp of body.checkpoints as unknown[]) {
1009
1193
  // `checkpoints[]` is attacker-shaped, and a verifier must never raise on
1010
1194
  // adversarial input: a null entry used to reach `cp.tx_id` and hand the
@@ -1032,16 +1216,16 @@ async function verifyAnchorTrace(
1032
1216
  let envelopeOk = false;
1033
1217
  let merkleRootOk = false;
1034
1218
 
1035
- // Decode record_bytes first: in v2 the join key is DERIVED from it, so it
1219
+ // Decode event_bytes first: in v2 the join key is DERIVED from it, so it
1036
1220
  // is an input to identity, not just to the payload binding below.
1037
- let recordBytes: Uint8Array | undefined;
1221
+ let eventBytes: Uint8Array | undefined;
1038
1222
  try {
1039
- if (typeof cp.record_bytes === "string") recordBytes = hexToBytes(cp.record_bytes);
1223
+ if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
1040
1224
  } catch (e) {
1041
- cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
1225
+ cpErrors.push(`checkpoint event_bytes is not hex${thrownDetail(e)}`);
1042
1226
  }
1043
1227
 
1044
- const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
1228
+ const keyed = await resolveCheckpointKey(cp, eventBytes);
1045
1229
  cpErrors.push(...keyed.errors);
1046
1230
  // A duplicate key is a HARD failure, not a note. `byKey` below keeps the
1047
1231
  // FIRST occurrence, so a second checkpoint claiming the same key is
@@ -1055,7 +1239,7 @@ async function verifyAnchorTrace(
1055
1239
  let duplicateKey = false;
1056
1240
  if (keyed.key !== null) {
1057
1241
  if (byKey.has(keyed.key)) {
1058
- cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
1242
+ cpErrors.push(`duplicate checkpoint checkpoint_ref ${keyed.key}`);
1059
1243
  duplicateKey = true;
1060
1244
  } else {
1061
1245
  byKey.set(keyed.key, cp);
@@ -1068,16 +1252,19 @@ async function verifyAnchorTrace(
1068
1252
  try {
1069
1253
  const res = await verifyEnvelope(
1070
1254
  cp.envelope as Envelope,
1071
- recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
1255
+ eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1072
1256
  );
1073
1257
  // A checkpoint must fully bind: signature + the committed record matching
1074
1258
  // payload_hash. An undetermined binding (no record) is not enough to
1075
1259
  // anchor the events' inclusion claims to a trustworthy root.
1076
- envelopeOk = res.ok && res.payloadHashOk === true;
1260
+ const hexError = envelopeHexError("checkpoint", cp.envelope);
1261
+ envelopeOk = res.ok && res.payloadHashOk === true && hexError === null;
1077
1262
  // See the event loop below: a degenerate envelope is a malformed input,
1078
1263
  // not a failed check (proof#76).
1079
1264
  if (!isJsonObject(cp.envelope)) {
1080
1265
  cpErrors.push("checkpoint envelope is not a JSON object");
1266
+ } else if (hexError !== null) {
1267
+ cpErrors.push(hexError);
1081
1268
  } else if (!res.signatureOk) {
1082
1269
  cpErrors.push("checkpoint envelope signature failed");
1083
1270
  }
@@ -1085,27 +1272,29 @@ async function verifyAnchorTrace(
1085
1272
  // proof#95: a malformed skeleton field fails the envelope with a valid
1086
1273
  // signature and binding; name it rather than fail with no reason.
1087
1274
  for (const m of res.errors) if (m.startsWith("malformed ")) cpErrors.push(`checkpoint envelope ${m}`);
1275
+ const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
1276
+ if (specFinding !== null) cpErrors.push(specFinding);
1088
1277
  // WHY the binding is undetermined decides which sentence is true.
1089
- // record_bytes really absent is one thing; record_bytes present and
1278
+ // event_bytes really absent is one thing; event_bytes present and
1090
1279
  // unbindable (a degenerate envelope, unreadable hex) is another, and
1091
1280
  // naming the wrong cause sends a reader to look for a field that is
1092
1281
  // sitting right there (proof#76).
1093
1282
  if (res.payloadHashOk === null) {
1094
1283
  cpErrors.push(
1095
- recordBytes === undefined
1096
- ? "checkpoint record_bytes missing -- root untrusted"
1284
+ eventBytes === undefined
1285
+ ? "checkpoint event_bytes missing -- root untrusted"
1097
1286
  : "checkpoint record binding not checked: root untrusted",
1098
1287
  );
1099
1288
  }
1100
1289
  } catch (e) {
1101
- cpErrors.push(`checkpoint envelope verify error: ${stringifyErr(e)}`);
1290
+ cpErrors.push(`checkpoint envelope verify error${thrownDetail(e)}`);
1102
1291
  }
1103
1292
 
1104
1293
  // The committed record's merkle_root must equal the checkpoint's claimed
1105
1294
  // merkle_root (recompute, don't trust the bundle's top-level claim).
1106
- if (recordBytes !== undefined) {
1295
+ if (eventBytes !== undefined) {
1107
1296
  try {
1108
- const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
1297
+ const record = jcsParse(eventBytes) as {
1109
1298
  event?: { merkle_root?: unknown };
1110
1299
  };
1111
1300
  const committedRoot = record.event?.merkle_root;
@@ -1117,7 +1306,7 @@ async function verifyAnchorTrace(
1117
1306
  );
1118
1307
  }
1119
1308
  } catch (e) {
1120
- cpErrors.push(`checkpoint record is not JSON: ${stringifyErr(e)}`);
1309
+ cpErrors.push(`checkpoint record is not JSON${thrownDetail(e)}`);
1121
1310
  }
1122
1311
  }
1123
1312
 
@@ -1137,6 +1326,9 @@ async function verifyAnchorTrace(
1137
1326
  ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
1138
1327
  errors: cpErrors,
1139
1328
  });
1329
+ if (keyed.anchorRefMalformed) {
1330
+ malformedAnchorRef.add(checkpointResults[checkpointResults.length - 1]);
1331
+ }
1140
1332
  }
1141
1333
 
1142
1334
  // --- Step 4: per-event ------------------------------------------------------
@@ -1164,18 +1356,42 @@ async function verifyAnchorTrace(
1164
1356
  let envelopeOk = false;
1165
1357
  let payloadBindingOk: boolean | null = null;
1166
1358
 
1167
- let recordBytes: Uint8Array | undefined;
1359
+ // A retired field name under this major is a reject, never an ignored
1360
+ // unknown field: ignored, it reads as a withheld record (W-3).
1361
+ const legacyError = legacyEventBytesError(ev, "event", bodyType);
1362
+ if (legacyError !== null) evErrors.push(legacyError);
1363
+
1364
+ // MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
1365
+ //
1366
+ // `event_bytes` that will not decode leaves `eventBytes` undefined, and an
1367
+ // undefined `eventBytes` is exactly how a WITHHELD record is spelled one
1368
+ // line down. So an event carrying garbage in place of its record used to
1369
+ // reach the same tri-state as an event carrying nothing: `payloadHashOk`
1370
+ // null, `ok` true, a leading tick on the line, and a `verified` rollup over
1371
+ // a record nobody could read. The only thing wrong with the pack was the
1372
+ // bytes, and it was the one thing the verdict did not say.
1373
+ //
1374
+ // A record the producer chose not to disclose is the customer exercising
1375
+ // minimum disclosure. A record that is present and unreadable is a defect
1376
+ // in the artifact, and this kernel's standing rule (the same one
1377
+ // `checkpoints[]` already applies through `keyed.errors`) is that a defect
1378
+ // we can see fails.
1379
+ let eventBytes: Uint8Array | undefined;
1380
+ let eventBytesMalformed = false;
1168
1381
  try {
1169
- if (typeof ev.record_bytes === "string") recordBytes = hexToBytes(ev.record_bytes);
1382
+ if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
1170
1383
  } catch (e) {
1171
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1384
+ eventBytesMalformed = true;
1385
+ evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
1172
1386
  }
1173
1387
  try {
1174
1388
  const res = await verifyEnvelope(
1175
1389
  ev.envelope,
1176
- recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
1390
+ eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1177
1391
  );
1178
- envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
1392
+ const hexError = envelopeHexError("event", ev.envelope);
1393
+ // ok tolerates an undetermined binding (withheld record)
1394
+ envelopeOk = res.ok && hexError === null;
1179
1395
  payloadBindingOk = res.payloadHashOk;
1180
1396
  // P-10: report what could not be determined, never a fault that was never
1181
1397
  // observed. There is no envelope here, so no signature was checked and no
@@ -1186,6 +1402,8 @@ async function verifyAnchorTrace(
1186
1402
  // the producer at all (proof#76).
1187
1403
  if (!isJsonObject(ev.envelope)) {
1188
1404
  evErrors.push("event envelope is not a JSON object");
1405
+ } else if (hexError !== null) {
1406
+ evErrors.push(hexError);
1189
1407
  } else if (!res.signatureOk) {
1190
1408
  evErrors.push("event envelope signature failed");
1191
1409
  }
@@ -1193,19 +1411,20 @@ async function verifyAnchorTrace(
1193
1411
  // A malformed skeleton field fails the envelope with a valid signature
1194
1412
  // and binding (proof#95), so without this the event fails with no reason.
1195
1413
  for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
1414
+ const specFinding = specVersionFinding("event", ev.envelope, res);
1415
+ if (specFinding !== null) evErrors.push(specFinding);
1196
1416
  } catch (e) {
1197
- evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1417
+ evErrors.push(`event envelope verify error${thrownDetail(e)}`);
1198
1418
  }
1199
1419
 
1200
1420
  // Bind the event to exactly one checkpoint in the bundle, then verify
1201
1421
  // RFC 9162 inclusion against THAT checkpoint's merkle_root.
1202
1422
  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";
1423
+ // An inclusion carrying the retired `checkpoint_tx_id` spelling is a mixed
1424
+ // bundle, and resolving it anyway is how an event silently binds to a
1425
+ // checkpoint nobody meant.
1426
+ const keyField = "checkpoint_ref";
1427
+ const wrongField = "checkpoint_tx_id";
1209
1428
  const inclAsRecord = incl as unknown as Record<string, unknown> | undefined;
1210
1429
  const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
1211
1430
  const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
@@ -1258,12 +1477,12 @@ async function verifyAnchorTrace(
1258
1477
  );
1259
1478
  }
1260
1479
  } catch (e) {
1261
- evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
1480
+ evErrors.push(`inclusion verify error${thrownDetail(e)}`);
1262
1481
  }
1263
1482
  }
1264
1483
 
1265
1484
  // Content (raw-log) binding — shared with every other event-carrying body.
1266
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1485
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
1267
1486
  const contentOk = contentRes.contentOk;
1268
1487
  evErrors.push(...contentRes.errors);
1269
1488
 
@@ -1279,7 +1498,15 @@ async function verifyAnchorTrace(
1279
1498
  // present checkpoint, and its disclosed content did not MISMATCH. A
1280
1499
  // withheld record or undisclosed content (binding/content null) does not
1281
1500
  // fail it.
1282
- ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false,
1501
+ // `!eventBytesMalformed`: present-and-unreadable record bytes are a
1502
+ // defect in the artifact, never a disclosure choice (proof#85).
1503
+ ok:
1504
+ envelopeOk &&
1505
+ inclusionOk &&
1506
+ checkpointBound &&
1507
+ contentOk !== false &&
1508
+ legacyError === null &&
1509
+ !eventBytesMalformed,
1283
1510
  errors: evErrors,
1284
1511
  });
1285
1512
  }
@@ -1287,6 +1514,8 @@ async function verifyAnchorTrace(
1287
1514
  // --- Step 5: optional on-chain re-fetch ------------------------------------
1288
1515
  const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
1289
1516
  let gatewayUnavailable = false;
1517
+ let uncheckedKind = false;
1518
+ let uncheckedId = false;
1290
1519
  if (onChainChecked) {
1291
1520
  if (typeof fetchImpl !== "function") {
1292
1521
  gatewayUnavailable = true;
@@ -1301,6 +1530,20 @@ async function verifyAnchorTrace(
1301
1530
  // the run to partial) and NEVER as a mismatch. Treating "we never
1302
1531
  // anchored it yet" as "the chain disagrees" would be the same false
1303
1532
  // accusation this whole change is removing.
1533
+ // proof#120. An `anchor_ref` that failed structural validation is not
1534
+ // an absent one. Nothing was fetched, so the witness dimension is NOT
1535
+ // CHECKED — `onChain: null`, exactly as when the caller supplied no
1536
+ // adapter — and it must not raise `gatewayUnavailable`, which is what
1537
+ // puts "could not be re-fetched from the supplied gateways" on the
1538
+ // bundle. Reporting an outage nobody observed is a claim about the
1539
+ // public record made out of a defect in the artifact, and verify-page
1540
+ // renders it word for word. The run still fails: the structural error
1541
+ // is already in `cpr.errors` and already in `ok` above.
1542
+ if (cpr.anchorRef === null && malformedAnchorRef.has(cpr)) {
1543
+ cpr.onChain = null;
1544
+ cpr.onChainOk = null;
1545
+ continue;
1546
+ }
1304
1547
  if (cpr.anchorRef === null) {
1305
1548
  gatewayUnavailable = true;
1306
1549
  cpr.onChain = {
@@ -1314,8 +1557,10 @@ async function verifyAnchorTrace(
1314
1557
  }
1315
1558
  const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
1316
1559
  cpr.onChain = outcome.onChain;
1317
- cpr.onChainOk = outcome.onChain.onChainOk;
1560
+ cpr.onChainOk = outcome.onChain?.onChainOk ?? null;
1318
1561
  if (outcome.unavailable) gatewayUnavailable = true;
1562
+ if (outcome.unchecked === "kind") uncheckedKind = true;
1563
+ if (outcome.unchecked === "id") uncheckedId = true;
1319
1564
  if (outcome.error) cpr.errors.push(outcome.error);
1320
1565
  // Fold the on-chain dimension in: a MISMATCH (false) fails the
1321
1566
  // checkpoint — the proof was not anchored as claimed. An UNREACHABLE
@@ -1347,12 +1592,16 @@ async function verifyAnchorTrace(
1347
1592
  let status: EvidenceStatus;
1348
1593
  if (anyHardFailure) {
1349
1594
  status = "failed";
1350
- } else if (gatewayUnavailable) {
1595
+ } else if (gatewayUnavailable || uncheckedKind || uncheckedId) {
1351
1596
  // Every offline check passed and nothing MISMATCHED, but a requested
1352
1597
  // on-chain confirmation could not be made — the bundle is offline-sound,
1353
1598
  // its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
1354
1599
  status = "partial";
1355
- errors.push("one or more checkpoints could not be re-fetched from the supplied gateways");
1600
+ pushRefetchGapNotes(errors, "checkpoints", {
1601
+ unavailable: gatewayUnavailable,
1602
+ kind: uncheckedKind,
1603
+ id: uncheckedId,
1604
+ });
1356
1605
  } else if (anyWithheldRecord) {
1357
1606
  // Everything that COULD be checked passed; some record bytes were withheld
1358
1607
  // so their semantic binding is undetermined (not a failure) — "partial".
@@ -1444,7 +1693,7 @@ function isNonNegInt(n: unknown): n is number {
1444
1693
  // ===========================================================================
1445
1694
  // Disclosed-content binding, shared by every body that carries events.
1446
1695
  //
1447
- // Extracted for `sanning.stamp.evidence/v1` (§5.3) rather than copied. The
1696
+ // Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
1448
1697
  // rawLog -> content_hash link is the one step the chain
1449
1698
  // (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
1450
1699
  // not otherwise cover, and a second copy of it is a second place for the
@@ -1456,7 +1705,7 @@ function isNonNegInt(n: unknown): n is number {
1456
1705
  async function verifyDisclosedContent(
1457
1706
  ev: { envelope?: unknown; content?: unknown },
1458
1707
  eventId: string,
1459
- recordBytes: Uint8Array | undefined,
1708
+ eventBytes: Uint8Array | undefined,
1460
1709
  sideContent: VerifyEvidenceOptions["content"],
1461
1710
  ): Promise<{ contentOk: boolean | null; errors: string[] }> {
1462
1711
  const evErrors: string[] = [];
@@ -1464,9 +1713,9 @@ async function verifyDisclosedContent(
1464
1713
 
1465
1714
  const committed: string[] = [];
1466
1715
  let recordContentHash: string | undefined;
1467
- if (recordBytes !== undefined) {
1716
+ if (eventBytes !== undefined) {
1468
1717
  try {
1469
- const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
1718
+ const record = jcsParse(eventBytes) as {
1470
1719
  event?: { content_hash?: unknown };
1471
1720
  };
1472
1721
  const ch = record.event?.content_hash;
@@ -1575,7 +1824,7 @@ async function verifyStampEntry(
1575
1824
  try {
1576
1825
  bytes = hexToBytes(e.stamp_bytes);
1577
1826
  } catch (err) {
1578
- errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
1827
+ errors.push(`stamp_bytes is not hex${thrownDetail(err)}`);
1579
1828
  return { result: base };
1580
1829
  }
1581
1830
  // Gated by `isLowerHex` above, so this is already the exact lowercase hex of
@@ -1608,7 +1857,7 @@ async function verifyStampEntry(
1608
1857
  // --- the body itself ------------------------------------------------------
1609
1858
  let body: StampBody;
1610
1859
  try {
1611
- const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
1860
+ const parsed: unknown = jcsParse(bytes);
1612
1861
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1613
1862
  errors.push("stamp body is not a JSON object");
1614
1863
  return { result: base, witnessedHex };
@@ -1618,12 +1867,12 @@ async function verifyStampEntry(
1618
1867
  // every submit attempt for exactly this reason: a non-canonical body makes
1619
1868
  // a retry a DIFFERENT submission, and makes every independent re-derivation
1620
1869
  // of the stamp disagree with the one that was witnessed.
1621
- if (jcs(body as unknown as Record<string, unknown>) !== new TextDecoder().decode(bytes)) {
1870
+ if (jcs(body as unknown as Record<string, unknown>) !== decodeUtf8Strict(bytes)) {
1622
1871
  errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
1623
1872
  return { result: base, witnessedHex };
1624
1873
  }
1625
1874
  } catch (err) {
1626
- errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
1875
+ errors.push(`stamp body is not JSON${thrownDetail(err)}`);
1627
1876
  return { result: base, witnessedHex };
1628
1877
  }
1629
1878
 
@@ -1666,7 +1915,10 @@ async function verifyStampEntry(
1666
1915
  // --- the sub-tree ranges --------------------------------------------------
1667
1916
  base.subtreesOk = checkSubtrees(body, base, errors);
1668
1917
 
1669
- base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
1918
+ // A stamp needs no malformed/absent split: the §5.2 re-fetch path already
1919
+ // leaves `onChain` null whenever there is no `AnchorRef`, so it never
1920
+ // synthesised the outage proof#120 is about.
1921
+ base.anchorRef = parseAnchorRef(e.anchor_ref, errors).ref;
1670
1922
 
1671
1923
  base.ok =
1672
1924
  base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
@@ -1762,7 +2014,7 @@ async function verifyStampTrace(
1762
2014
  ): Promise<EvidenceBundleResult> {
1763
2015
  const body = b.body as StampTraceBody;
1764
2016
  if (!Array.isArray(body?.stamps)) {
1765
- return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
2017
+ return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true, true);
1766
2018
  }
1767
2019
 
1768
2020
  const seen = new Set<string>();
@@ -1781,7 +2033,7 @@ async function verifyStampTrace(
1781
2033
  // other — asks every consumer to remember which wrapper it is holding before
1782
2034
  // it can trust `onChain: null`, and that is exactly the kind of per-body
1783
2035
  // exception a reader eventually gets wrong.
1784
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(
2036
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
1785
2037
  stamps,
1786
2038
  witnessed,
1787
2039
  options,
@@ -1801,13 +2053,15 @@ async function verifyStampTrace(
1801
2053
  // does not have.
1802
2054
  status = "partial";
1803
2055
  errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
1804
- } else if (gatewayUnavailable || anyUnwitnessed) {
2056
+ } else if (gatewayUnavailable || uncheckedKind || uncheckedId || anyUnwitnessed) {
1805
2057
  // Both hold the bundle at `partial`, and both get said — see §5.3's rollup
1806
2058
  // for why one note is not enough when there are two different gaps.
1807
2059
  status = "partial";
1808
- if (gatewayUnavailable) {
1809
- errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1810
- }
2060
+ pushRefetchGapNotes(errors, "stamps", {
2061
+ unavailable: gatewayUnavailable,
2062
+ kind: uncheckedKind,
2063
+ id: uncheckedId,
2064
+ });
1811
2065
  if (anyUnwitnessed) {
1812
2066
  // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1813
2067
  // sealed and unwitnessed: `retained-by-sanning`, which is a statement
@@ -1840,7 +2094,7 @@ async function verifyStampTrace(
1840
2094
  }
1841
2095
 
1842
2096
  // ===========================================================================
1843
- // `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
2097
+ // `sanning.stamp.evidence/v2`: the two halves, bound (evidence-bundle.md §5.3)
1844
2098
  //
1845
2099
  // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
1846
2100
  // the sub-tree partition check). The event half reuses the same primitives the
@@ -1863,7 +2117,11 @@ async function verifyStampEvidence(
1863
2117
  ): Promise<EvidenceBundleResult> {
1864
2118
  const body = b.body as StampEvidenceBody;
1865
2119
  if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
1866
- return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true);
2120
+ return malformed(
2121
+ `${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`,
2122
+ true,
2123
+ true,
2124
+ );
1867
2125
  }
1868
2126
 
1869
2127
  // --- The stamp half: §5.2's rules, §5.2's code ----------------------------
@@ -1918,18 +2176,42 @@ async function verifyStampEvidence(
1918
2176
  let envelopeOk = false;
1919
2177
  let payloadBindingOk: boolean | null = null;
1920
2178
 
1921
- let recordBytes: Uint8Array | undefined;
2179
+ // A retired field name under this major is a reject, never an ignored
2180
+ // unknown field: ignored, it reads as a withheld record (W-3).
2181
+ const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
2182
+ if (legacyError !== null) evErrors.push(legacyError);
2183
+
2184
+ // MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
2185
+ //
2186
+ // `event_bytes` that will not decode leaves `eventBytes` undefined, and an
2187
+ // undefined `eventBytes` is exactly how a WITHHELD record is spelled one
2188
+ // line down. So an event carrying garbage in place of its record used to
2189
+ // reach the same tri-state as an event carrying nothing: `payloadHashOk`
2190
+ // null, `ok` true, a leading tick on the line, and a `verified` rollup over
2191
+ // a record nobody could read. The only thing wrong with the pack was the
2192
+ // bytes, and it was the one thing the verdict did not say.
2193
+ //
2194
+ // A record the producer chose not to disclose is the customer exercising
2195
+ // minimum disclosure. A record that is present and unreadable is a defect
2196
+ // in the artifact, and this kernel's standing rule (the same one
2197
+ // `checkpoints[]` already applies through `keyed.errors`) is that a defect
2198
+ // we can see fails.
2199
+ let eventBytes: Uint8Array | undefined;
2200
+ let eventBytesMalformed = false;
1922
2201
  try {
1923
- if (typeof ev.record_bytes === "string") recordBytes = hexToBytes(ev.record_bytes);
2202
+ if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
1924
2203
  } catch (e) {
1925
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
2204
+ eventBytesMalformed = true;
2205
+ evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
1926
2206
  }
1927
2207
  try {
1928
2208
  const res = await verifyEnvelope(
1929
2209
  ev.envelope,
1930
- recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
2210
+ eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1931
2211
  );
1932
- envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
2212
+ const hexError = envelopeHexError("event", ev.envelope);
2213
+ // tolerates an undetermined binding (withheld record)
2214
+ envelopeOk = res.ok && hexError === null;
1933
2215
  payloadBindingOk = res.payloadHashOk;
1934
2216
  // P-10: report what could not be determined, never a fault that was never
1935
2217
  // observed. There is no envelope here, so no signature was checked and no
@@ -1940,6 +2222,8 @@ async function verifyStampEvidence(
1940
2222
  // the producer at all (proof#76).
1941
2223
  if (!isJsonObject(ev.envelope)) {
1942
2224
  evErrors.push("event envelope is not a JSON object");
2225
+ } else if (hexError !== null) {
2226
+ evErrors.push(hexError);
1943
2227
  } else if (!res.signatureOk) {
1944
2228
  evErrors.push("event envelope signature failed");
1945
2229
  }
@@ -1947,8 +2231,10 @@ async function verifyStampEvidence(
1947
2231
  // A malformed skeleton field fails the envelope with a valid signature
1948
2232
  // and binding (proof#95), so without this the event fails with no reason.
1949
2233
  for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
2234
+ const specFinding = specVersionFinding("event", ev.envelope, res);
2235
+ if (specFinding !== null) evErrors.push(specFinding);
1950
2236
  } catch (e) {
1951
- evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
2237
+ evErrors.push(`event envelope verify error${thrownDetail(e)}`);
1952
2238
  }
1953
2239
 
1954
2240
  // --- The join (§5.3.3 rule 1) -------------------------------------------
@@ -2011,12 +2297,12 @@ async function verifyStampEvidence(
2011
2297
  );
2012
2298
  }
2013
2299
  } catch (e) {
2014
- evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
2300
+ evErrors.push(`inclusion verify error${thrownDetail(e)}`);
2015
2301
  }
2016
2302
  }
2017
2303
  }
2018
2304
 
2019
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
2305
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
2020
2306
  const contentOk = contentRes.contentOk;
2021
2307
  evErrors.push(...contentRes.errors);
2022
2308
 
@@ -2028,13 +2314,21 @@ async function verifyStampEvidence(
2028
2314
  // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
2029
2315
  checkpointBound: stampBound,
2030
2316
  contentOk,
2031
- ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
2317
+ // `!eventBytesMalformed`: present-and-unreadable record bytes are a
2318
+ // defect in the artifact, never a disclosure choice (proof#85).
2319
+ ok:
2320
+ envelopeOk &&
2321
+ inclusionOk &&
2322
+ stampBound &&
2323
+ contentOk !== false &&
2324
+ legacyError === null &&
2325
+ !eventBytesMalformed,
2032
2326
  errors: evErrors,
2033
2327
  });
2034
2328
  }
2035
2329
 
2036
2330
  // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
2037
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(
2331
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
2038
2332
  stamps,
2039
2333
  witnessed,
2040
2334
  options,
@@ -2054,16 +2348,31 @@ async function verifyStampEvidence(
2054
2348
  } else if (stamps.length === 0 && eventResults.length === 0) {
2055
2349
  status = "partial";
2056
2350
  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.
2351
+ } else if (
2352
+ eventResults.length === 0 ||
2353
+ gatewayUnavailable ||
2354
+ uncheckedKind ||
2355
+ uncheckedId ||
2356
+ anyUnwitnessed
2357
+ ) {
2358
+ // Each holds the bundle at `partial`, and EACH gets said. A pack with no
2359
+ // events, a stamp nobody witnessed and a witness we could not reach are
2360
+ // different gaps with different fixes, and an auditor told only about one
2361
+ // would read the others as fine. The order is fixed so the two kernels
2362
+ // emit the same list.
2063
2363
  status = "partial";
2064
- if (gatewayUnavailable) {
2065
- errors.push("one or more stamps could not be re-fetched from the supplied gateways");
2364
+ if (eventResults.length === 0) {
2365
+ // §5.3.2: an events-empty body is exactly a §5.2 stamp trace, and is
2366
+ // partial, never verified. It says nothing about any event.
2367
+ errors.push(
2368
+ `${STAMP_EVIDENCE_BODY_TYPE} body carries no events -- it evidences no event, only its stamps`,
2369
+ );
2066
2370
  }
2371
+ pushRefetchGapNotes(errors, "stamps", {
2372
+ unavailable: gatewayUnavailable,
2373
+ kind: uncheckedKind,
2374
+ id: uncheckedId,
2375
+ });
2067
2376
  if (anyUnwitnessed) {
2068
2377
  // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
2069
2378
  // the independent part, so the events resting on it inherit `partial`.
@@ -2129,18 +2438,21 @@ async function refetchStamps(
2129
2438
  witnessed: (string | undefined)[],
2130
2439
  options: VerifyEvidenceOptions,
2131
2440
  errors: string[],
2132
- ): Promise<{ onChainChecked: boolean; gatewayUnavailable: boolean }> {
2441
+ ): Promise<StampRefetchSummary> {
2133
2442
  const gateways = options.gateways ?? [];
2134
2443
  const onChainChecked = gateways.length > 0;
2135
- if (!onChainChecked) return { onChainChecked, gatewayUnavailable: false };
2444
+ const none = { gatewayUnavailable: false, uncheckedKind: false, uncheckedId: false };
2445
+ if (!onChainChecked) return { onChainChecked, ...none };
2136
2446
 
2137
2447
  const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
2138
2448
  if (typeof fetchImpl !== "function") {
2139
2449
  errors.push("on-chain re-fetch requested but no fetch implementation is available");
2140
- return { onChainChecked, gatewayUnavailable: true };
2450
+ return { onChainChecked, ...none, gatewayUnavailable: true };
2141
2451
  }
2142
2452
 
2143
2453
  let gatewayUnavailable = false;
2454
+ let uncheckedKind = false;
2455
+ let uncheckedId = false;
2144
2456
  for (let i = 0; i < stamps.length; i++) {
2145
2457
  const st = stamps[i]!;
2146
2458
  const witnessedHex = witnessed[i];
@@ -2150,8 +2462,10 @@ async function refetchStamps(
2150
2462
 
2151
2463
  const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
2152
2464
  st.onChain = outcome.onChain;
2153
- st.onChainOk = outcome.onChain.onChainOk;
2465
+ st.onChainOk = outcome.onChain?.onChainOk ?? null;
2154
2466
  if (outcome.unavailable) gatewayUnavailable = true;
2467
+ if (outcome.unchecked === "kind") uncheckedKind = true;
2468
+ if (outcome.unchecked === "id") uncheckedId = true;
2155
2469
  if (outcome.error) st.errors.push(outcome.error);
2156
2470
  // Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
2157
2471
  // (false) fails the stamp — the chain does not hold what the bundle says
@@ -2164,7 +2478,14 @@ async function refetchStamps(
2164
2478
  // unreachable note would fail a stamp for a gateway being down.
2165
2479
  st.ok = st.ok && st.onChainOk !== false;
2166
2480
  }
2167
- return { onChainChecked, gatewayUnavailable };
2481
+ return { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId };
2482
+ }
2483
+
2484
+ interface StampRefetchSummary {
2485
+ onChainChecked: boolean;
2486
+ gatewayUnavailable: boolean;
2487
+ uncheckedKind: boolean;
2488
+ uncheckedId: boolean;
2168
2489
  }
2169
2490
 
2170
2491
  // ===========================================================================
@@ -2220,7 +2541,7 @@ export interface DisclosedContentResult {
2220
2541
  errors: string[];
2221
2542
  }
2222
2543
 
2223
- // How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
2544
+ // How many `sanning.evidence.export/v2` wrappers deep the source bundle may be
2224
2545
  // nested before this path gives up. verifyEvidenceBundle's canonicalization
2225
2546
  // depth bound already rejects a pathological bundle, but the unwrap must be
2226
2547
  // independently bounded rather than trusting that it ran first.
@@ -2259,7 +2580,7 @@ export async function disclosedContent(
2259
2580
  const traceEvents = resolveTraceEvents(bundle);
2260
2581
  if (traceEvents === undefined) {
2261
2582
  errors.push(
2262
- `no ${[...ANCHOR_TRACE_BODY_TYPES, STAMP_EVIDENCE_BODY_TYPE].join(" / ")} ` +
2583
+ `no ${ANCHOR_TRACE_BODY_TYPE} / ${STAMP_EVIDENCE_BODY_TYPE} ` +
2263
2584
  `body to materialize content from ` +
2264
2585
  `(body_type ${JSON.stringify(verification.bodyType)})`,
2265
2586
  );
@@ -2360,7 +2681,7 @@ export async function disclosedContent(
2360
2681
  // a caller must distinguish from a body that has events and no links.
2361
2682
  //
2362
2683
  // 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
2684
+ // `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
2364
2685
  // event shape.
2365
2686
  //
2366
2687
  // Exported for `chain.ts`, which needs the same descent and must not derive it
@@ -2375,8 +2696,8 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
2375
2696
  const b = cur as EvidenceBundle;
2376
2697
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
2377
2698
  // 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),
2699
+ // `sanning.stamp.evidence/v2` presents the SAME event shape (a
2700
+ // producer-signed envelope, optional `event_bytes`, an inclusion proof),
2380
2701
  // and it is the HAND-OVER PACK: the only artifact an auditor is ever
2381
2702
  // handed. Dispatching on anchor-trace alone meant `readChains` reported
2382
2703
  // zero chains on every pack, however well-formed the links inside its
@@ -2384,7 +2705,6 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
2384
2705
  // that matters. The writers have been emitting those links the whole time.
2385
2706
  if (
2386
2707
  bodyType === ANCHOR_TRACE_BODY_TYPE ||
2387
- bodyType === ANCHOR_TRACE_BODY_TYPE_V1 ||
2388
2708
  bodyType === STAMP_EVIDENCE_BODY_TYPE
2389
2709
  ) {
2390
2710
  const body = b.body as AnchorTraceBody | null | undefined;
@@ -2482,14 +2802,14 @@ async function verifyExportBody(
2482
2802
  );
2483
2803
  }
2484
2804
  } catch (e) {
2485
- errors.push(`source_bundle canonicalization failed: ${stringifyErr(e)}`);
2805
+ errors.push(`source_bundle canonicalization failed${thrownDetail(e)}`);
2486
2806
  }
2487
2807
 
2488
2808
  // --- Step 4: recompute the source verdict ---------------------------------
2489
2809
  // The existing evidence path over the inline source bundle — same primitives
2490
2810
  // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
2491
2811
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
2492
- const sourceResult = await verifyEvidenceBundle(inline, options);
2812
+ const sourceResult = await verifyExportSource(inline, options);
2493
2813
 
2494
2814
  // Attestation data_hash binding (§5 6c).
2495
2815
  const sourceCheckpoints = await indexSourceCheckpoints(inline);
@@ -2530,7 +2850,7 @@ async function verifyExportBody(
2530
2850
  );
2531
2851
  }
2532
2852
  } catch (e) {
2533
- errors.push(`verdict agreement comparison failed: ${stringifyErr(e)}`);
2853
+ errors.push(`verdict agreement comparison failed${thrownDetail(e)}`);
2534
2854
  }
2535
2855
 
2536
2856
  // --- Step 7 already folded in by the source recompute ---------------------
@@ -2622,7 +2942,7 @@ export async function recomputeExportVerdict(
2622
2942
  generatedAt: string,
2623
2943
  options: VerifyEvidenceOptions = {},
2624
2944
  ): Promise<ComposeVerdictResult> {
2625
- const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
2945
+ const sourceResult = await verifyExportSource(sourceBundle, options);
2626
2946
 
2627
2947
  // Attestation data_hash binding (§5 6c) — the SAME index construction as
2628
2948
  // verifyExportBody, deliberately shared rather than restated.
@@ -2668,6 +2988,52 @@ export async function recomputeExportVerdict(
2668
2988
  // field; otherwise a per-dimension AttestationResult whose `ok` gates on the
2669
2989
  // three binding checks (sig / operator-address / data_hash). subject_ref is
2670
2990
  // undetermined-tolerant and never gates.
2991
+ // The attestation payload fields evidence-export.md §3.1 marks Required, with
2992
+ // the type it gives each. A record whose payload lacks one, or carries another
2993
+ // type (null included), is malformed: §5 step 9 and §4.5 make "a missing
2994
+ // required field" an export the verifier cannot render (exit 2). §5 step 6
2995
+ // gates a WELL-FORMED record on signature, operator and data_hash only; these
2996
+ // checks decide whether the record is well-formed at all. `level` is the §3.1
2997
+ // enum 1 | 2 | 3. The integers are non-negative and within 2**53 - 1 (the
2998
+ // §3.1 clarification).
2999
+ const PAYLOAD_REQUIRED: readonly (readonly [string, "string" | "boolean" | "integer" | "level"])[] = [
3000
+ ["attested_at", "string"],
3001
+ ["tx_id", "string"],
3002
+ ["data_hash", "string"],
3003
+ ["data_size", "integer"],
3004
+ ["block_height", "integer"],
3005
+ ["block_timestamp", "integer"],
3006
+ ["operator", "string"],
3007
+ ["owner_address", "string"],
3008
+ ["gateway", "string"],
3009
+ ["signature_verified", "boolean"],
3010
+ ["level", "level"],
3011
+ ];
3012
+
3013
+ function payloadFieldError(payload: Record<string, unknown>): string | null {
3014
+ for (const [name, kind] of PAYLOAD_REQUIRED) {
3015
+ const v = Object.prototype.hasOwnProperty.call(payload, name) ? payload[name] : undefined;
3016
+ let ok: boolean;
3017
+ let want: string;
3018
+ if (kind === "string") {
3019
+ ok = typeof v === "string";
3020
+ want = "a string";
3021
+ } else if (kind === "boolean") {
3022
+ ok = typeof v === "boolean";
3023
+ want = "a boolean";
3024
+ } else if (kind === "integer") {
3025
+ // §3.1 (clarified): a non-negative integer no larger than 2**53 - 1.
3026
+ ok = typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
3027
+ want = "a non-negative integer";
3028
+ } else {
3029
+ ok = v === 1 || v === 2 || v === 3;
3030
+ want = "1, 2 or 3";
3031
+ }
3032
+ if (!ok) return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
3033
+ }
3034
+ return null;
3035
+ }
3036
+
2671
3037
  async function verifyAttestation(
2672
3038
  rec: AttestationRecord,
2673
3039
  sourceCheckpoints: Map<string, AnyTraceCheckpoint>,
@@ -2714,6 +3080,8 @@ async function verifyAttestation(
2714
3080
  if (typeof rec.signature !== "string") {
2715
3081
  return { result, malformed: "attestation record missing signature" };
2716
3082
  }
3083
+ const fieldError = payloadFieldError(payload as unknown as Record<string, unknown>);
3084
+ if (fieldError !== null) return { result, malformed: fieldError };
2717
3085
  const rsaKey = { kty: "RSA" as const, n: pub.n, e: pub.e };
2718
3086
 
2719
3087
  result.operator = typeof payload.operator === "string" ? payload.operator : "";
@@ -2727,13 +3095,20 @@ async function verifyAttestation(
2727
3095
  try {
2728
3096
  payloadBytes = utf8(jcs(payload));
2729
3097
  } catch (e) {
2730
- return { result, malformed: `attestation payload canonicalization failed: ${stringifyErr(e)}` };
3098
+ return { result, malformed: `attestation payload canonicalization failed${thrownDetail(e)}` };
2731
3099
  }
2732
3100
  try {
2733
3101
  result.signatureOk = await verifyRsaPssSha256(payloadBytes, rec.signature, rsaKey);
2734
3102
  if (!result.signatureOk) errs.push("attestation RSA-PSS signature verification failed");
2735
- } catch (e) {
2736
- return { result, malformed: `attestation ${stringifyErr(e)}` };
3103
+ } catch {
3104
+ // A FIXED sentence, not the library's. This kernel throws
3105
+ // `verifyRsaPssSha256: malformed signature hex: ...` and the Python kernel
3106
+ // raises `malformed signature hex: ...`, so quoting either one split the
3107
+ // two kernels on a finding a holder can put in an export body it signs,
3108
+ // and an export pack's findings reach a case reading through `reasons`
3109
+ // (gate 4 on proof#140, finding 1). Callers of `verifyRsaPssSha256` still
3110
+ // get this kernel's own words.
3111
+ return { result, malformed: "attestation signature or key is malformed" };
2737
3112
  }
2738
3113
 
2739
3114
  // (b) operator-address binding: base64url(SHA-256(modulus)) == payload.operator.
@@ -2746,7 +3121,7 @@ async function verifyAttestation(
2746
3121
  );
2747
3122
  }
2748
3123
  } catch (e) {
2749
- return { result, malformed: `attestation operator key: ${stringifyErr(e)}` };
3124
+ return { result, malformed: `attestation operator key${thrownDetail(e)}` };
2750
3125
  }
2751
3126
 
2752
3127
  // (c) checkpoint resolution + data_hash binding. The checkpoint tx data IS the
@@ -2771,7 +3146,7 @@ async function verifyAttestation(
2771
3146
  );
2772
3147
  }
2773
3148
  } catch (e) {
2774
- errs.push(`cannot recompute checkpoint content hash: ${stringifyErr(e)}`);
3149
+ errs.push(`cannot recompute checkpoint content hash${thrownDetail(e)}`);
2775
3150
  }
2776
3151
  }
2777
3152
 
@@ -2953,7 +3328,7 @@ async function refetchCheckpoint(
2953
3328
  anchorRef: AnchorRef,
2954
3329
  gateways: string[],
2955
3330
  fetchImpl: typeof fetch,
2956
- ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
3331
+ ): Promise<RefetchOutcome> {
2957
3332
  let expectedHex: string | undefined;
2958
3333
  try {
2959
3334
  // A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
@@ -2999,7 +3374,7 @@ async function refetchStamp(
2999
3374
  anchorRef: AnchorRef,
3000
3375
  gateways: string[],
3001
3376
  fetchImpl: typeof fetch,
3002
- ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
3377
+ ): Promise<RefetchOutcome> {
3003
3378
  return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
3004
3379
  }
3005
3380
 
@@ -3014,7 +3389,36 @@ async function refetchAnchored(
3014
3389
  anchorRef: AnchorRef,
3015
3390
  gateways: string[],
3016
3391
  fetchImpl: typeof fetch,
3017
- ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
3392
+ ): Promise<RefetchOutcome> {
3393
+ // Only a `kind` this kernel has a witness adapter for is re-fetched
3394
+ // (evidence-bundle.md §5.1: "where the verifier has an adapter for its
3395
+ // `kind`"; §5.1.1: an unrecognised kind MUST NOT fail verification). An `id`
3396
+ // of any other kind is not an Arweave id, so whatever a gateway serves at
3397
+ // that path says nothing about the witness, and comparing it would turn an
3398
+ // unrelated 200 into a false mismatch. No fetch, `onChain: null`, and the
3399
+ // run goes `partial`: the caller asked for the witness check and it was not
3400
+ // made.
3401
+ if (!WITNESS_ADAPTER_KINDS.has(anchorRef.kind)) {
3402
+ return {
3403
+ onChain: null,
3404
+ unavailable: false,
3405
+ unchecked: "kind",
3406
+ error: `no witness adapter for kind '${anchorRef.kind}'; the witness was not checked`,
3407
+ };
3408
+ }
3409
+ // The id becomes a URL path segment. Anything but a 43-character base64url
3410
+ // transaction id can leave the gateway's base path once URL parsing
3411
+ // normalises it (`../x`, `%2e%2e/x`), or drop part of the path (`a#b`,
3412
+ // `a?b`), and the two kernels would then request different URLs. Such an id
3413
+ // is never fetched and never failed: the witness is not checked.
3414
+ if (!ARWEAVE_ID_RE.test(anchorRef.id)) {
3415
+ return {
3416
+ onChain: null,
3417
+ unavailable: false,
3418
+ unchecked: "id",
3419
+ error: "arweave witness id is not a 43-character base64url transaction id; the witness was not checked",
3420
+ };
3421
+ }
3018
3422
  const perGateway: PerGatewayOutcome[] = [];
3019
3423
  for (const gw of gateways) {
3020
3424
  perGateway.push({
@@ -3033,7 +3437,51 @@ async function refetchAnchored(
3033
3437
  } else if (onChain.rollup === "unreachable") {
3034
3438
  error = `${subject} ${anchorRef.id} unreachable on all gateways`;
3035
3439
  }
3036
- return { onChain, unavailable: onChain.rollup === "unreachable", error };
3440
+ return { onChain, unavailable: onChain.rollup === "unreachable", unchecked: null, error };
3441
+ }
3442
+
3443
+ // The `anchor_ref.kind` values this kernel can re-fetch from a gateway
3444
+ // (evidence-bundle.md §5.1.1 registers exactly `arweave`). Any other kind is
3445
+ // left unchecked by `refetchAnchored`, never probed.
3446
+ const WITNESS_ADAPTER_KINDS: ReadonlySet<string> = new Set(["arweave"]);
3447
+
3448
+ // The only `arweave` ids the adapter looks up: a 43-character base64url
3449
+ // transaction or data-item id.
3450
+ const ARWEAVE_ID_RE = /^[A-Za-z0-9_-]{43}$/;
3451
+
3452
+ // Why a re-fetch was not made: `kind` (no adapter for the witness kind) or
3453
+ // `id` (an arweave id the adapter cannot look up). null when it was made.
3454
+ type Unchecked = "kind" | "id" | null;
3455
+
3456
+ interface RefetchOutcome {
3457
+ onChain: OnChainResult | null;
3458
+ // Every gateway was unreachable.
3459
+ unavailable: boolean;
3460
+ unchecked: Unchecked;
3461
+ error?: string;
3462
+ }
3463
+
3464
+ // The bundle-level notes for a run where requested re-fetches were not made.
3465
+ // Each note keeps the phrase "could not be re-fetched", which is what both
3466
+ // CLIs map to exit 3. The order is fixed so the two kernels emit the same list.
3467
+ function pushRefetchGapNotes(
3468
+ errors: string[],
3469
+ subject: "stamps" | "checkpoints",
3470
+ gaps: { unavailable: boolean; kind: boolean; id: boolean },
3471
+ ): void {
3472
+ if (gaps.unavailable) {
3473
+ errors.push(`one or more ${subject} could not be re-fetched from the supplied gateways`);
3474
+ }
3475
+ if (gaps.kind) {
3476
+ errors.push(
3477
+ `one or more ${subject} name a witness kind this verifier has no adapter for, so it could not be re-fetched`,
3478
+ );
3479
+ }
3480
+ if (gaps.id) {
3481
+ errors.push(
3482
+ `one or more ${subject} name an Arweave witness id this verifier cannot look up, so it could not be re-fetched`,
3483
+ );
3484
+ }
3037
3485
  }
3038
3486
 
3039
3487
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
@@ -3065,7 +3513,7 @@ async function probeGateway(
3065
3513
  // the load-bearing check.
3066
3514
  if (toHex(onChainBytes) === expectedHex) return "confirm";
3067
3515
  try {
3068
- const parsed = JSON.parse(new TextDecoder().decode(onChainBytes));
3516
+ const parsed = jcsParse(onChainBytes);
3069
3517
  if (toHex(utf8(jcs(parsed))) === expectedHex) return "confirm";
3070
3518
  } catch {
3071
3519
  // not JSON / not parseable — a genuine mismatch
@@ -3131,7 +3579,7 @@ function selectDisclosedBytes(
3131
3579
  try {
3132
3580
  inBody = hexToBytes(ev.content);
3133
3581
  } catch (e) {
3134
- errors.push(`event content is not hex: ${stringifyErr(e)}`);
3582
+ errors.push(`event content is not hex${thrownDetail(e)}`);
3135
3583
  }
3136
3584
  }
3137
3585
  let side: Uint8Array | undefined;
@@ -3142,7 +3590,7 @@ function selectDisclosedBytes(
3142
3590
  try {
3143
3591
  side = hexToBytes(sideEntry.value);
3144
3592
  } catch (e) {
3145
- errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
3593
+ errors.push(`supplied content for ${eventId} is not hex${thrownDetail(e)}`);
3146
3594
  }
3147
3595
  }
3148
3596
  // `offered` comes from the SAME test that produced the value, never from