@sanning/proof 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/evidence.js 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
  //
@@ -27,33 +27,60 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
27
27
  // body_type is not a hard failure of the WRAPPER (the signature + body_hash
28
28
  // still verify), but its body cannot be re-derived, so the per-body verdict is
29
29
  // "undetermined" rather than "verified".
30
- export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
30
+ //
31
+ // The producer-to-agent cut (W-3, DL-73) renamed `record_bytes` to
32
+ // `event_bytes` and moved every body that carries it to a new major. This
33
+ // kernel reads ONLY those majors: `sanning.anchor.trace/v1` and `/v2`,
34
+ // `sanning.stamp.evidence/v1` and `sanning.evidence.export/v1` are unknown body
35
+ // types here and answer `partial`. DL-73 releases evidence made before the cut
36
+ // from any obligation to keep verifying, so there is no second field name to
37
+ // read and no dual-accept path.
38
+ export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v3";
31
39
  /**
32
- * The frozen pre-`anchor_ref` body (evidence-bundle.md §5.1.3).
33
- *
34
- * Accepted PERMANENTLY, not for a deprecation window. Every bundle emitted
35
- * before 2026-09-03 carries this shape, and those bundles are evidence — a
36
- * reader that stopped accepting them would destroy the record it exists to
37
- * defend (DL-30). Nothing new emits it.
40
+ * The name `event_bytes` replaced. Under the new majors a body entry that still
41
+ * carries it is a hard reject, never an ignored unknown field: ignored, it
42
+ * would read as a withheld record and the event would verify with nothing
43
+ * checked, the fail-open the new majors exist to prevent (governance.md §8,
44
+ * W-3).
38
45
  */
39
- export const ANCHOR_TRACE_BODY_TYPE_V1 = "sanning.anchor.trace/v1";
40
- /** Every anchor-trace body this kernel verifies. Order is newest-first. */
41
- export const ANCHOR_TRACE_BODY_TYPES = [
42
- ANCHOR_TRACE_BODY_TYPE,
43
- ANCHOR_TRACE_BODY_TYPE_V1,
44
- ];
46
+ export const LEGACY_EVENT_BYTES_FIELD = "record_bytes";
47
+ /**
48
+ * Name an envelope the accept-set refused. Without this a bundle whose
49
+ * envelopes carry a retired profile major (a half-migrated producer still
50
+ * writing `sanning.events/v1`) fails with no reason at all. Spelled with
51
+ * `JSON.stringify` so both kernels write the same sentence.
52
+ */
53
+ function specVersionFinding(what, envelope, res) {
54
+ if (!isJsonObject(envelope) || res.specVersionOk)
55
+ return null;
56
+ if (!Object.prototype.hasOwnProperty.call(envelope, "spec_version")) {
57
+ return `${what} envelope has no spec_version`;
58
+ }
59
+ const sv = envelope.spec_version;
60
+ return `${what} envelope spec_version ${JSON.stringify(sv)} is not accepted by this kernel`;
61
+ }
62
+ /**
63
+ * The reject for an entry that still carries `record_bytes`. One sentence,
64
+ * spelled the same in both kernels, so a negative vector can assert it.
65
+ */
66
+ function legacyEventBytesError(entry, what, bodyType) {
67
+ if (!Object.prototype.hasOwnProperty.call(entry, LEGACY_EVENT_BYTES_FIELD))
68
+ return null;
69
+ return (`${what} carries ${LEGACY_EVENT_BYTES_FIELD} in a ${bodyType} body ` +
70
+ "(renamed event_bytes) -- one bundle speaks one vocabulary");
71
+ }
45
72
  // The issuer-composed attested-evidence-export body (specs/evidence-export.md
46
73
  // §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
47
74
  // carries a cached verdict + the inline source bundle + embedded RSA-PSS
48
75
  // operator attestations. verifyEvidenceBundle dispatches on it to
49
76
  // verifyExportBody (§5).
50
- export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
77
+ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v2";
51
78
  /**
52
79
  * The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
53
80
  *
54
81
  * Its own `body_type` rather than a widened anchor-trace body, and the reason
55
82
  * is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
56
- * `sanning.events/v1` envelope, and a stamp has no producer signature at all —
83
+ * `sanning.events/v2` envelope, and a stamp has no producer signature at all:
57
84
  * Sanning places it, under DL-52, for a customer who holds no chain identity.
58
85
  * The two rejected options were making `envelope` optional and synthesising an
59
86
  * envelope that Sanning signs; the second would have made a Sanning
@@ -61,7 +88,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
61
88
  * unrecoverable once witnessed.
62
89
  */
63
90
  export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
