@sanning/proof 0.5.0 → 0.6.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,7 +27,21 @@ 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
@@ -149,12 +164,12 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
149
164
  if (bodyType === EXPORT_BODY_TYPE) {
150
165
  return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
151
166
  }
152
- if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
167
+ if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
153
168
  // The wrapper is authentic but we don't know this body's structure. Per
154
169
  // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
155
170
  // body verdict undetermined) — not a failure.
156
171
  errors.push(`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
157
- `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
172
+ `(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`);
158
173
  return {
159
174
  status: "partial",
160
175
  specVersionOk,
@@ -170,31 +185,168 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
170
185
  }
171
186
  return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
172
187
  }
188
+ /**
189
+ * Index a source bundle's checkpoints by the key its attestations will use.
190
+ *
191
+ * The attestation join key follows the SOURCE bundle's own vocabulary — a
192
+ * v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
193
+ * alternative (always deriving a v2-style key) would silently un-resolve every
194
+ * attestation ever written against a v1 export: `data_hash_bound` would go
195
+ * false, the export would stop reaching `verified`, and nothing would say why.
196
+ */
197
+ async function indexSourceCheckpoints(bundle) {
198
+ const out = new Map();
199
+ const sb = bundle.body;
200
+ const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
201
+ if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints))
202
+ return out;
203
+ for (const cp of sb.checkpoints) {
204
+ if (!cp || typeof cp !== "object")
205
+ continue;
206
+ let recordBytes;
207
+ try {
208
+ if (typeof cp.record_bytes === "string")
209
+ recordBytes = hexToBytes(cp.record_bytes);
210
+ }
211
+ catch {
212
+ recordBytes = undefined;
213
+ }
214
+ const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
215
+ if (keyed.key !== null && !out.has(keyed.key))
216
+ out.set(keyed.key, cp);
217
+ }
218
+ return out;
219
+ }
220
+ /**
221
+ * Resolve a checkpoint's join key and its witness reference, for whichever
222
+ * body form we are reading.
223
+ *
224
+ * ── The rule that matters, and why it is a reject rather than a preference ──
225
+ *
226
+ * A checkpoint carrying BOTH vocabularies is two answers to one question
227
+ * inside one signature. A reader that silently prefers one gives a different
228
+ * answer from a reader that prefers the other, over the same signed bytes —
229
+ * and both look like they verified. So a v2 body carrying `tx_id`, or a v1
230
+ * body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
231
+ * resolved. This is cheap to enforce here and impossible to enforce later.
232
+ *
233
+ * ── Why v2's key is recomputed ──
234
+ *
235
+ * In v1 the key was an asserted label matched against another asserted label;
236
+ * nothing tied either to the bytes, so relabelling a checkpoint and every
237
+ * event pointing at it left a bundle that still verified. In v2 the key IS the
238
+ * bytes, so the same relabel fails here. v1's behaviour is preserved exactly
239
+ * as it was — deliberately, because those bundles are already signed and
240
+ * re-interpreting them is not backwards compatibility.
241
+ */
242
+ async function resolveCheckpointKey(cp, recordBytes, isV1) {
243
+ const errors = [];
244
+ if (isV1) {
245
+ if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
246
+ errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) — " +
247
+ "one bundle speaks one vocabulary");
248
+ return { key: null, anchorRef: null, errors };
249
+ }
250
+ if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
251
+ errors.push("checkpoint missing tx_id");
252
+ return { key: null, anchorRef: null, errors };
253
+ }
254
+ // v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
255
+ // through the same shape as v2 gives ONE reader-side vocabulary without
256
+ // touching a byte of the signed bundle.
257
+ return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
258
+ }
259
+ if (cp.tx_id !== undefined) {
260
+ errors.push("sanning.anchor.trace/v2 checkpoint carries legacy tx_id — one bundle speaks one vocabulary");
261
+ return { key: null, anchorRef: null, errors };
262
+ }
263
+ if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
264
+ errors.push("checkpoint missing checkpoint_ref");
265
+ return { key: null, anchorRef: null, errors };
266
+ }
267
+ if (recordBytes === undefined) {
268
+ // Without the bytes the key cannot be recomputed, so it cannot be trusted.
269
+ errors.push("checkpoint_ref cannot be verified — record_bytes missing or not hex");
270
+ return { key: null, anchorRef: null, errors };
271
+ }
272
+ const derived = await sha256Hex(recordBytes);
273
+ if (derived !== cp.checkpoint_ref.toLowerCase()) {
274
+ errors.push(`checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
275
+ return { key: null, anchorRef: null, errors };
276
+ }
277
+ const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
278
+ return { key: derived, anchorRef, errors };
279
+ }
280
+ /**
281
+ * Validate an optional `anchor_ref` (§5.1.1).
282
+ *
283
+ * ── An unrecognised `kind` is NOT a failure, and that is the point ──
284
+ *
285
+ * The offline verdict — signatures, payload binding, Merkle inclusion — is
286
+ * computed with no witness access at all. A reader that cannot resolve a
287
+ * witness has therefore learned NOTHING about whether the evidence was
288
+ * altered, and must not say otherwise. The shipped issuer did the opposite:
289
+ * an unrecognised signature type returned `false` rather than `null`, mapped
290
+ * to `tampered`, and was cached as permanent — a false accusation, produced
291
+ * by a coverage gap, for a company whose product is telling people whether
292
+ * records were altered.
293
+ *
294
+ * So this validates the SHAPE and never the vocabulary. A malformed
295
+ * `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
296
+ * the reader, and only the first is the bundle's fault.
297
+ */
298
+ function parseAnchorRef(raw, errors) {
299
+ if (raw === undefined || raw === null)
300
+ return null; // placed, not yet witnessed
301
+ if (typeof raw !== "object" || Array.isArray(raw)) {
302
+ errors.push("anchor_ref must be an object");
303
+ return null;
304
+ }
305
+ const o = raw;
306
+ if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
307
+ errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
308
+ return null;
309
+ }
310
+ if (typeof o.id !== "string" || o.id === "") {
311
+ errors.push("anchor_ref.id is missing or empty");
312
+ return null;
313
+ }
314
+ const ref = o.ref;
315
+ if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
316
+ errors.push("anchor_ref.ref must be an object when present");
317
+ return null;
318
+ }
319
+ return {
320
+ kind: o.kind,
321
+ id: o.id,
322
+ ...(ref === undefined ? {} : { ref: ref }),
323
+ };
324
+ }
173
325
  async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options) {
174
326
  const body = b.body;
327
+ const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
175
328
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
176
- return malformed("sanning.anchor.trace/v1 body must have checkpoints[] and events[]", true);
329
+ return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
177
330
  }
