@sanning/proof 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/evidence.js CHANGED
@@ -18,6 +18,7 @@
18
18
  import { deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
19
19
  import { leafHash, verifyInclusion } from "./merkle.js";
20
20
  import { MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, jcs, verifyEnvelope, } from "./verifier.js";
21
+ import { inBodyContentOffered, sideContentEntry, } from "./side-content.js";
21
22
  // Only the evidence-bundle major(s) this kernel verifies. A new major is a
22
23
  // deliberate one-entry addition HERE — same fail-closed discipline as the
23
24
  // envelope verifier's ACCEPTED_SPEC_MAJORS.
@@ -26,13 +27,52 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
26
27
  // body_type is not a hard failure of the WRAPPER (the signature + body_hash
27
28
  // still verify), but its body cannot be re-derived, so the per-body verdict is
28
29
  // "undetermined" rather than "verified".
29
- export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v1";
30
+ export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
31
+ /**
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.
38
+ */
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
+ ];
30
45
  // The issuer-composed attested-evidence-export body (specs/evidence-export.md
31
46
  // §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
32
47
  // carries a cached verdict + the inline source bundle + embedded RSA-PSS
33
48
  // operator attestations. verifyEvidenceBundle dispatches on it to
34
49
  // verifyExportBody (§5).
35
50
  export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
51
+ /**
52
+ * The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
53
+ *
54
+ * Its own `body_type` rather than a widened anchor-trace body, and the reason
55
+ * 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 —
57
+ * Sanning places it, under DL-52, for a customer who holds no chain identity.
58
+ * The two rejected options were making `envelope` optional and synthesising an
59
+ * envelope that Sanning signs; the second would have made a Sanning
60
+ * attestation indistinguishable ON THE WIRE from a producer's, which is
61
+ * unrecoverable once witnessed.
62
+ */
63
+ export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
64
+ /**
65
+ * The version string INSIDE a witnessed stamp body — a different string from
66
+ * the `body_type` above, and the two are easy to confuse.
67
+ *
68
+ * `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
69
+ * dispatch key. `sanning.stamp/v1` is the control plane's own version of the
70
+ * bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
71
+ * `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
72
+ * version to exactly this value: two answers to one question inside one
73
+ * signature is the §5.1.2 mixed-vocabulary reject, one layer down.
74
+ */
75
+ export const STAMP_BODY_VERSION = "sanning.stamp/v1";
36
76
  function isEvidenceMajorAccepted(specVersion) {
37
77
  if (typeof specVersion !== "string" || specVersion === "")
38
78
  return false;
@@ -149,12 +189,18 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
149
189
  if (bodyType === EXPORT_BODY_TYPE) {
150
190
  return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
151
191
  }
152
- if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
192
+ // The Sanning-signed interval stamp body (evidence-bundle.md §5.2). The
193
+ // wrapper is authentic — and for this body that IS "Sanning signed it", since
194
+ // a stamp carries no second signature of its own inside the bundle.
195
+ if (bodyType === STAMP_TRACE_BODY_TYPE) {
196
+ return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
197
+ }
198
+ if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
153
199
  // The wrapper is authentic but we don't know this body's structure. Per
154
200
  // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
155
201
  // body verdict undetermined) — not a failure.
156
202
  errors.push(`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
157
- `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
203
+ `(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`);
158
204
  return {
159
205
  status: "partial",
160
206
  specVersionOk,
@@ -170,31 +216,168 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
170
216
  }
171
217
  return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
172
218
  }