64
- export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
91
+ export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
65
92
  /**
66
93
  * The version string INSIDE a witnessed stamp body — a different string from
67
94
  * the `body_type` above, and the two are easy to confuse.
@@ -116,13 +143,18 @@ function stringifyErr(e) {
116
143
  function jsonStr(value) {
117
144
  return JSON.stringify(value === undefined ? null : value) ?? "null";
118
145
  }
119
- // Verify a `sanning.evidence/v1` bundle carrying a `sanning.anchor.trace/v1` body.
146
+ // Verify a `sanning.evidence/v1` bundle and dispatch on its `body_type`.
120
147
  //
121
148
  // Returns a structured per-checkpoint + per-event result and a recomputed
122
149
  // rollup `status`. The producer's asserted `verdict.status` is surfaced
123
150
  // (`assertedStatus`) but NEVER trusted — the displayed status is the one this
124
151
  // function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
125
152
  export async function verifyEvidenceBundle(bundle, options = {}) {
153
+ return verifyBundle(bundle, options, false);
154
+ }
155
+ // `exportSource`: read the bundle as an export's source (evidence-export.md
156
+ // §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
157
+ async function verifyBundle(bundle, options, exportSource) {
126
158
  // --- Step 1: parse + reject unknown spec_version major ---------------------
127
159
  if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
128
160
  return malformed("evidence bundle is not a JSON object");
@@ -197,6 +229,25 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
197
229
  }
198
230
  // --- Body dispatch ---------------------------------------------------------
199
231
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
232
+ // Read as an export's source (evidence-export.md §2.4), the only body this
233
+ // kernel recognises is sanning.anchor.trace/v3. Any other body, even one it
234
+ // verifies on its own, is undetermined here, after the wrapper checks above.
235
+ if (exportSource && bodyType !== ANCHOR_TRACE_BODY_TYPE) {
236
+ errors.push(`body_type ${JSON.stringify(bodyType)} is not an export source ` +
237
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
238
+ return {
239
+ status: "partial",
240
+ specVersionOk,
241
+ bodyHashOk,
242
+ signatureOk,
243
+ bodyType,
244
+ onChainChecked: false,
245
+ checkpoints: [],
246
+ events: [],
247
+ assertedStatus,
248
+ errors,
249
+ };
250
+ }
200
251
  // Attested-evidence-export body (evidence-export.md §5). The wrapper is
201
252
  // authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
202
253
  // hand off to the export verifier for steps 3–9.
@@ -213,12 +264,12 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
213
264
  if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
214
265
  return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
215
266
  }
216
- if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
267
+ if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
217
268
  // The wrapper is authentic but we don't know this body's structure. Per
218
269
  // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
219
270
  // body verdict undetermined) — not a failure.
220
271
  errors.push(`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
221
- `(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`);
272
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
222
273
  return {
223
274
  status: "partial",
224
275
  specVersionOk,
@@ -232,95 +283,89 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
232
283
  errors,
233
284
  };
234
285
  }
235
- return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
286
+ return verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options);
236
287
  }
237
288
  /**
238
- * Index a source bundle's checkpoints by the key its attestations will use.
289
+ * Recompute an export's source verdict (evidence-export.md §5 step 4).
239
290
  *
240
- * The attestation join key follows the SOURCE bundle's own vocabulary — a
241
- * v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
242
- * alternative (always deriving a v2-style key) would silently un-resolve every
243
- * attestation ever written against a v1 export: `data_hash_bound` would go
244
- * false, the export would stop reaching `verified`, and nothing would say why.
291
+ * The only export source is `sanning.anchor.trace/v3` (§2.2, §2.4). Any other
292
+ * inline source body is unrecognised *as a source*, even one this kernel
293
+ * verifies on its own, such as a `sanning.stamp.evidence/v2` pack: §2.4 makes
294
+ * its source verdict undetermined, so the export cannot reach `verified`. The
295
+ * source wrapper is still checked first, because a forged source is a failure
296
+ * whatever its body.
297
+ */
298
+ async function verifyExportSource(source, options) {
299
+ return verifyBundle(source, options, true);
300
+ }
301
+ /**
302
+ * Index a source bundle's checkpoints by the key its attestations use: the
303
+ * derived `checkpoint_ref`. Only a `sanning.anchor.trace/v3` source has
304
+ * checkpoints an attestation can resolve (evidence-export.md §2.4).
245
305
  */
246
306
  async function indexSourceCheckpoints(bundle) {
247
307
  const out = new Map();
308
+ if (!isJsonObject(bundle) || bundle.body_type !== ANCHOR_TRACE_BODY_TYPE)
309
+ return out;
248
310
  const sb = bundle.body;
249
- const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
250
311
  if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints))
251
312
  return out;
252
313
  for (const cp of sb.checkpoints) {
253
314
  if (!cp || typeof cp !== "object")
254
315
  continue;
255
- let recordBytes;
316
+ let eventBytes;
256
317
  try {
257
- if (typeof cp.record_bytes === "string")
258
- recordBytes = hexToBytes(cp.record_bytes);
318
+ if (typeof cp.event_bytes === "string")
319
+ eventBytes = hexToBytes(cp.event_bytes);
259
320
  }
260
321
  catch {
261
- recordBytes = undefined;
322
+ eventBytes = undefined;
262
323
  }
263
- const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
324
+ const keyed = await resolveCheckpointKey(cp, eventBytes);
264
325
  if (keyed.key !== null && !out.has(keyed.key))
265
326
  out.set(keyed.key, cp);
266
327
  }
267
328
  return out;
268
329
  }
269
330
  /**
270
- * Resolve a checkpoint's join key and its witness reference, for whichever
271
- * body form we are reading.
331
+ * Resolve a checkpoint's join key and its witness reference.
272
332
  *
273
- * ── The rule that matters, and why it is a reject rather than a preference ──
333
+ * ── Why a retired spelling is a reject rather than a preference ──
274
334
  *
275
- * A checkpoint carrying BOTH vocabularies is two answers to one question
276
- * inside one signature. A reader that silently prefers one gives a different
277
- * answer from a reader that prefers the other, over the same signed bytes —
278
- * and both look like they verified. So a v2 body carrying `tx_id`, or a v1
279
- * body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
280
- * resolved. This is cheap to enforce here and impossible to enforce later.
335
+ * A checkpoint carrying a retired spelling beside the current one is two
336
+ * answers to one question inside one signature. A reader that silently prefers
337
+ * one gives a different answer from a reader that prefers the other, over the
338
+ * same signed bytes, and both look like they verified. So `tx_id` and
339
+ * `record_bytes` are rejected rather than resolved.
281
340
  *
282
- * ── Why v2's key is recomputed ──
341
+ * ── Why the key is recomputed ──
283
342
  *
284
- * In v1 the key was an asserted label matched against another asserted label;
285
- * nothing tied either to the bytes, so relabelling a checkpoint and every
286
- * event pointing at it left a bundle that still verified. In v2 the key IS the
287
- * bytes, so the same relabel fails here. v1's behaviour is preserved exactly
288
- * as it was — deliberately, because those bundles are already signed and
289
- * re-interpreting them is not backwards compatibility.
343
+ * The key IS the bytes: relabelling a checkpoint and every event pointing at it
344
+ * fails here, because nothing is taken from the label.
290
345
  */