178
331
  const gateways = options.gateways ?? [];
179
332
  const onChainChecked = gateways.length > 0;
180
333
  // --- Step 3: per-checkpoint -------------------------------------------------
181
- const byTxId = new Map();
334
+ //
335
+ // ── Why the join key is resolved BEFORE anything else ──
336
+ //
337
+ // `byKey` is what binds an event to the root its inclusion proof is checked
338
+ // against. Get it wrong and an event proves inclusion in a tree it was never
339
+ // in — the one failure in this file that produces a WRONG VERIFIED, not a
340
+ // failed verify. Everything below is downstream of it.
341
+ const byKey = new Map();
342
+ const anchorByKey = new Map();
182
343
  const checkpointResults = [];
183
344
  for (const cp of body.checkpoints) {
184
345
  const cpErrors = [];
185
346
  let envelopeOk = false;
186
347
  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).
348
+ // Decode record_bytes first: in v2 the join key is DERIVED from it, so it
349
+ // is an input to identity, not just to the payload binding below.
198
350
  let recordBytes;
199
351
  try {
200
352
  if (typeof cp.record_bytes === "string")
@@ -203,6 +355,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
203
355
  catch (e) {
204
356
  cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
205
357
  }
358
+ const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
359
+ cpErrors.push(...keyed.errors);
360
+ if (keyed.key !== null) {
361
+ if (byKey.has(keyed.key)) {
362
+ cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
363
+ }
364
+ else {
365
+ byKey.set(keyed.key, cp);
366
+ anchorByKey.set(keyed.key, keyed.anchorRef);
367
+ }
368
+ }
369
+ // Verify the checkpoint envelope against its committed record (external
370
+ // commitment — the record is the payload these external envelopes bind to).
206
371
  try {
207
372
  const res = await verifyEnvelope(cp.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
208
373
  // A checkpoint must fully bind: signature + the committed record matching
@@ -235,12 +400,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
235
400
  }
236
401
  }
237
402
  checkpointResults.push({
238
- txId: typeof cp.tx_id === "string" ? cp.tx_id : "",
403
+ checkpointRef: keyed.key ?? "",
404
+ anchorRef: keyed.anchorRef,
239
405
  envelopeOk,
240
406
  merkleRootOk,
241
407
  onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
242
408
  onChain: null, // per-gateway (§4.2), filled in step 5
243
- ok: envelopeOk && merkleRootOk,
409
+ // `keyed.errors` is load-bearing here, not decorative. A structurally
410
+ // invalid anchor_ref rides INSIDE the signature, so it is a defect we
411
+ // can see rather than a coverage gap we cannot — and this kernel's
412
+ // standing rule is that malformed is a failure while absent is
413
+ // undetermined (the same rule disclosed `content` follows). Without
414
+ // this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
415
+ ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
244
416
  errors: cpErrors,
245
417
  });
246
418
  }