219
+ /**
220
+ * Index a source bundle's checkpoints by the key its attestations will use.
221
+ *
222
+ * The attestation join key follows the SOURCE bundle's own vocabulary — a
223
+ * v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
224
+ * alternative (always deriving a v2-style key) would silently un-resolve every
225
+ * attestation ever written against a v1 export: `data_hash_bound` would go
226
+ * false, the export would stop reaching `verified`, and nothing would say why.
227
+ */
228
+ async function indexSourceCheckpoints(bundle) {
229
+ const out = new Map();
230
+ const sb = bundle.body;
231
+ const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
232
+ if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints))
233
+ return out;
234
+ for (const cp of sb.checkpoints) {
235
+ if (!cp || typeof cp !== "object")
236
+ continue;
237
+ let recordBytes;
238
+ try {
239
+ if (typeof cp.record_bytes === "string")
240
+ recordBytes = hexToBytes(cp.record_bytes);
241
+ }
242
+ catch {
243
+ recordBytes = undefined;
244
+ }
245
+ const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
246
+ if (keyed.key !== null && !out.has(keyed.key))
247
+ out.set(keyed.key, cp);
248
+ }
249
+ return out;
250
+ }
251
+ /**
252
+ * Resolve a checkpoint's join key and its witness reference, for whichever
253
+ * body form we are reading.
254
+ *
255
+ * ── The rule that matters, and why it is a reject rather than a preference ──
256
+ *
257
+ * A checkpoint carrying BOTH vocabularies is two answers to one question
258
+ * inside one signature. A reader that silently prefers one gives a different
259
+ * answer from a reader that prefers the other, over the same signed bytes —
260
+ * and both look like they verified. So a v2 body carrying `tx_id`, or a v1
261
+ * body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
262
+ * resolved. This is cheap to enforce here and impossible to enforce later.
263
+ *
264
+ * ── Why v2's key is recomputed ──
265
+ *
266
+ * In v1 the key was an asserted label matched against another asserted label;
267
+ * nothing tied either to the bytes, so relabelling a checkpoint and every
268
+ * event pointing at it left a bundle that still verified. In v2 the key IS the
269
+ * bytes, so the same relabel fails here. v1's behaviour is preserved exactly
270
+ * as it was — deliberately, because those bundles are already signed and
271
+ * re-interpreting them is not backwards compatibility.
272
+ */
273
+ async function resolveCheckpointKey(cp, recordBytes, isV1) {
274
+ const errors = [];
275
+ if (isV1) {
276
+ if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
277
+ errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) — " +
278
+ "one bundle speaks one vocabulary");
279
+ return { key: null, anchorRef: null, errors };
280
+ }
281
+ if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
282
+ errors.push("checkpoint missing tx_id");
283
+ return { key: null, anchorRef: null, errors };
284
+ }
285
+ // v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
286
+ // through the same shape as v2 gives ONE reader-side vocabulary without
287
+ // touching a byte of the signed bundle.
288
+ return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
289
+ }
290
+ if (cp.tx_id !== undefined) {
291
+ errors.push("sanning.anchor.trace/v2 checkpoint carries legacy tx_id — one bundle speaks one vocabulary");
292
+ return { key: null, anchorRef: null, errors };
293
+ }
294
+ if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
295
+ errors.push("checkpoint missing checkpoint_ref");
296
+ return { key: null, anchorRef: null, errors };
297
+ }
298
+ if (recordBytes === undefined) {
299
+ // Without the bytes the key cannot be recomputed, so it cannot be trusted.
300
+ errors.push("checkpoint_ref cannot be verified — record_bytes missing or not hex");
301
+ return { key: null, anchorRef: null, errors };
302
+ }
303
+ const derived = await sha256Hex(recordBytes);
304
+ if (derived !== cp.checkpoint_ref.toLowerCase()) {
305
+ errors.push(`checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
306
+ return { key: null, anchorRef: null, errors };
307
+ }
308
+ const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
309
+ return { key: derived, anchorRef, errors };
310
+ }
311
+ /**
312
+ * Validate an optional `anchor_ref` (§5.1.1).
313
+ *
314
+ * ── An unrecognised `kind` is NOT a failure, and that is the point ──
315
+ *
316
+ * The offline verdict — signatures, payload binding, Merkle inclusion — is
317
+ * computed with no witness access at all. A reader that cannot resolve a
318
+ * witness has therefore learned NOTHING about whether the evidence was
319
+ * altered, and must not say otherwise. The shipped issuer did the opposite:
320
+ * an unrecognised signature type returned `false` rather than `null`, mapped
321
+ * to `tampered`, and was cached as permanent — a false accusation, produced
322
+ * by a coverage gap, for a company whose product is telling people whether
323
+ * records were altered.
324
+ *
325
+ * So this validates the SHAPE and never the vocabulary. A malformed
326
+ * `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
327
+ * the reader, and only the first is the bundle's fault.
328
+ */
329
+ function parseAnchorRef(raw, errors) {
330
+ if (raw === undefined || raw === null)
331
+ return null; // placed, not yet witnessed
332
+ if (typeof raw !== "object" || Array.isArray(raw)) {
333
+ errors.push("anchor_ref must be an object");
334
+ return null;
335
+ }
336
+ const o = raw;
337
+ if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
338
+ errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
339
+ return null;
340
+ }
341
+ if (typeof o.id !== "string" || o.id === "") {
342
+ errors.push("anchor_ref.id is missing or empty");
343
+ return null;
344
+ }
345
+ const ref = o.ref;
346
+ if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
347
+ errors.push("anchor_ref.ref must be an object when present");
348
+ return null;
349
+ }
350
+ return {
351
+ kind: o.kind,
352
+ id: o.id,
353
+ ...(ref === undefined ? {} : { ref: ref }),
354
+ };
355
+ }
173
356
  async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options) {
174
357
  const body = b.body;
358
+ const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
175
359
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
176
- return malformed("sanning.anchor.trace/v1 body must have checkpoints[] and events[]", true);
360
+ return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
177
361
  }
178
362
  const gateways = options.gateways ?? [];
179
363
  const onChainChecked = gateways.length > 0;
180
364
  // --- Step 3: per-checkpoint -------------------------------------------------
181
- const byTxId = new Map();
365
+ //
366
+ // ── Why the join key is resolved BEFORE anything else ──
367
+ //
368
+ // `byKey` is what binds an event to the root its inclusion proof is checked
369
+ // against. Get it wrong and an event proves inclusion in a tree it was never
370
+ // in — the one failure in this file that produces a WRONG VERIFIED, not a
371
+ // failed verify. Everything below is downstream of it.
372
+ const byKey = new Map();
373
+ const anchorByKey = new Map();
182
374
  const checkpointResults = [];