291
- async function resolveCheckpointKey(cp, recordBytes, isV1) {
346
+ async function resolveCheckpointKey(cp, eventBytes) {
292
347
  const errors = [];
293
- if (isV1) {
294
- if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
295
- errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) -- " +
296
- "one bundle speaks one vocabulary");
297
- return { key: null, anchorRef: null, errors };
298
- }
299
- if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
300
- errors.push("checkpoint missing tx_id");
301
- return { key: null, anchorRef: null, errors };
302
- }
303
- // v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
304
- // through the same shape as v2 gives ONE reader-side vocabulary without
305
- // touching a byte of the signed bundle.
306
- return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
307
- }
308
348
  if (cp.tx_id !== undefined) {
309
- errors.push("sanning.anchor.trace/v2 checkpoint carries legacy tx_id -- one bundle speaks one vocabulary");
349
+ errors.push(`${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`);
350
+ return { key: null, anchorRef: null, errors };
351
+ }
352
+ const legacy = legacyEventBytesError(cp, "checkpoint", ANCHOR_TRACE_BODY_TYPE);
353
+ if (legacy !== null) {
354
+ errors.push(legacy);
310
355
  return { key: null, anchorRef: null, errors };
311
356
  }
312
357
  if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
313
358
  errors.push("checkpoint missing checkpoint_ref");
314
359
  return { key: null, anchorRef: null, errors };
315
360
  }