@@ -274,18 +446,36 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
274
446
  // Bind the event to exactly one checkpoint in the bundle, then verify
275
447
  // RFC 9162 inclusion against THAT checkpoint's merkle_root.
276
448
  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;
449
+ // The join key is spelled differently in the two forms and MUST NOT be read
450
+ // from the other one: an inclusion carrying v1's spelling inside a v2 body
451
+ // is a mixed bundle, and resolving it anyway is how an event silently binds
452
+ // to a checkpoint nobody meant.
453
+ const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
454
+ const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
455
+ const inclAsRecord = incl;
456
+ const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
457
+ const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
458
+ let cp;
279
459
  if (!incl || typeof incl !== "object") {
280
460
  evErrors.push("event missing inclusion proof");
281
461
  }
282
- else if (!checkpointBound) {
283
- evErrors.push(`event inclusion.checkpoint_tx_id ${JSON.stringify(incl.checkpoint_tx_id)} resolves to no checkpoint`);
462
+ else if (strayKey !== undefined) {
463
+ evErrors.push(`event inclusion carries ${wrongField} in a ${bodyType} body — one bundle speaks one vocabulary`);
284
464
  }
465
+ else if (typeof rawKey !== "string") {
466
+ evErrors.push(`event inclusion missing ${keyField}`);
467
+ }
468
+ else {
469
+ cp = byKey.get(rawKey);
470
+ if (cp === undefined) {
471
+ evErrors.push(`event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`);
472
+ }
473
+ }
474
+ const checkpointBound = cp !== undefined;
285
475
  let inclusionOk = false;
286
476
  if (incl && checkpointBound && cp) {
287
477
  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));
478
+ 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
479
  if (!inclusionOk)
290
480
  evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
291
481
  // Defense in depth: the claimed leaf_hash MUST be the hash of the
@@ -397,10 +587,27 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
397
587
  }