183
375
  for (const cp of body.checkpoints) {
184
376
  const cpErrors = [];
185
377
  let envelopeOk = false;
186
378
  let merkleRootOk = false;
187
- if (typeof cp.tx_id !== "string") {
188
- cpErrors.push("checkpoint missing tx_id");
189
- }
190
- else if (byTxId.has(cp.tx_id)) {
191
- cpErrors.push(`duplicate checkpoint tx_id ${cp.tx_id}`);
192
- }
193
- else {
194
- byTxId.set(cp.tx_id, cp);
195
- }
196
- // Verify the checkpoint envelope against its committed record (external
197
- // commitment — the record is the payload these external envelopes bind to).
379
+ // Decode record_bytes first: in v2 the join key is DERIVED from it, so it
380
+ // is an input to identity, not just to the payload binding below.
198
381
  let recordBytes;
199
382
  try {
200
383
  if (typeof cp.record_bytes === "string")
@@ -203,6 +386,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
203
386
  catch (e) {
204
387
  cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
205
388
  }
389
+ const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
390
+ cpErrors.push(...keyed.errors);
391
+ if (keyed.key !== null) {
392
+ if (byKey.has(keyed.key)) {
393
+ cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
394
+ }
395
+ else {
396
+ byKey.set(keyed.key, cp);
397
+ anchorByKey.set(keyed.key, keyed.anchorRef);
398
+ }
399
+ }
400
+ // Verify the checkpoint envelope against its committed record (external
401
+ // commitment — the record is the payload these external envelopes bind to).
206
402
  try {
207
403
  const res = await verifyEnvelope(cp.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
208
404
  // A checkpoint must fully bind: signature + the committed record matching
@@ -235,12 +431,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
235
431
  }
236
432
  }
237
433
  checkpointResults.push({
238
- txId: typeof cp.tx_id === "string" ? cp.tx_id : "",
434
+ checkpointRef: keyed.key ?? "",
435
+ anchorRef: keyed.anchorRef,
239
436
  envelopeOk,
240
437
  merkleRootOk,
241
438
  onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
242
439
  onChain: null, // per-gateway (§4.2), filled in step 5
243
- ok: envelopeOk && merkleRootOk,
440
+ // `keyed.errors` is load-bearing here, not decorative. A structurally
441
+ // invalid anchor_ref rides INSIDE the signature, so it is a defect we
442
+ // can see rather than a coverage gap we cannot — and this kernel's
443
+ // standing rule is that malformed is a failure while absent is
444
+ // undetermined (the same rule disclosed `content` follows). Without
445
+ // this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
446
+ ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
244
447
  errors: cpErrors,
245
448
  });
246
449
  }
@@ -274,18 +477,36 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
274
477
  // Bind the event to exactly one checkpoint in the bundle, then verify
275
478
  // RFC 9162 inclusion against THAT checkpoint's merkle_root.
276
479
  const incl = ev.inclusion;
277
- const cp = incl && typeof incl.checkpoint_tx_id === "string" ? byTxId.get(incl.checkpoint_tx_id) : undefined;
278
- const checkpointBound = cp !== undefined;
480
+ // The join key is spelled differently in the two forms and MUST NOT be read
481
+ // from the other one: an inclusion carrying v1's spelling inside a v2 body
482
+ // is a mixed bundle, and resolving it anyway is how an event silently binds
483
+ // to a checkpoint nobody meant.
484
+ const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
485
+ const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
486
+ const inclAsRecord = incl;
487
+ const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
488
+ const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
489
+ let cp;
279
490
  if (!incl || typeof incl !== "object") {
280
491
  evErrors.push("event missing inclusion proof");
281
492
  }
282
- else if (!checkpointBound) {
283
- evErrors.push(`event inclusion.checkpoint_tx_id ${JSON.stringify(incl.checkpoint_tx_id)} resolves to no checkpoint`);
493
+ else if (strayKey !== undefined) {
494
+ evErrors.push(`event inclusion carries ${wrongField} in a ${bodyType} body — one bundle speaks one vocabulary`);
495
+ }
496
+ else if (typeof rawKey !== "string") {
497
+ evErrors.push(`event inclusion missing ${keyField}`);
498
+ }
499
+ else {
500
+ cp = byKey.get(rawKey);
501
+ if (cp === undefined) {
502
+ evErrors.push(`event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`);
503
+ }
284
504
  }
505
+ const checkpointBound = cp !== undefined;
285
506
  let inclusionOk = false;
286
507
  if (incl && checkpointBound && cp) {
287
508
  try {
288
- inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(cp.merkle_root));
509
+ inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(cp.merkle_root ?? ""));
289
510
  if (!inclusionOk)
290
511
  evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
291
512
  // Defense in depth: the claimed leaf_hash MUST be the hash of the
@@ -397,10 +618,27 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
397
618
  }
398
619
  else {
399
620
  for (const cpr of checkpointResults) {
400
- const cp = byTxId.get(cpr.txId);
621
+ const cp = byKey.get(cpr.checkpointRef);
401
622
  if (!cp)
402
623
  continue;
403
- const outcome = await refetchCheckpoint(cp, gateways, fetchImpl);
624
+ // A checkpoint with no anchor_ref has NOTHING to re-fetch. Under DL-52
625
+ // that is the normal state inside a stamp interval — placed, readable,
626
+ // not yet witnessed — so it reports as unreachable (which downgrades
627
+ // the run to partial) and NEVER as a mismatch. Treating "we never
628
+ // anchored it yet" as "the chain disagrees" would be the same false
629
+ // accusation this whole change is removing.
630
+ if (cpr.anchorRef === null) {
631
+ gatewayUnavailable = true;
632
+ cpr.onChain = {
633
+ perGateway: gateways.map((gw) => ({ gateway: gw, outcome: "unreachable" })),
634
+ rollup: "unreachable",
635
+ onChainOk: null,
636
+ };
637
+ cpr.onChainOk = null;
638
+ cpr.errors.push("checkpoint has no anchor_ref — not yet witnessed, nothing to re-fetch");
639
+ continue;
640
+ }
641
+ const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
404
642
  cpr.onChain = outcome.onChain;
405
643
  cpr.onChainOk = outcome.onChain.onChainOk;
406
644
  if (outcome.unavailable)
@@ -456,6 +694,305 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
456
694
  errors,
457
695
  };
458
696
  }
697
+ // ===========================================================================
698
+ // `sanning.stamp.trace/v1` — the Sanning-signed interval stamp (§5.2, DL-58)
699
+ //
700
+ // ── What this can prove, and what it deliberately does not claim ──
701
+ //
702
+ // A stamp is not a producer artifact. Under DL-52 the customer signs an
703
+ // envelope and POSTs it; the commitment is witnessed later, on an interval, by
704
+ // a stamp the customer never sees and holds no key for. So there is no
705
+ // producer signature inside this body to check, and — this is the part worth
706
+ // being precise about — there is no SECOND Sanning signature either. The
707
+ // control plane stores the stamp's canonical bytes and hands them to a witness
708
+ // as a signed chain data item; the signature lives on the chain, not in the
709
+ // `stamps` table and not in this body.
710
+ //
711
+ // What "Sanning signed this" means for a stamp bundle is therefore exactly the
712
+ // WRAPPER signature, verified generically before this function is reached. And
713
+ // what makes the stamp evidence rather than an assertion is the witness, which
714
+ // is on a chain and not checkable offline. Hence the rollup below: a stamp we
715
+ // can read but nobody has witnessed holds the bundle at `partial`.
716
+ //
717
+ // So the checkable surface here is the stamp's INTERNAL consistency against
718
+ // the one authority in the bundle — the witnessed bytes:
719
+ //
720
+ // 1. `stamp_ref` is SHA-256 of the DECODED bytes (derived, not asserted).
721
+ // 2. Those bytes parse and re-canonicalize to themselves under JCS.
722
+ // 3. Their `stamp_version` is the one this body_type pins.
723
+ // 4. Every sub-tree range accounts for exactly one leaf of the stamp.
724
+ //
725
+ // (4) is the one that carries customer weight. R-8's whole per-agent story is
726
+ // "prove this agent was under evidence over window W", answered by a
727
+ // contiguous `first_leaf`/`leaf_count` slice of the org tree. Ranges that do
728
+ // not tile `[0, leaf_count)` describe a partition that does not exist, so a
729
+ // slice claim resting on them is unfounded — and unlike a bad signature,
730
+ // nothing else in the stack would ever notice.
731
+ // ===========================================================================
732
+ /** Lowercase hex, even length, non-empty. The family's serialization rule. */
733
+ function isLowerHex(s, nibbles) {
734
+ if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0)
735
+ return false;
736
+ if (nibbles !== undefined && s.length !== nibbles)
737
+ return false;
738
+ return /^[0-9a-f]+$/.test(s);
739
+ }
740
+ function isNonNegInt(n) {
741
+ return typeof n === "number" && Number.isInteger(n) && n >= 0;
742
+ }
743
+ /**
744
+ * Parse and check one stamp's witnessed bytes.
745
+ *
746
+ * Errors accumulate rather than short-circuit wherever a later check is still
747
+ * meaningful, so a bundle with two independent defects reports two — a
748
+ * verifier that stops at the first one turns a second fix into a second
749
+ * round-trip.
750
+ */
751
+ async function verifyStampEntry(raw, seen) {
752
+ const errors = [];
753
+ const base = {
754
+ stampRef: "",
755
+ stampRefOk: false,
756
+ bodyOk: false,
757
+ stampVersion: null,
758
+ intervalStart: null,
759
+ intervalEnd: null,
760
+ merkleRoot: null,
761
+ leafCount: null,
762
+ subtreesOk: false,
763
+ subtrees: [],
764
+ anchorRef: null,
765
+ ok: false,
766
+ errors,
767
+ };
768
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
769
+ errors.push("stamp entry is not an object");
770
+ return base;
771
+ }
772
+ const e = raw;
773
+ // --- the witnessed bytes, which are the only authority in this body -------
774
+ if (!isLowerHex(e.stamp_bytes)) {
775
+ errors.push("stamp_bytes is missing or not lowercase hex");
776
+ return base;
777
+ }
778
+ let bytes;
779
+ try {
780
+ bytes = hexToBytes(e.stamp_bytes);
781
+ }
782
+ catch (err) {
783
+ errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
784
+ return base;
785
+ }
786
+ // --- the derived identity -------------------------------------------------
787
+ const derived = await sha256Hex(bytes);
788
+ if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
789
+ errors.push("stamp entry missing stamp_ref");
790
+ }
791
+ else if (e.stamp_ref.toLowerCase() !== derived) {
792
+ // The bytes were edited and the label was not — or the label was edited
793
+ // and the bytes were not. Either way the bundle names a stamp it is not
794
+ // carrying, and quoting that name to a witness would resolve to something
795
+ // else entirely.
796
+ errors.push(`stamp_ref does not match SHA-256(stamp_bytes): claimed=${e.stamp_ref} derived=${derived}`);
797
+ }
798
+ else {
799
+ base.stampRefOk = true;
800
+ base.stampRef = derived;
801
+ if (seen.has(derived)) {
802
+ errors.push(`duplicate stamp_ref ${derived}`);
803
+ base.stampRefOk = false;
804
+ }
805
+ else {
806
+ seen.add(derived);
807
+ }
808
+ }
809
+ // --- the body itself ------------------------------------------------------
810
+ let body;
811
+ try {
812
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
813
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
814
+ errors.push("stamp body is not a JSON object");
815
+ return base;
816
+ }
817
+ body = parsed;
818
+ // Re-canonicalize and compare. The plane replays these bytes verbatim on
819
+ // every submit attempt for exactly this reason: a non-canonical body makes
820
+ // a retry a DIFFERENT submission, and makes every independent re-derivation
821
+ // of the stamp disagree with the one that was witnessed.
822
+ if (jcs(body) !== new TextDecoder().decode(bytes)) {
823
+ errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
824
+ return base;
825
+ }
826
+ }
827
+ catch (err) {
828
+ errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
829
+ return base;
830
+ }
831
+ if (body.stamp_version !== STAMP_BODY_VERSION) {
832
+ // A `sanning.stamp.trace/v1` body whose inner version is something else is
833
+ // two answers to one question inside one signature. The body_type is the
834
+ // reader's dispatch key and it said v1; honouring the other spelling is
835
+ // how a reader half-parses a shape it does not know.
836
+ errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
837
+ `${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`);
838
+ return base;
839
+ }
840
+ base.stampVersion = body.stamp_version;
841
+ base.bodyOk = true;
842
+ for (const f of ["org_ref", "interval_start", "interval_end"]) {
843
+ if (typeof body[f] !== "string" || body[f] === "") {
844
+ errors.push(`stamp body ${f} is missing or not a non-empty string`);
845
+ base.bodyOk = false;
846
+ }
847
+ }
848
+ base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
849
+ base.intervalEnd = typeof body.interval_end === "string" ? body.interval_end : null;
850
+ if (!isLowerHex(body.merkle_root, 64)) {
851
+ errors.push("stamp body merkle_root is not 64 lowercase hex characters");
852
+ base.bodyOk = false;
853
+ }
854
+ else {
855
+ base.merkleRoot = body.merkle_root;
856
+ }
857
+ if (!isNonNegInt(body.leaf_count)) {
858
+ errors.push("stamp body leaf_count is not a non-negative integer");
859
+ base.bodyOk = false;
860
+ }
861
+ else {
862
+ base.leafCount = body.leaf_count;
863
+ }
864
+ // --- the sub-tree ranges --------------------------------------------------
865
+ base.subtreesOk = checkSubtrees(body, base, errors);
866
+ base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
867
+ base.ok =
868
+ base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
869
+ return base;
870
+ }
871
+ /**
872
+ * The range arithmetic (§5.2), and the reason it is a hard check.
873
+ *
874
+ * `buildStampTree` orders a stamp's leaves agent-major, which is the only
875
+ * reason `first_leaf` + `leaf_count` can describe an agent's slice at all: the
876
+ * ranges partition `[0, leaf_count)` exactly, by construction. A body whose
877
+ * ranges do not is either not a stamp this stack built or one built wrong, and
878
+ * in both cases the per-agent claim the ranges exist to support is unfounded.
879
+ *
880
+ * The sum is checked BEFORE the tiling and reports its own message, so a body
881
+ * that is merely short does not get filed under a gap it does not have.
882
+ */
883
+ function checkSubtrees(body, out, errors) {
884
+ const raw = body.subtrees;
885
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
886
+ errors.push("stamp body subtrees is not an object");
887
+ return false;
888
+ }
889
+ let shapeOk = true;
890
+ const subtrees = [];
891
+ for (const [ref, v] of Object.entries(raw)) {
892
+ if (v === null || typeof v !== "object" || Array.isArray(v)) {
893
+ errors.push(`sub-tree ${ref} is not an object`);
894
+ shapeOk = false;
895
+ continue;
896
+ }
897
+ const s = v;
898
+ if (!isNonNegInt(s.first_leaf)) {
899
+ errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
900
+ shapeOk = false;
901
+ }
902
+ // A zero-leaf sub-tree is not a slice of anything, and admitting one lets
903
+ // an arbitrary number of empty ranges sit inside a tiling that still adds
904
+ // up.
905
+ if (!isNonNegInt(s.leaf_count) || s.leaf_count === 0) {
906
+ errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
907
+ shapeOk = false;
908
+ }
909
+ if (!isLowerHex(s.root, 64)) {
910
+ errors.push(`sub-tree ${ref} root is not 64 lowercase hex characters`);
911
+ shapeOk = false;
912
+ }
913
+ if (!shapeOk)
914
+ continue;
915
+ subtrees.push({
916
+ ref,
917
+ firstLeaf: s.first_leaf,
918
+ leafCount: s.leaf_count,
919
+ root: s.root,
920
+ });
921
+ }
922
+ subtrees.sort((a, b) => a.firstLeaf - b.firstLeaf);
923
+ out.subtrees = subtrees;
924
+ if (!shapeOk)
925
+ return false;
926
+ const total = body.leaf_count;
927
+ if (!isNonNegInt(total))
928
+ return false; // already reported by the caller
929
+ const sum = subtrees.reduce((n, s) => n + s.leafCount, 0);
930
+ if (sum !== total) {
931
+ errors.push(`sub-tree leaf_count sum ${sum} does not equal the stamp's leaf_count ${total}`);
932
+ return false;
933
+ }
934
+ let next = 0;
935
+ for (const s of subtrees) {
936
+ if (s.firstLeaf !== next) {
937
+ errors.push(`sub-tree ranges do not tile [0, ${total}): expected a range at leaf ` +
938
+ `${next}, found one at ${s.firstLeaf}`);
939
+ return false;
940
+ }
941
+ next += s.leafCount;
942
+ }
943
+ return true;
944
+ }
945
+ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
946
+ const body = b.body;
947
+ if (!Array.isArray(body?.stamps)) {
948
+ return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
949
+ }
950
+ const seen = new Set();
951
+ const stamps = [];
952
+ for (const entry of body.stamps) {
953
+ stamps.push(await verifyStampEntry(entry, seen));
954
+ }
955
+ // --- Rollup ----------------------------------------------------------------
956
+ const anyHardFailure = stamps.some((s) => !s.ok);
957
+ const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
958
+ let status;
959
+ if (anyHardFailure) {
960
+ status = "failed";
961
+ }
962
+ else if (stamps.length === 0) {
963
+ // A bundle asserting nothing. Not a failure — nothing here is wrong — but
964
+ // it must never read `verified`, which a caller would take as a witness it
965
+ // does not have.
966
+ status = "partial";
967
+ errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
968
+ }
969
+ else if (anyUnwitnessed) {
970
+ // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
971
+ // sealed and unwitnessed: `retained-by-sanning`, which is a statement about
972
+ // our coverage and never an accusation about their evidence. It is also not
973
+ // `verified`, because for a stamp the witness is the entire independent
974
+ // part — unlike an anchor-trace checkpoint, which still carries a producer
975
+ // signature and an inclusion proof that stand on their own offline.
976
+ status = "partial";
977
+ errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
978
+ }
979
+ else {
980
+ status = "verified";
981
+ }
982
+ return {
983
+ status,
984
+ specVersionOk,
985
+ bodyHashOk: true,
986
+ signatureOk: true,
987
+ bodyType: STAMP_TRACE_BODY_TYPE,
988
+ onChainChecked: false,
989
+ checkpoints: [],
990
+ events: [],
991
+ assertedStatus,
992
+ stamps,
993
+ errors,
994
+ };
995
+ }
459
996
  // How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