316
- if (recordBytes === undefined) {
361
+ if (eventBytes === undefined) {
317
362
  // Without the bytes the key cannot be recomputed, so it cannot be trusted.
318
- errors.push("checkpoint_ref cannot be verified -- record_bytes missing or not hex");
363
+ errors.push("checkpoint_ref cannot be verified -- event_bytes missing or not hex");
319
364
  return { key: null, anchorRef: null, errors };
320
365
  }
321
- const derived = await sha256Hex(recordBytes);
366
+ const derived = await sha256Hex(eventBytes);
322
367
  if (derived !== cp.checkpoint_ref.toLowerCase()) {
323
- errors.push(`checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
368
+ errors.push(`checkpoint_ref does not match SHA-256(event_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
324
369
  return { key: null, anchorRef: null, errors };
325
370
  }
326
371
  const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
@@ -371,9 +416,9 @@ function parseAnchorRef(raw, errors) {
371
416
  ...(ref === undefined ? {} : { ref: ref }),
372
417
  };
373
418
  }
374
- async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options) {
419
+ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options) {
375
420
  const body = b.body;
376
- const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
421
+ const bodyType = ANCHOR_TRACE_BODY_TYPE;
377
422
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
378
423
  return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
379
424
  }
@@ -416,17 +461,17 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
416
461
  const cpErrors = [];
417
462
  let envelopeOk = false;
418
463
  let merkleRootOk = false;
419
- // Decode record_bytes first: in v2 the join key is DERIVED from it, so it
464
+ // Decode event_bytes first: in v2 the join key is DERIVED from it, so it
420
465
  // is an input to identity, not just to the payload binding below.
421
- let recordBytes;
466
+ let eventBytes;
422
467
  try {
423
- if (typeof cp.record_bytes === "string")
424
- recordBytes = hexToBytes(cp.record_bytes);
468
+ if (typeof cp.event_bytes === "string")
469
+ eventBytes = hexToBytes(cp.event_bytes);
425
470
  }
426
471
  catch (e) {
427
- cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
472
+ cpErrors.push(`checkpoint event_bytes is not hex: ${stringifyErr(e)}`);
428
473
  }
429
- const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
474
+ const keyed = await resolveCheckpointKey(cp, eventBytes);
430
475
  cpErrors.push(...keyed.errors);
431
476
  // A duplicate key is a HARD failure, not a note. `byKey` below keeps the
432
477
  // FIRST occurrence, so a second checkpoint claiming the same key is
@@ -440,7 +485,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
440
485
  let duplicateKey = false;
441
486
  if (keyed.key !== null) {
442
487
  if (byKey.has(keyed.key)) {
443
- cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
488
+ cpErrors.push(`duplicate checkpoint checkpoint_ref ${keyed.key}`);
444
489
  duplicateKey = true;
445
490
  }
446
491
  else {
@@ -451,7 +496,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
451
496
  // Verify the checkpoint envelope against its committed record (external
452
497
  // commitment — the record is the payload these external envelopes bind to).
453
498
  try {
454
- const res = await verifyEnvelope(cp.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
499
+ const res = await verifyEnvelope(cp.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
455
500
  // A checkpoint must fully bind: signature + the committed record matching
456
501
  // payload_hash. An undetermined binding (no record) is not enough to
457
502
  // anchor the events' inclusion claims to a trustworthy root.
@@ -471,14 +516,17 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
471
516
  for (const m of res.errors)
472
517
  if (m.startsWith("malformed "))
473
518
  cpErrors.push(`checkpoint envelope ${m}`);
519
+ const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
520
+ if (specFinding !== null)
521
+ cpErrors.push(specFinding);
474
522
  // WHY the binding is undetermined decides which sentence is true.
475
- // record_bytes really absent is one thing; record_bytes present and
523
+ // event_bytes really absent is one thing; event_bytes present and
476
524
  // unbindable (a degenerate envelope, unreadable hex) is another, and
477
525
  // naming the wrong cause sends a reader to look for a field that is
478
526
  // sitting right there (proof#76).
479
527
  if (res.payloadHashOk === null) {
480
- cpErrors.push(recordBytes === undefined
481
- ? "checkpoint record_bytes missing -- root untrusted"
528
+ cpErrors.push(eventBytes === undefined
529
+ ? "checkpoint event_bytes missing -- root untrusted"
482
530
  : "checkpoint record binding not checked: root untrusted");
483
531
  }
484
532
  }
@@ -487,9 +535,9 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
487
535
  }
488
536
  // The committed record's merkle_root must equal the checkpoint's claimed
489
537
  // merkle_root (recompute, don't trust the bundle's top-level claim).
490
- if (recordBytes !== undefined) {
538
+ if (eventBytes !== undefined) {
491
539
  try {
492
- const record = JSON.parse(new TextDecoder().decode(recordBytes));
540
+ const record = JSON.parse(new TextDecoder().decode(eventBytes));
493
541
  const committedRoot = record.event?.merkle_root;
494
542
  merkleRootOk = typeof committedRoot === "string" && committedRoot === cp.merkle_root;
495
543
  if (!merkleRootOk) {
@@ -542,16 +590,21 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
542
590
  const eventId = getEventId(ev.envelope);
543
591
  let envelopeOk = false;
544
592
  let payloadBindingOk = null;
545
- let recordBytes;
593
+ // A retired field name under this major is a reject, never an ignored
594
+ // unknown field: ignored, it reads as a withheld record (W-3).
595
+ const legacyError = legacyEventBytesError(ev, "event", bodyType);
596
+ if (legacyError !== null)
597
+ evErrors.push(legacyError);
598
+ let eventBytes;
546
599
  try {
547
- if (typeof ev.record_bytes === "string")
548
- recordBytes = hexToBytes(ev.record_bytes);
600
+ if (typeof ev.event_bytes === "string")
601
+ eventBytes = hexToBytes(ev.event_bytes);
549
602
  }
550
603
  catch (e) {
551
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
604
+ evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
552
605
  }
553
606
  try {
554
- const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
607
+ const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
555
608
  envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
556
609
  payloadBindingOk = res.payloadHashOk;
557
610
  // P-10: report what could not be determined, never a fault that was never
@@ -574,6 +627,9 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
574
627
  for (const m of res.errors)
575
628
  if (m.startsWith("malformed "))
576
629
  evErrors.push(`event envelope ${m}`);
630
+ const specFinding = specVersionFinding("event", ev.envelope, res);
631
+ if (specFinding !== null)
632
+ evErrors.push(specFinding);
577
633
  }
578
634
  catch (e) {
579
635
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
@@ -581,12 +637,11 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
581
637
  // Bind the event to exactly one checkpoint in the bundle, then verify
582
638
  // RFC 9162 inclusion against THAT checkpoint's merkle_root.
583
639
  const incl = ev.inclusion;
584
- // The join key is spelled differently in the two forms and MUST NOT be read
585
- // from the other one: an inclusion carrying v1's spelling inside a v2 body
586
- // is a mixed bundle, and resolving it anyway is how an event silently binds
587
- // to a checkpoint nobody meant.
588
- const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
589
- const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
640
+ // An inclusion carrying the retired `checkpoint_tx_id` spelling is a mixed
641
+ // bundle, and resolving it anyway is how an event silently binds to a
642
+ // checkpoint nobody meant.
643
+ const keyField = "checkpoint_ref";
644
+ const wrongField = "checkpoint_tx_id";
590
645
  const inclAsRecord = incl;
591
646
  const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
592
647
  const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
@@ -635,7 +690,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
635
690
  }
636
691
  }
637
692
  // Content (raw-log) binding — shared with every other event-carrying body.
638
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
693
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
639
694
  const contentOk = contentRes.contentOk;
640
695
  evErrors.push(...contentRes.errors);
641
696
  eventResults.push({
@@ -650,13 +705,15 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
650
705
  // present checkpoint, and its disclosed content did not MISMATCH. A
651
706
  // withheld record or undisclosed content (binding/content null) does not
652
707
  // fail it.
653
- ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false,
708
+ ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false && legacyError === null,
654
709
  errors: evErrors,
655
710
  });
656
711
  }
657
712
  // --- Step 5: optional on-chain re-fetch ------------------------------------
658
713
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
659
714
  let gatewayUnavailable = false;
715
+ let uncheckedKind = false;
716
+ let uncheckedId = false;
660
717
  if (onChainChecked) {
661
718
  if (typeof fetchImpl !== "function") {
662
719
  gatewayUnavailable = true;
@@ -686,9 +743,13 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
686
743
  }
687
744
  const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
688
745
  cpr.onChain = outcome.onChain;
689
- cpr.onChainOk = outcome.onChain.onChainOk;
746
+ cpr.onChainOk = outcome.onChain?.onChainOk ?? null;
690
747
  if (outcome.unavailable)
691
748
  gatewayUnavailable = true;
749
+ if (outcome.unchecked === "kind")
750
+ uncheckedKind = true;
751
+ if (outcome.unchecked === "id")
752
+ uncheckedId = true;
692
753
  if (outcome.error)
693
754
  cpr.errors.push(outcome.error);
694
755
  // Fold the on-chain dimension in: a MISMATCH (false) fails the
@@ -719,12 +780,16 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
719
780
  if (anyHardFailure) {
720
781
  status = "failed";
721
782
  }
722
- else if (gatewayUnavailable) {
783
+ else if (gatewayUnavailable || uncheckedKind || uncheckedId) {
723
784
  // Every offline check passed and nothing MISMATCHED, but a requested
724
785
  // on-chain confirmation could not be made — the bundle is offline-sound,
725
786
  // its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
726
787
  status = "partial";
727
- errors.push("one or more checkpoints could not be re-fetched from the supplied gateways");
788
+ pushRefetchGapNotes(errors, "checkpoints", {
789
+ unavailable: gatewayUnavailable,
790
+ kind: uncheckedKind,
791
+ id: uncheckedId,
792
+ });
728
793
  }
729
794
  else if (anyWithheldRecord) {
730
795
  // Everything that COULD be checked passed; some record bytes were withheld
@@ -815,7 +880,7 @@ function isNonNegInt(n) {
815
880
  // ===========================================================================
816
881
  // Disclosed-content binding, shared by every body that carries events.
817
882
  //
818
- // Extracted for `sanning.stamp.evidence/v1` (§5.3) rather than copied. The
883
+ // Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
819
884
  // rawLog -> content_hash link is the one step the chain
820
885
  // (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
821
886
  // not otherwise cover, and a second copy of it is a second place for the
@@ -824,14 +889,14 @@ function isNonNegInt(n) {
824
889
  // disclosed or nothing is committed; false ONLY on a real mismatch or an
825
890
  // in-body vs side-input disagreement.
826
891
  // ===========================================================================
827
- async function verifyDisclosedContent(ev, eventId, recordBytes, sideContent) {
892
+ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
828
893
  const evErrors = [];
829
894
  let contentOk = null;
830
895
  const committed = [];
831
896
  let recordContentHash;
832
- if (recordBytes !== undefined) {
897
+ if (eventBytes !== undefined) {
833
898
  try {
834
- const record = JSON.parse(new TextDecoder().decode(recordBytes));
899
+ const record = JSON.parse(new TextDecoder().decode(eventBytes));
835
900
  const ch = record.event?.content_hash;
836
901
  if (typeof ch === "string" && ch)
837
902
  recordContentHash = ch.toLowerCase();
@@ -1097,7 +1162,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1097
1162
  // other — asks every consumer to remember which wrapper it is holding before
1098
1163
  // it can trust `onChain: null`, and that is exactly the kind of per-body
1099
1164
  // exception a reader eventually gets wrong.
1100
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
1165
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
1101
1166
  // --- Rollup ----------------------------------------------------------------
1102
1167
  const anyHardFailure = stamps.some((s) => !s.ok);
1103
1168
  const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
@@ -1112,13 +1177,15 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1112
1177
  status = "partial";
1113
1178
  errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
1114
1179
  }
1115
- else if (gatewayUnavailable || anyUnwitnessed) {
1180
+ else if (gatewayUnavailable || uncheckedKind || uncheckedId || anyUnwitnessed) {
1116
1181
  // Both hold the bundle at `partial`, and both get said — see §5.3's rollup
1117
1182
  // for why one note is not enough when there are two different gaps.
1118
1183
  status = "partial";
1119
- if (gatewayUnavailable) {
1120
- errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1121
- }
1184
+ pushRefetchGapNotes(errors, "stamps", {
1185
+ unavailable: gatewayUnavailable,
1186
+ kind: uncheckedKind,
1187
+ id: uncheckedId,
1188
+ });
1122
1189
  if (anyUnwitnessed) {
1123
1190
  // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1124
1191
  // sealed and unwitnessed: `retained-by-sanning`, which is a statement
@@ -1148,7 +1215,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1148
1215
  };
1149
1216
  }
1150
1217
  // ===========================================================================
1151
- // `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
1218
+ // `sanning.stamp.evidence/v2`: the two halves, bound (evidence-bundle.md §5.3)
1152
1219
  //
1153
1220
  // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
1154
1221
  // the sub-tree partition check). The event half reuses the same primitives the
@@ -1217,16 +1284,21 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1217
1284
  const eventId = getEventId(ev.envelope);
1218
1285
  let envelopeOk = false;
1219
1286
  let payloadBindingOk = null;
1220
- let recordBytes;
1287
+ // A retired field name under this major is a reject, never an ignored
1288
+ // unknown field: ignored, it reads as a withheld record (W-3).
1289
+ const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
1290
+ if (legacyError !== null)
1291
+ evErrors.push(legacyError);
1292
+ let eventBytes;
1221
1293
  try {
1222
- if (typeof ev.record_bytes === "string")
1223
- recordBytes = hexToBytes(ev.record_bytes);
1294
+ if (typeof ev.event_bytes === "string")
1295
+ eventBytes = hexToBytes(ev.event_bytes);
1224
1296
  }
1225
1297
  catch (e) {
1226
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1298
+ evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
1227
1299
  }
1228
1300
  try {
1229
- const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
1301
+ const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
1230
1302
  envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1231
1303
  payloadBindingOk = res.payloadHashOk;
1232
1304
  // P-10: report what could not be determined, never a fault that was never
@@ -1249,6 +1321,9 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1249
1321
  for (const m of res.errors)
1250
1322
  if (m.startsWith("malformed "))
1251
1323
  evErrors.push(`event envelope ${m}`);
1324
+ const specFinding = specVersionFinding("event", ev.envelope, res);
1325
+ if (specFinding !== null)
1326
+ evErrors.push(specFinding);
1252
1327
  }
1253
1328
  catch (e) {
1254
1329
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
@@ -1306,7 +1381,7 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1306
1381
  }
1307
1382
  }
1308
1383
  }
1309
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1384
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
1310
1385
  const contentOk = contentRes.contentOk;
1311
1386
  evErrors.push(...contentRes.errors);
1312
1387
  eventResults.push({
@@ -1317,12 +1392,12 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1317
1392
  // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
1318
1393
  checkpointBound: stampBound,
1319
1394
  contentOk,
1320
- ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
1395
+ ok: envelopeOk && inclusionOk && stampBound && contentOk !== false && legacyError === null,
1321
1396
  errors: evErrors,
1322
1397
  });
1323
1398
  }
1324
1399
  // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
1325
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
1400
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
1326
1401
  // --- Rollup ---------------------------------------------------------------
1327
1402
  // Rule 4: a declared event MUST be checked. `events[]` present and unverified
1328
1403
  // is the false green this body exists to prevent, so an event that did not
@@ -1337,16 +1412,27 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1337
1412
  status = "partial";
1338
1413
  errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
1339
1414
  }
1340
- else if (gatewayUnavailable || anyUnwitnessed) {
1341
- // Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
1342
- // witnessed and a witness we could not reach are different gaps with
1343
- // different fixes — one waits for the interval, the other for the network —
1344
- // and an auditor told only about the second would read the first as fine.
1345
- // The order is fixed so the two kernels emit the same list.
1415
+ else if (eventResults.length === 0 ||
1416
+ gatewayUnavailable ||
1417
+ uncheckedKind ||
1418
+ uncheckedId ||
1419
+ anyUnwitnessed) {
1420
+ // Each holds the bundle at `partial`, and EACH gets said. A pack with no
1421
+ // events, a stamp nobody witnessed and a witness we could not reach are
1422
+ // different gaps with different fixes, and an auditor told only about one
1423
+ // would read the others as fine. The order is fixed so the two kernels
1424
+ // emit the same list.
1346
1425
  status = "partial";
1347
- if (gatewayUnavailable) {
1348
- errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1349
- }
1426
+ if (eventResults.length === 0) {
1427
+ // §5.3.2: an events-empty body is exactly a §5.2 stamp trace, and is
1428
+ // partial, never verified. It says nothing about any event.
1429
+ errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no events -- it evidences no event, only its stamps`);
1430
+ }
1431
+ pushRefetchGapNotes(errors, "stamps", {
1432
+ unavailable: gatewayUnavailable,
1433
+ kind: uncheckedKind,
1434
+ id: uncheckedId,
1435
+ });
1350
1436
  if (anyUnwitnessed) {
1351
1437
  // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
1352
1438
  // the independent part, so the events resting on it inherit `partial`.
@@ -1407,14 +1493,17 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1407
1493
  async function refetchStamps(stamps, witnessed, options, errors) {
1408
1494
  const gateways = options.gateways ?? [];
1409
1495
  const onChainChecked = gateways.length > 0;
1496
+ const none = { gatewayUnavailable: false, uncheckedKind: false, uncheckedId: false };
1410
1497
  if (!onChainChecked)
1411
- return { onChainChecked, gatewayUnavailable: false };
1498
+ return { onChainChecked, ...none };
1412
1499
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1413
1500
  if (typeof fetchImpl !== "function") {
1414
1501
  errors.push("on-chain re-fetch requested but no fetch implementation is available");
1415
- return { onChainChecked, gatewayUnavailable: true };
1502
+ return { onChainChecked, ...none, gatewayUnavailable: true };
1416
1503
  }
1417
1504
  let gatewayUnavailable = false;
1505
+ let uncheckedKind = false;
1506
+ let uncheckedId = false;
1418
1507
  for (let i = 0; i < stamps.length; i++) {
1419
1508
  const st = stamps[i];
1420
1509
  const witnessedHex = witnessed[i];
@@ -1424,9 +1513,13 @@ async function refetchStamps(stamps, witnessed, options, errors) {
1424
1513
  continue;
1425
1514
  const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
1426
1515
  st.onChain = outcome.onChain;
1427
- st.onChainOk = outcome.onChain.onChainOk;
1516
+ st.onChainOk = outcome.onChain?.onChainOk ?? null;
1428
1517
  if (outcome.unavailable)
1429
1518
  gatewayUnavailable = true;
1519
+ if (outcome.unchecked === "kind")
1520
+ uncheckedKind = true;
1521
+ if (outcome.unchecked === "id")
1522
+ uncheckedId = true;
1430
1523
  if (outcome.error)
1431
1524
  st.errors.push(outcome.error);
1432
1525
  // Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
@@ -1440,9 +1533,9 @@ async function refetchStamps(stamps, witnessed, options, errors) {
1440
1533
  // unreachable note would fail a stamp for a gateway being down.
1441
1534
  st.ok = st.ok && st.onChainOk !== false;
1442
1535
  }
1443
- return { onChainChecked, gatewayUnavailable };
1536
+ return { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId };
1444
1537
  }
1445
- // How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
1538
+ // How many `sanning.evidence.export/v2` wrappers deep the source bundle may be
1446
1539
  // nested before this path gives up. verifyEvidenceBundle's canonicalization
1447
1540
  // depth bound already rejects a pathological bundle, but the unwrap must be
1448
1541
  // independently bounded rather than trusting that it ran first.
@@ -1475,7 +1568,7 @@ export async function disclosedContent(bundle, options = {}) {
1475
1568
  });
1476
1569
  const traceEvents = resolveTraceEvents(bundle);
1477
1570
  if (traceEvents === undefined) {
1478
- errors.push(`no ${[...ANCHOR_TRACE_BODY_TYPES, STAMP_EVIDENCE_BODY_TYPE].join(" / ")} ` +
1571
+ errors.push(`no ${ANCHOR_TRACE_BODY_TYPE} / ${STAMP_EVIDENCE_BODY_TYPE} ` +
1479
1572
  `body to materialize content from ` +
1480
1573
  `(body_type ${JSON.stringify(verification.bodyType)})`);
1481
1574
  return empty();
@@ -1562,7 +1655,7 @@ export async function disclosedContent(bundle, options = {}) {
1562
1655
  // a caller must distinguish from a body that has events and no links.
1563
1656
  //
1564
1657
  // BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
1565
- // `sanning.stamp.evidence/v1` is the hand-over pack, and it presents the same
1658
+ // `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
1566
1659
  // event shape.
1567
1660
  //
1568
1661
  // Exported for `chain.ts`, which needs the same descent and must not derive it
@@ -1578,15 +1671,14 @@ export function resolveTraceEvents(bundle) {
1578
1671
  const b = cur;
1579
1672
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
1580
1673
  // Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
1581
- // `sanning.stamp.evidence/v1` presents the SAME event shape (a
1582
- // producer-signed envelope, optional `record_bytes`, an inclusion proof),
1674
+ // `sanning.stamp.evidence/v2` presents the SAME event shape (a
1675
+ // producer-signed envelope, optional `event_bytes`, an inclusion proof),
1583
1676
  // and it is the HAND-OVER PACK: the only artifact an auditor is ever
1584
1677
  // handed. Dispatching on anchor-trace alone meant `readChains` reported
1585
1678
  // zero chains on every pack, however well-formed the links inside its
1586
1679
  // records were, so "is it in order?" read as NOT CHECKED on the one shape
1587
1680
  // that matters. The writers have been emitting those links the whole time.
1588
1681
  if (bodyType === ANCHOR_TRACE_BODY_TYPE ||
1589
- bodyType === ANCHOR_TRACE_BODY_TYPE_V1 ||
1590
1682
  bodyType === STAMP_EVIDENCE_BODY_TYPE) {
1591
1683
  const body = b.body;
1592
1684
  if (body === null || typeof body !== "object" || !Array.isArray(body.events))
@@ -1679,7 +1771,7 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
1679
1771
  // The existing evidence path over the inline source bundle — same primitives
1680
1772
  // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
1681
1773
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
1682
- const sourceResult = await verifyEvidenceBundle(inline, options);
1774
+ const sourceResult = await verifyExportSource(inline, options);
1683
1775
  // Attestation data_hash binding (§5 6c).
1684
1776
  const sourceCheckpoints = await indexSourceCheckpoints(inline);
1685
1777
  // --- Step 6: embedded attestation records ---------------------------------
@@ -1772,7 +1864,7 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
1772
1864
  };
1773
1865
  }
1774
1866
  export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
1775
- const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
1867
+ const sourceResult = await verifyExportSource(sourceBundle, options);
1776
1868
  // Attestation data_hash binding (§5 6c) — the SAME index construction as
1777
1869
  // verifyExportBody, deliberately shared rather than restated.
1778
1870
  const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
@@ -1812,6 +1904,54 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
1812
1904
  // field; otherwise a per-dimension AttestationResult whose `ok` gates on the
1813
1905
  // three binding checks (sig / operator-address / data_hash). subject_ref is
1814
1906
  // undetermined-tolerant and never gates.
1907
+ // The attestation payload fields evidence-export.md §3.1 marks Required, with
1908
+ // the type it gives each. A record whose payload lacks one, or carries another
1909
+ // type (null included), is malformed: §5 step 9 and §4.5 make "a missing
1910
+ // required field" an export the verifier cannot render (exit 2). §5 step 6
1911
+ // gates a WELL-FORMED record on signature, operator and data_hash only; these
1912
+ // checks decide whether the record is well-formed at all. `level` is the §3.1
1913
+ // enum 1 | 2 | 3. The integers are non-negative and within 2**53 - 1 (the
1914
+ // §3.1 clarification).
1915
+ const PAYLOAD_REQUIRED = [
1916
+ ["attested_at", "string"],
1917
+ ["tx_id", "string"],
1918
+ ["data_hash", "string"],
1919
+ ["data_size", "integer"],
1920
+ ["block_height", "integer"],
1921
+ ["block_timestamp", "integer"],
1922
+ ["operator", "string"],
1923
+ ["owner_address", "string"],
1924
+ ["gateway", "string"],
1925
+ ["signature_verified", "boolean"],
1926
+ ["level", "level"],
1927
+ ];
1928
+ function payloadFieldError(payload) {
1929
+ for (const [name, kind] of PAYLOAD_REQUIRED) {
1930
+ const v = Object.prototype.hasOwnProperty.call(payload, name) ? payload[name] : undefined;
1931
+ let ok;
1932
+ let want;
1933
+ if (kind === "string") {
1934
+ ok = typeof v === "string";
1935
+ want = "a string";
1936
+ }
1937
+ else if (kind === "boolean") {
1938
+ ok = typeof v === "boolean";
1939
+ want = "a boolean";
1940
+ }
1941
+ else if (kind === "integer") {
1942
+ // §3.1 (clarified): a non-negative integer no larger than 2**53 - 1.
1943
+ ok = typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
1944
+ want = "a non-negative integer";
1945
+ }
1946
+ else {
1947
+ ok = v === 1 || v === 2 || v === 3;
1948
+ want = "1, 2 or 3";
1949
+ }
1950
+ if (!ok)
1951
+ return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
1952
+ }
1953
+ return null;
1954
+ }
1815
1955
  async function verifyAttestation(rec, sourceCheckpoints) {
1816
1956
  const errs = [];
1817
1957
  const result = {
@@ -1851,6 +1991,9 @@ async function verifyAttestation(rec, sourceCheckpoints) {
1851
1991
  if (typeof rec.signature !== "string") {
1852
1992
  return { result, malformed: "attestation record missing signature" };
1853
1993
  }
1994
+ const fieldError = payloadFieldError(payload);
1995
+ if (fieldError !== null)
1996
+ return { result, malformed: fieldError };
1854
1997
  const rsaKey = { kty: "RSA", n: pub.n, e: pub.e };
1855
1998
  result.operator = typeof payload.operator === "string" ? payload.operator : "";
1856
1999
  result.gateway = typeof payload.gateway === "string" ? payload.gateway : null;
@@ -2116,6 +2259,35 @@ async function refetchStamp(witnessedHex, anchorRef, gateways, fetchImpl) {
2116
2259
  // beats unreachable" to drift, and the drifted copy is the one nobody reads.
2117
2260
  // The subject only ever selects wording; it never changes a verdict.
2118
2261
  async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchImpl) {
2262
+ // Only a `kind` this kernel has a witness adapter for is re-fetched
2263
+ // (evidence-bundle.md §5.1: "where the verifier has an adapter for its
2264
+ // `kind`"; §5.1.1: an unrecognised kind MUST NOT fail verification). An `id`
2265
+ // of any other kind is not an Arweave id, so whatever a gateway serves at
2266
+ // that path says nothing about the witness, and comparing it would turn an
2267
+ // unrelated 200 into a false mismatch. No fetch, `onChain: null`, and the
2268
+ // run goes `partial`: the caller asked for the witness check and it was not
2269
+ // made.
2270
+ if (!WITNESS_ADAPTER_KINDS.has(anchorRef.kind)) {
2271
+ return {
2272
+ onChain: null,
2273
+ unavailable: false,
2274
+ unchecked: "kind",
2275
+ error: `no witness adapter for kind '${anchorRef.kind}'; the witness was not checked`,
2276
+ };
2277
+ }
2278
+ // The id becomes a URL path segment. Anything but a 43-character base64url
2279
+ // transaction id can leave the gateway's base path once URL parsing
2280
+ // normalises it (`../x`, `%2e%2e/x`), or drop part of the path (`a#b`,
2281
+ // `a?b`), and the two kernels would then request different URLs. Such an id
2282
+ // is never fetched and never failed: the witness is not checked.
2283
+ if (!ARWEAVE_ID_RE.test(anchorRef.id)) {
2284
+ return {
2285
+ onChain: null,
2286
+ unavailable: false,
2287
+ unchecked: "id",
2288
+ error: "arweave witness id is not a 43-character base64url transaction id; the witness was not checked",
2289
+ };
2290
+ }
2119
2291
  const perGateway = [];
2120
2292
  for (const gw of gateways) {
2121
2293
  perGateway.push({
@@ -2134,7 +2306,28 @@ async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchI
2134
2306
  else if (onChain.rollup === "unreachable") {
2135
2307
  error = `${subject} ${anchorRef.id} unreachable on all gateways`;
2136
2308
  }
2137
- return { onChain, unavailable: onChain.rollup === "unreachable", error };
2309
+ return { onChain, unavailable: onChain.rollup === "unreachable", unchecked: null, error };
2310
+ }
2311
+ // The `anchor_ref.kind` values this kernel can re-fetch from a gateway
2312
+ // (evidence-bundle.md §5.1.1 registers exactly `arweave`). Any other kind is
2313
+ // left unchecked by `refetchAnchored`, never probed.
2314
+ const WITNESS_ADAPTER_KINDS = new Set(["arweave"]);
2315
+ // The only `arweave` ids the adapter looks up: a 43-character base64url
2316
+ // transaction or data-item id.
2317
+ const ARWEAVE_ID_RE = /^[A-Za-z0-9_-]{43}$/;
2318
+ // The bundle-level notes for a run where requested re-fetches were not made.
2319
+ // Each note keeps the phrase "could not be re-fetched", which is what both
2320
+ // CLIs map to exit 3. The order is fixed so the two kernels emit the same list.
2321
+ function pushRefetchGapNotes(errors, subject, gaps) {
2322
+ if (gaps.unavailable) {
2323
+ errors.push(`one or more ${subject} could not be re-fetched from the supplied gateways`);
2324
+ }
2325
+ if (gaps.kind) {
2326
+ errors.push(`one or more ${subject} name a witness kind this verifier has no adapter for, so it could not be re-fetched`);
2327
+ }
2328
+ if (gaps.id) {
2329
+ errors.push(`one or more ${subject} name an Arweave witness id this verifier cannot look up, so it could not be re-fetched`);
2330
+ }
2138
2331
  }
2139
2332
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
2140
2333
  // (bytes disagree), or unreachable (no response / !ok / network error).