398
588
  else {
399
589
  for (const cpr of checkpointResults) {
400
- const cp = byTxId.get(cpr.txId);
590
+ const cp = byKey.get(cpr.checkpointRef);
401
591
  if (!cp)
402
592
  continue;
403
- const outcome = await refetchCheckpoint(cp, gateways, fetchImpl);
593
+ // A checkpoint with no anchor_ref has NOTHING to re-fetch. Under DL-52
594
+ // that is the normal state inside a stamp interval — placed, readable,
595
+ // not yet witnessed — so it reports as unreachable (which downgrades
596
+ // the run to partial) and NEVER as a mismatch. Treating "we never
597
+ // anchored it yet" as "the chain disagrees" would be the same false
598
+ // accusation this whole change is removing.
599
+ if (cpr.anchorRef === null) {
600
+ gatewayUnavailable = true;
601
+ cpr.onChain = {
602
+ perGateway: gateways.map((gw) => ({ gateway: gw, outcome: "unreachable" })),
603
+ rollup: "unreachable",
604
+ onChainOk: null,
605
+ };
606
+ cpr.onChainOk = null;
607
+ cpr.errors.push("checkpoint has no anchor_ref — not yet witnessed, nothing to re-fetch");
608
+ continue;
609
+ }
610
+ const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
404
611
  cpr.onChain = outcome.onChain;
405
612
  cpr.onChainOk = outcome.onChain.onChainOk;
406
613
  if (outcome.unavailable)
@@ -489,7 +696,7 @@ export async function disclosedContent(bundle, options = {}) {
489
696
  });
490
697
  const traceEvents = resolveTraceEvents(bundle);
491
698
  if (traceEvents === undefined) {
492
- errors.push(`no ${ANCHOR_TRACE_BODY_TYPE} body to materialize content from ` +
699
+ errors.push(`no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")} body to materialize content from ` +
493
700
  `(body_type ${JSON.stringify(verification.bodyType)})`);
494
701
  return empty();
495
702
  }
@@ -579,7 +786,7 @@ function resolveTraceEvents(bundle) {
579
786
  return undefined;
580
787
  const b = cur;
581
788
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
582
- if (bodyType === ANCHOR_TRACE_BODY_TYPE) {
789
+ if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
583
790
  const body = b.body;
584
791
  if (body === null || typeof body !== "object" || !Array.isArray(body.events))
585
792
  return undefined;
@@ -672,15 +879,8 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
672
879
  // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
673
880
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
674
881
  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
- }
882
+ // Attestation data_hash binding (§5 6c).
883
+ const sourceCheckpoints = await indexSourceCheckpoints(inline);
684
884
  // --- Step 6: embedded attestation records ---------------------------------
685
885
  const attestations = [];
686
886
  for (const rec of body.attestations) {
@@ -772,16 +972,9 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
772
972
  }
773
973
  export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
774
974
  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
- }
975
+ // Attestation data_hash binding (§5 6c) — the SAME index construction as
976
+ // verifyExportBody, deliberately shared rather than restated.
977
+ const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
785
978
  const attestations = [];
786
979
  for (const rec of attestationRecords) {
787
980
  const r = await verifyAttestation(rec, sourceCheckpoints);
@@ -821,7 +1014,7 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
821
1014
  async function verifyAttestation(rec, sourceCheckpoints) {
822
1015
  const errs = [];
823
1016
  const result = {
824
- checkpointTxId: rec && typeof rec.checkpoint_tx_id === "string" ? rec.checkpoint_tx_id : "",
1017
+ checkpointRef: rec && typeof rec.checkpoint_ref === "string" ? rec.checkpoint_ref : "",
825
1018
  operator: "",
826
1019
  gateway: null,
827
1020
  signatureOk: false,
@@ -894,10 +1087,10 @@ async function verifyAttestation(rec, sourceCheckpoints) {
894
1087
  // uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
895
1088
  // against, §4.2), so the checkpoint's committed content hash is
896
1089
  // SHA-256(JCS(checkpoint.envelope)).
897
- const cp = sourceCheckpoints.get(result.checkpointTxId);
1090
+ const cp = sourceCheckpoints.get(result.checkpointRef);
898
1091
  result.checkpointResolved = cp !== undefined;
899
1092
  if (!cp) {
900
- errs.push(`attestation checkpoint_tx_id ${JSON.stringify(result.checkpointTxId)} resolves to no source checkpoint`);
1093
+ errs.push(`attestation checkpoint_ref ${JSON.stringify(result.checkpointRef)} resolves to no source checkpoint`);
901
1094
  }
902
1095
  else {
903
1096
  try {
@@ -949,7 +1142,7 @@ function evaluateSubjectRef(subjectRef, errs) {
949
1142
  function buildVerdictObject(source, attestations) {
950
1143
  const attByCheckpoint = new Map();
951
1144
  for (const a of attestations) {
952
- const list = attByCheckpoint.get(a.checkpointTxId) ?? [];
1145
+ const list = attByCheckpoint.get(a.checkpointRef) ?? [];
953
1146
  list.push({
954
1147
  operator: a.operator,
955
1148
  ...(a.gateway ? { gateway: a.gateway } : {}),
@@ -959,7 +1152,7 @@ function buildVerdictObject(source, attestations) {
959
1152
  ...(a.level !== null ? { level: a.level } : {}),
960
1153
  subject_ref_ok: a.subjectRefOk,
961
1154
  });
962
- attByCheckpoint.set(a.checkpointTxId, list);
1155
+ attByCheckpoint.set(a.checkpointRef, list);
963
1156
  }
964
1157
  const events = source.events.map((e) => ({
965
1158
  event_id: e.eventId,
@@ -970,10 +1163,10 @@ function buildVerdictObject(source, attestations) {
970
1163
  status: eventVerdictStatus(e),
971
1164
  }));
972
1165
  const checkpoints = source.checkpoints.map((c) => ({
973
- checkpoint_tx_id: c.txId,
1166
+ checkpoint_ref: c.checkpointRef,
974
1167
  merkle_root_ok: c.merkleRootOk,
975
1168
  on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
976
- attestations: attByCheckpoint.get(c.txId) ?? [],
1169
+ attestations: attByCheckpoint.get(c.checkpointRef) ?? [],
977
1170
  }));
978
1171
  let verified = 0;
979
1172
  let failed = 0;
@@ -1044,7 +1237,7 @@ function projectVerdictForAgreement(v) {
1044
1237
  const cp = (c ?? {});
1045
1238
  const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
1046
1239
  return {
1047
- checkpoint_tx_id: cp.checkpoint_tx_id ?? null,
1240
+ checkpoint_ref: cp.checkpoint_ref ?? null,
1048
1241
  merkle_root_ok: cp.merkle_root_ok ?? null,
1049
1242
  // on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
1050
1243
  attestations: atts.map((a) => {
@@ -1073,7 +1266,7 @@ function summarizeExport(status, source, attestations) {
1073
1266
  // records a per-gateway array so a confirm/mismatch/unreachable MIX across
1074
1267
  // gateways is preserved; the derived collapsed `onChainOk` reproduces the old
1075
1268
  // single-boolean exactly for a single gateway.
1076
- async function refetchCheckpoint(cp, gateways, fetchImpl) {
1269
+ async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
1077
1270
  let expectedHex;
1078
1271
  try {
1079
1272
  expectedHex = toHex(utf8(jcs(cp.envelope)));
@@ -1085,22 +1278,25 @@ async function refetchCheckpoint(cp, gateways, fetchImpl) {
1085
1278
  }
1086
1279
  const perGateway = [];
1087
1280
  for (const gw of gateways) {
1088
- perGateway.push({ gateway: gw, outcome: await probeGateway(cp, gw, expectedHex, fetchImpl) });
1281
+ perGateway.push({
1282
+ gateway: gw,
1283
+ outcome: await probeGateway(anchorRef, gw, expectedHex, fetchImpl),
1284
+ });
1089
1285
  }
1090
1286
  const onChain = rollupOnChain(perGateway);
1091
1287
  let error;
1092
1288
  if (onChain.rollup === "mismatch") {
1093
- error = `on-chain bytes at ${cp.tx_id} do not match the bundle's checkpoint envelope`;
1289
+ error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
1094
1290
  }
1095
1291
  else if (onChain.rollup === "unreachable") {
1096
- error = `checkpoint ${cp.tx_id} unreachable on all gateways`;
1292
+ error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
1097
1293
  }
1098
1294
  return { onChain, unavailable: onChain.rollup === "unreachable", error };
1099
1295
  }
1100
1296
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
1101
1297
  // (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}`;
1298
+ async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
1299
+ const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
1104
1300
  let resp;
1105
1301
  try {
1106
1302
  resp = await fetchImpl(url);
@@ -1154,6 +1350,14 @@ function getEventId(env) {
1154
1350
  }
1155
1351
  return "<unknown>";
1156
1352
  }
1353
+ // The side-input presence rule lives in `side-content.ts`, stated ONCE.
1354
+ //
1355
+ // It used to live here as a private `lookupSideContent`, and `grading.ts`
1356
+ // carried a second, laxer answer to the same question (`hasEntry`: own +
1357
+ // enumerable, nothing more). On one and the same content map the grader said
1358
+ // PRESENT while this verifier said nothing was offered, and `gradePack` then
1359
+ // returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
1360
+ // and for what each clause of it cost.
1157
1361
  function selectDisclosedBytes(ev, eventId, sideContent) {
1158
1362
  const errors = [];
1159
1363
  let inBody;
@@ -1166,19 +1370,33 @@ function selectDisclosedBytes(ev, eventId, sideContent) {
1166
1370
  }
1167
1371
  }
1168
1372
  let side;
1169
- const sideRaw = sideContent?.[eventId];
1170
- if (sideRaw instanceof Uint8Array) {
1171
- side = sideRaw;
1373
+ const sideEntry = sideContentEntry(sideContent, eventId);
1374
+ if (sideEntry.value instanceof Uint8Array) {
1375
+ side = sideEntry.value;
1172
1376
  }
1173
- else if (typeof sideRaw === "string") {
1377
+ else if (typeof sideEntry.value === "string") {
1174
1378
  try {
1175
- side = hexToBytes(sideRaw);
1379
+ side = hexToBytes(sideEntry.value);
1176
1380
  }
1177
1381
  catch (e) {
1178
1382
  errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
1179
1383
  }
1180
1384
  }
1181
- return { inBody, side, offered: ev.content !== undefined || sideRaw !== undefined, errors };
1385
+ // `offered` comes from the SAME test that produced the value, never from
1386
+ // `value !== undefined` — that is the conflation this whole guard exists to
1387
+ // remove, and it was removed from ONE of the two operands. The left one
1388
+ // carried it unchanged: an in-body `"content": null` is `!== undefined`, so
1389
+ // the event was marked offered while no bytes were selected, and the kernel
1390
+ // split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
1391
+ // FAILED, on one bundle. `null` is how an optional field's absence
1392
+ // serializes; `inBodyContentOffered` states that once, beside the side-input
1393
+ // rule it mirrors.
1394
+ return {
1395
+ inBody,
1396
+ side,
1397
+ offered: inBodyContentOffered(ev.content) || sideEntry.offered,
1398
+ errors,
1399
+ };
1182
1400
  }
1183
1401
  function toHex(bytes) {
1184
1402
  let out = "";