460
997
  // nested before this path gives up. verifyEvidenceBundle's canonicalization
461
998
  // depth bound already rejects a pathological bundle, but the unwrap must be
@@ -489,7 +1026,7 @@ export async function disclosedContent(bundle, options = {}) {
489
1026
  });
490
1027
  const traceEvents = resolveTraceEvents(bundle);
491
1028
  if (traceEvents === undefined) {
492
- errors.push(`no ${ANCHOR_TRACE_BODY_TYPE} body to materialize content from ` +
1029
+ errors.push(`no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")} body to materialize content from ` +
493
1030
  `(body_type ${JSON.stringify(verification.bodyType)})`);
494
1031
  return empty();
495
1032
  }
@@ -572,14 +1109,20 @@ export async function disclosedContent(bundle, options = {}) {
572
1109
  // wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
573
1110
  // recurses into `source_bundle`, so the events line up with its results.
574
1111
  // `undefined` when there is no anchor-trace body to materialize from.
575
- function resolveTraceEvents(bundle) {
1112
+ //
1113
+ // Exported for `chain.ts`, which needs the same descent and must not derive it
1114
+ // again: two answers to "which events is this bundle actually about" inside one
1115
+ // kernel is two implementations of one rule, and the one that drifted would be
1116
+ // the one nobody was looking at. Deliberately NOT re-exported from `index.ts` —
1117
+ // same posture as `side-content.ts`, internal and shared.
1118
+ export function resolveTraceEvents(bundle) {
576
1119
  let cur = bundle;
577
1120
  for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
578
1121
  if (cur === null || typeof cur !== "object" || Array.isArray(cur))
579
1122
  return undefined;
580
1123
  const b = cur;
581
1124
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
582
- if (bodyType === ANCHOR_TRACE_BODY_TYPE) {
1125
+ if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
583
1126
  const body = b.body;
584
1127
  if (body === null || typeof body !== "object" || !Array.isArray(body.events))
585
1128
  return undefined;
@@ -672,15 +1215,8 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
672
1215
  // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
673
1216
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
674
1217
  const sourceResult = await verifyEvidenceBundle(inline, options);
675
- // Map source checkpoints by tx_id for attestation data_hash binding (§5 6c).
676
- const sourceCheckpoints = new Map();
677
- const sb = inline.body;
678
- if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
679
- for (const cp of sb.checkpoints) {
680
- if (cp && typeof cp.tx_id === "string")
681
- sourceCheckpoints.set(cp.tx_id, cp);
682
- }
683
- }
1218
+ // Attestation data_hash binding (§5 6c).
1219
+ const sourceCheckpoints = await indexSourceCheckpoints(inline);
684
1220
  // --- Step 6: embedded attestation records ---------------------------------
685
1221
  const attestations = [];
686
1222
  for (const rec of body.attestations) {
@@ -772,16 +1308,9 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
772
1308
  }
773
1309
  export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
774
1310
  const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
775
- // Map source checkpoints by tx_id for attestation data_hash binding (§5 6c) —
776
- // identical to verifyExportBody's map construction.
777
- const sourceCheckpoints = new Map();
778
- const sb = sourceBundle.body;
779
- if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
780
- for (const cp of sb.checkpoints) {
781
- if (cp && typeof cp.tx_id === "string")
782
- sourceCheckpoints.set(cp.tx_id, cp);
783
- }
784
- }
1311
+ // Attestation data_hash binding (§5 6c) — the SAME index construction as
1312
+ // verifyExportBody, deliberately shared rather than restated.
1313
+ const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
785
1314
  const attestations = [];
786
1315
  for (const rec of attestationRecords) {
787
1316
  const r = await verifyAttestation(rec, sourceCheckpoints);
@@ -821,7 +1350,7 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
821
1350
  async function verifyAttestation(rec, sourceCheckpoints) {
822
1351
  const errs = [];
823
1352
  const result = {
824
- checkpointTxId: rec && typeof rec.checkpoint_tx_id === "string" ? rec.checkpoint_tx_id : "",
1353
+ checkpointRef: rec && typeof rec.checkpoint_ref === "string" ? rec.checkpoint_ref : "",
825
1354
  operator: "",
826
1355
  gateway: null,
827
1356
  signatureOk: false,
@@ -894,10 +1423,10 @@ async function verifyAttestation(rec, sourceCheckpoints) {
894
1423
  // uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
895
1424
  // against, §4.2), so the checkpoint's committed content hash is
896
1425
  // SHA-256(JCS(checkpoint.envelope)).
897
- const cp = sourceCheckpoints.get(result.checkpointTxId);
1426
+ const cp = sourceCheckpoints.get(result.checkpointRef);
898
1427
  result.checkpointResolved = cp !== undefined;
899
1428
  if (!cp) {
900
- errs.push(`attestation checkpoint_tx_id ${JSON.stringify(result.checkpointTxId)} resolves to no source checkpoint`);
1429
+ errs.push(`attestation checkpoint_ref ${JSON.stringify(result.checkpointRef)} resolves to no source checkpoint`);
901
1430
  }
902
1431
  else {
903
1432
  try {
@@ -949,7 +1478,7 @@ function evaluateSubjectRef(subjectRef, errs) {
949
1478
  function buildVerdictObject(source, attestations) {
950
1479
  const attByCheckpoint = new Map();
951
1480
  for (const a of attestations) {
952
- const list = attByCheckpoint.get(a.checkpointTxId) ?? [];
1481
+ const list = attByCheckpoint.get(a.checkpointRef) ?? [];
953
1482
  list.push({
954
1483
  operator: a.operator,
955
1484
  ...(a.gateway ? { gateway: a.gateway } : {}),
@@ -959,7 +1488,7 @@ function buildVerdictObject(source, attestations) {
959
1488
  ...(a.level !== null ? { level: a.level } : {}),
960
1489
  subject_ref_ok: a.subjectRefOk,
961
1490
  });
962
- attByCheckpoint.set(a.checkpointTxId, list);
1491
+ attByCheckpoint.set(a.checkpointRef, list);
963
1492
  }
964
1493
  const events = source.events.map((e) => ({
965
1494
  event_id: e.eventId,
@@ -970,10 +1499,10 @@ function buildVerdictObject(source, attestations) {
970
1499
  status: eventVerdictStatus(e),
971
1500
  }));
972
1501
  const checkpoints = source.checkpoints.map((c) => ({
973
- checkpoint_tx_id: c.txId,
1502
+ checkpoint_ref: c.checkpointRef,
974
1503
  merkle_root_ok: c.merkleRootOk,
975
1504
  on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
976
- attestations: attByCheckpoint.get(c.txId) ?? [],
1505
+ attestations: attByCheckpoint.get(c.checkpointRef) ?? [],
977
1506
  }));
978
1507
  let verified = 0;
979
1508
  let failed = 0;
@@ -1044,7 +1573,7 @@ function projectVerdictForAgreement(v) {
1044
1573
  const cp = (c ?? {});
1045
1574
  const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
1046
1575
  return {
1047
- checkpoint_tx_id: cp.checkpoint_tx_id ?? null,
1576
+ checkpoint_ref: cp.checkpoint_ref ?? null,
1048
1577
  merkle_root_ok: cp.merkle_root_ok ?? null,
1049
1578
  // on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
1050
1579
  attestations: atts.map((a) => {
@@ -1073,7 +1602,7 @@ function summarizeExport(status, source, attestations) {
1073
1602
  // records a per-gateway array so a confirm/mismatch/unreachable MIX across
1074
1603
  // gateways is preserved; the derived collapsed `onChainOk` reproduces the old
1075
1604
  // single-boolean exactly for a single gateway.
1076
- async function refetchCheckpoint(cp, gateways, fetchImpl) {
1605
+ async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
1077
1606
  let expectedHex;
1078
1607
  try {
1079
1608
  expectedHex = toHex(utf8(jcs(cp.envelope)));
@@ -1085,22 +1614,25 @@ async function refetchCheckpoint(cp, gateways, fetchImpl) {
1085
1614
  }
1086
1615
  const perGateway = [];
1087
1616
  for (const gw of gateways) {
1088
- perGateway.push({ gateway: gw, outcome: await probeGateway(cp, gw, expectedHex, fetchImpl) });
1617
+ perGateway.push({
1618
+ gateway: gw,
1619
+ outcome: await probeGateway(anchorRef, gw, expectedHex, fetchImpl),
1620
+ });
1089
1621
  }
1090
1622
  const onChain = rollupOnChain(perGateway);
1091
1623
  let error;
1092
1624
  if (onChain.rollup === "mismatch") {
1093
- error = `on-chain bytes at ${cp.tx_id} do not match the bundle's checkpoint envelope`;
1625
+ error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
1094
1626
  }
1095
1627
  else if (onChain.rollup === "unreachable") {
1096
- error = `checkpoint ${cp.tx_id} unreachable on all gateways`;
1628
+ error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
1097
1629
  }
1098
1630
  return { onChain, unavailable: onChain.rollup === "unreachable", error };
1099
1631
  }
1100
1632
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
1101
1633
  // (bytes disagree), or unreachable (no response / !ok / network error).
1102
- async function probeGateway(cp, gateway, expectedHex, fetchImpl) {
1103
- const url = `${gateway.replace(/\/+$/, "")}/${cp.tx_id}`;
1634
+ async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
1635
+ const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
1104
1636
  let resp;
1105
1637
  try {
1106
1638
  resp = await fetchImpl(url);
@@ -1154,6 +1686,14 @@ function getEventId(env) {
1154
1686
  }
1155
1687
  return "<unknown>";
1156
1688
  }
1689
+ // The side-input presence rule lives in `side-content.ts`, stated ONCE.
1690
+ //
1691
+ // It used to live here as a private `lookupSideContent`, and `grading.ts`
1692
+ // carried a second, laxer answer to the same question (`hasEntry`: own +
1693
+ // enumerable, nothing more). On one and the same content map the grader said
1694
+ // PRESENT while this verifier said nothing was offered, and `gradePack` then
1695
+ // returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
1696
+ // and for what each clause of it cost.
1157
1697
  function selectDisclosedBytes(ev, eventId, sideContent) {
1158
1698
  const errors = [];
1159
1699
  let inBody;
@@ -1166,19 +1706,33 @@ function selectDisclosedBytes(ev, eventId, sideContent) {
1166
1706
  }
1167
1707
  }
1168
1708
  let side;
1169
- const sideRaw = sideContent?.[eventId];
1170
- if (sideRaw instanceof Uint8Array) {
1171
- side = sideRaw;
1709
+ const sideEntry = sideContentEntry(sideContent, eventId);
1710
+ if (sideEntry.value instanceof Uint8Array) {
1711
+ side = sideEntry.value;
1172
1712
  }
1173
- else if (typeof sideRaw === "string") {
1713
+ else if (typeof sideEntry.value === "string") {
1174
1714
  try {
1175
- side = hexToBytes(sideRaw);
1715
+ side = hexToBytes(sideEntry.value);
1176
1716
  }
1177
1717
  catch (e) {
1178
1718
  errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
1179
1719
  }
1180
1720
  }
1181
- return { inBody, side, offered: ev.content !== undefined || sideRaw !== undefined, errors };
1721
+ // `offered` comes from the SAME test that produced the value, never from
1722
+ // `value !== undefined` — that is the conflation this whole guard exists to
1723
+ // remove, and it was removed from ONE of the two operands. The left one
1724
+ // carried it unchanged: an in-body `"content": null` is `!== undefined`, so
1725
+ // the event was marked offered while no bytes were selected, and the kernel
1726
+ // split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
1727
+ // FAILED, on one bundle. `null` is how an optional field's absence
1728
+ // serializes; `inBodyContentOffered` states that once, beside the side-input
1729
+ // rule it mirrors.
1730
+ return {
1731
+ inBody,
1732
+ side,
1733
+ offered: inBodyContentOffered(ev.content) || sideEntry.offered,
1734
+ errors,
1735
+ };
1182
1736
  }
1183
1737
  function toHex(bytes) {
1184
1738
  let out = "";