@sanning/proof 0.8.0 → 0.8.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,11 +38,6 @@
38
38
  "typescript": "^5.4.0",
39
39
  "vitest": "^2.0.0"
40
40
  },
41
- "repository": {
42
- "type": "git",
43
- "url": "git+https://github.com/sanning-io/proof.git",
44
- "directory": "ts"
45
- },
46
41
  "publishConfig": {
47
42
  "access": "public"
48
43
  },
@@ -56,5 +51,6 @@
56
51
  "ed25519",
57
52
  "merkle",
58
53
  "rfc9162"
59
- ]
54
+ ],
55
+ "homepage": "https://docs.sanning.io"
60
56
  }
package/src/cli.ts CHANGED
@@ -697,6 +697,35 @@ function reportEvidence(
697
697
  }
698
698
  }
699
699
 
700
+ // Stamps (evidence-bundle.md §5.2 / §5.3). Printed rather than summarised,
701
+ // because for a stamp body the WITNESS is the entire independent part
702
+ // (§5.2.4) — and until now this reporter showed nothing about it at all, so a
703
+ // §5.3 pack printed a rollup line claiming an on-chain scope with no visible
704
+ // on-chain result anywhere above it.
705
+ //
706
+ // The on-chain column distinguishes all four states in the same three
707
+ // characters the checkpoint block uses, plus its absence: `✓` confirmed,
708
+ // `✗` mismatched, `~` asked and unreachable, and NO COLUMN AT ALL when
709
+ // nothing was checked. A reader must never have to infer "not checked" from
710
+ // a symbol that also means "could not be reached".
711
+ if (result.stamps && result.stamps.length > 0) {
712
+ io.out("");
713
+ io.out(paint(" Stamps", DIM));
714
+ for (const st of result.stamps) {
715
+ const witness =
716
+ st.anchorRef === null
717
+ ? paint("~ no witness", Y)
718
+ : `witness ${st.anchorRef.kind}:${shortTx(st.anchorRef.id)}`;
719
+ const onChain =
720
+ st.onChain === null ? "" : ` on-chain ${mark(st.onChainOk)} (${st.onChain.rollup})`;
721
+ io.out(
722
+ ` ${mark(st.ok)} ${shortTx(st.stampRef)} bytes ${mark(st.stampRefOk && st.bodyOk)}` +
723
+ ` ranges ${mark(st.subtreesOk)} ${witness}${onChain}`,
724
+ );
725
+ for (const e of st.errors) io.out(paint(` - ${e}`, R));
726
+ }
727
+ }
728
+
700
729
  if (result.events.length > 0) {
701
730
  io.out("");
702
731
  io.out(paint(" Events", DIM));
package/src/evidence.ts CHANGED
@@ -428,6 +428,35 @@ export interface StampResult {
428
428
  * carries it.
429
429
  */
430
430
  anchorRef: AnchorRef | null;
431
+ /**
432
+ * Per-gateway on-chain outcomes for this stamp's `anchor_ref` (§4.2's shape,
433
+ * `checkpoints[]`'s vocabulary), or `null` when NOTHING WAS CHECKED.
434
+ *
435
+ * ── The four states, and why none of them collapses ──
436
+ *
437
+ * `null` means not checked — no gateways were supplied, or this stamp names
438
+ * no witness to check. It is a statement about the RUN, never about the
439
+ * stamp. The other three are statements about the stamp and live in
440
+ * `onChain.rollup`: `confirm` (a gateway returned the witnessed bytes),
441
+ * `mismatch` (a gateway returned DIFFERENT bytes — a finding, and the only
442
+ * one of the four that fails anything), `unreachable` (we asked and nobody
443
+ * answered — an availability gap, never an accusation).
444
+ *
445
+ * Keeping "not checked" distinct from "unreachable" is the whole point. A
446
+ * surface handed a collapsed boolean cannot tell "no gateway could be
447
+ * reached" from "no gateway was tried", and reported the first when the
448
+ * second was true — a false accusation manufactured by a missing state.
449
+ */
450
+ onChain: OnChainResult | null;
451
+ /**
452
+ * Derived collapse of `onChain`, mirroring `CheckpointResult.onChainOk` so a
453
+ * consumer reads one field the same way on both: `false` on mismatch, `true`
454
+ * on confirm, `null` on unreachable AND on not-checked.
455
+ *
456
+ * It is deliberately the LOSSY view — `null` is three-quarters of the state
457
+ * space here. Anything that needs to tell those apart reads `onChain`.
458
+ */
459
+ onChainOk: boolean | null;
431
460
  ok: boolean;
432
461
  errors: string[];
433
462
  }
@@ -749,7 +778,7 @@ export async function verifyEvidenceBundle(
749
778
  // wrapper is authentic — and for this body that IS "Sanning signed it", since
750
779
  // a stamp carries no second signature of its own inside the bundle.
751
780
  if (bodyType === STAMP_TRACE_BODY_TYPE) {
752
- return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
781
+ return verifyStampTrace(b, specVersionOk, assertedStatus, errors, options);
753
782
  }
754
783
 
755
784
  // The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
@@ -1365,10 +1394,29 @@ async function verifyDisclosedContent(
1365
1394
  return { contentOk, errors: evErrors };
1366
1395
  }
1367
1396
 
1397
+ /**
1398
+ * One `stamps[]` entry's result, plus the witnessed bytes it was graded on.
1399
+ *
1400
+ * `witnessedHex` is internal and never surfaces on `StampResult`: it is the
1401
+ * whole stamp body again, and putting it back on the result would double every
1402
+ * serialized verdict to carry a copy of what the bundle already holds. It
1403
+ * exists so the optional on-chain re-fetch compares the chain against THE SAME
1404
+ * BYTES this function hashed to derive `stamp_ref`, rather than re-reading
1405
+ * `stamp_bytes` out of the body a second time — one value, so the graded bytes
1406
+ * and the compared bytes cannot come apart.
1407
+ *
1408
+ * Undefined exactly when `stamp_bytes` was unusable, in which case the entry
1409
+ * has already failed and there is nothing a gateway could confirm.
1410
+ */
1411
+ interface StampEntryOutcome {
1412
+ result: StampResult;
1413
+ witnessedHex?: string;
1414
+ }
1415
+
1368
1416
  async function verifyStampEntry(
1369
1417
  raw: unknown,
1370
1418
  seen: Set<string>,
1371
- ): Promise<StampResult> {
1419
+ ): Promise<StampEntryOutcome> {
1372
1420
  const errors: string[] = [];
1373
1421
  const base: StampResult = {
1374
1422
  stampRef: "",
@@ -1382,28 +1430,33 @@ async function verifyStampEntry(
1382
1430
  subtreesOk: false,
1383
1431
  subtrees: [],
1384
1432
  anchorRef: null,
1433
+ onChain: null,
1434
+ onChainOk: null,
1385
1435
  ok: false,
1386
1436
  errors,
1387
1437
  };
1388
1438
 
1389
1439
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1390
1440
  errors.push("stamp entry is not an object");
1391
- return base;
1441
+ return { result: base };
1392
1442
  }
1393
1443
  const e = raw as Partial<StampEntry> & Record<string, unknown>;
1394
1444
 
1395
1445
  // --- the witnessed bytes, which are the only authority in this body -------
1396
1446
  if (!isLowerHex(e.stamp_bytes)) {
1397
1447
  errors.push("stamp_bytes is missing or not lowercase hex");
1398
- return base;
1448
+ return { result: base };
1399
1449
  }
1400
1450
  let bytes: Uint8Array;
1401
1451
  try {
1402
1452
  bytes = hexToBytes(e.stamp_bytes);
1403
1453
  } catch (err) {
1404
1454
  errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
1405
- return base;
1455
+ return { result: base };
1406
1456
  }
1457
+ // Gated by `isLowerHex` above, so this is already the exact lowercase hex of
1458
+ // the exact witnessed bytes — §5.2.2's "one authority in this body".
1459
+ const witnessedHex: string = e.stamp_bytes;
1407
1460
 
1408
1461
  // --- the derived identity -------------------------------------------------
1409
1462
  const derived = await sha256Hex(bytes);
@@ -1434,7 +1487,7 @@ async function verifyStampEntry(
1434
1487
  const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
1435
1488
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1436
1489
  errors.push("stamp body is not a JSON object");
1437
- return base;
1490
+ return { result: base, witnessedHex };
1438
1491
  }
1439
1492
  body = parsed as StampBody;
1440
1493
  // Re-canonicalize and compare. The plane replays these bytes verbatim on
@@ -1443,11 +1496,11 @@ async function verifyStampEntry(
1443
1496
  // of the stamp disagree with the one that was witnessed.
1444
1497
  if (jcs(body as unknown as Record<string, unknown>) !== new TextDecoder().decode(bytes)) {
1445
1498
  errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
1446
- return base;
1499
+ return { result: base, witnessedHex };
1447
1500
  }
1448
1501
  } catch (err) {
1449
1502
  errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
1450
- return base;
1503
+ return { result: base, witnessedHex };
1451
1504
  }
1452
1505
 
1453
1506
  if (body.stamp_version !== STAMP_BODY_VERSION) {
@@ -1459,7 +1512,7 @@ async function verifyStampEntry(
1459
1512
  `unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
1460
1513
  `${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
1461
1514
  );
1462
- return base;
1515
+ return { result: base, witnessedHex };
1463
1516
  }
1464
1517
  base.stampVersion = body.stamp_version;
1465
1518
  base.bodyOk = true;
@@ -1493,7 +1546,7 @@ async function verifyStampEntry(
1493
1546
 
1494
1547
  base.ok =
1495
1548
  base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
1496
- return base;
1549
+ return { result: base, witnessedHex };
1497
1550
  }
1498
1551
 
1499
1552
  /**
@@ -1581,6 +1634,7 @@ async function verifyStampTrace(
1581
1634
  specVersionOk: boolean,
1582
1635
  assertedStatus: string | null,
1583
1636
  errors: string[],
1637
+ options: VerifyEvidenceOptions,
1584
1638
  ): Promise<EvidenceBundleResult> {
1585
1639
  const body = b.body as StampTraceBody;
1586
1640
  if (!Array.isArray(body?.stamps)) {
@@ -1589,10 +1643,27 @@ async function verifyStampTrace(
1589
1643
 
1590
1644
  const seen = new Set<string>();
1591
1645
  const stamps: StampResult[] = [];
1646
+ const witnessed: (string | undefined)[] = [];
1592
1647
  for (const entry of body.stamps) {
1593
- stamps.push(await verifyStampEntry(entry, seen));
1648
+ const outcome = await verifyStampEntry(entry, seen);
1649
+ stamps.push(outcome.result);
1650
+ witnessed.push(outcome.witnessedHex);
1594
1651
  }
1595
1652
 
1653
+ // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
1654
+ //
1655
+ // The SAME stamps[] array §5.3 carries, so it gets the same capability. The
1656
+ // alternative — a stamp that can be re-fetched inside one body and not the
1657
+ // other — asks every consumer to remember which wrapper it is holding before
1658
+ // it can trust `onChain: null`, and that is exactly the kind of per-body
1659
+ // exception a reader eventually gets wrong.
1660
+ const { onChainChecked, gatewayUnavailable } = await refetchStamps(
1661
+ stamps,
1662
+ witnessed,
1663
+ options,
1664
+ errors,
1665
+ );
1666
+
1596
1667
  // --- Rollup ----------------------------------------------------------------
1597
1668
  const anyHardFailure = stamps.some((s) => !s.ok);
1598
1669
  const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
@@ -1606,17 +1677,25 @@ async function verifyStampTrace(
1606
1677
  // does not have.
1607
1678
  status = "partial";
1608
1679
  errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
1609
- } else if (anyUnwitnessed) {
1610
- // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1611
- // sealed and unwitnessed: `retained-by-sanning`, which is a statement about
1612
- // our coverage and never an accusation about their evidence. It is also not
1613
- // `verified`, because for a stamp the witness is the entire independent
1614
- // part — unlike an anchor-trace checkpoint, which still carries a producer
1615
- // signature and an inclusion proof that stand on their own offline.
1680
+ } else if (gatewayUnavailable || anyUnwitnessed) {
1681
+ // Both hold the bundle at `partial`, and both get said — see §5.3's rollup
1682
+ // for why one note is not enough when there are two different gaps.
1616
1683
  status = "partial";
1617
- errors.push(
1618
- "one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
1619
- );
1684
+ if (gatewayUnavailable) {
1685
+ errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1686
+ }
1687
+ if (anyUnwitnessed) {
1688
+ // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1689
+ // sealed and unwitnessed: `retained-by-sanning`, which is a statement
1690
+ // about our coverage and never an accusation about their evidence. It is
1691
+ // also not `verified`, because for a stamp the witness is the entire
1692
+ // independent part — unlike an anchor-trace checkpoint, which still
1693
+ // carries a producer signature and an inclusion proof that stand on
1694
+ // their own offline.
1695
+ errors.push(
1696
+ "one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
1697
+ );
1698
+ }
1620
1699
  } else {
1621
1700
  status = "verified";
1622
1701
  }
@@ -1627,7 +1706,7 @@ async function verifyStampTrace(
1627
1706
  bodyHashOk: true,
1628
1707
  signatureOk: true,
1629
1708
  bodyType: STAMP_TRACE_BODY_TYPE,
1630
- onChainChecked: false,
1709
+ onChainChecked,
1631
1710
  checkpoints: [],
1632
1711
  events: [],
1633
1712
  assertedStatus,
@@ -1666,7 +1745,12 @@ async function verifyStampEvidence(
1666
1745
  // --- The stamp half: §5.2's rules, §5.2's code ----------------------------
1667
1746
  const seen = new Set<string>();
1668
1747
  const stamps: StampResult[] = [];
1669
- for (const entry of body.stamps) stamps.push(await verifyStampEntry(entry, seen));
1748
+ const witnessed: (string | undefined)[] = [];
1749
+ for (const entry of body.stamps) {
1750
+ const outcome = await verifyStampEntry(entry, seen);
1751
+ stamps.push(outcome.result);
1752
+ witnessed.push(outcome.witnessedHex);
1753
+ }
1670
1754
 
1671
1755
  // The join table. ONLY a stamp whose derived ref and witnessed bytes both
1672
1756
  // checked out is joinable: a root read out of bytes that failed their own
@@ -1811,6 +1895,14 @@ async function verifyStampEvidence(
1811
1895
  });
1812
1896
  }
1813
1897
 
1898
+ // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
1899
+ const { onChainChecked, gatewayUnavailable } = await refetchStamps(
1900
+ stamps,
1901
+ witnessed,
1902
+ options,
1903
+ errors,
1904
+ );
1905
+
1814
1906
  // --- Rollup ---------------------------------------------------------------
1815
1907
  // Rule 4: a declared event MUST be checked. `events[]` present and unverified
1816
1908
  // is the false green this body exists to prevent, so an event that did not
@@ -1824,13 +1916,23 @@ async function verifyStampEvidence(
1824
1916
  } else if (stamps.length === 0 && eventResults.length === 0) {
1825
1917
  status = "partial";
1826
1918
  errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
1827
- } else if (anyUnwitnessed) {
1828
- // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
1829
- // the independent part, so the events resting on it inherit `partial`.
1919
+ } else if (gatewayUnavailable || anyUnwitnessed) {
1920
+ // Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
1921
+ // witnessed and a witness we could not reach are different gaps with
1922
+ // different fixes — one waits for the interval, the other for the network —
1923
+ // and an auditor told only about the second would read the first as fine.
1924
+ // The order is fixed so the two kernels emit the same list.
1830
1925
  status = "partial";
1831
- errors.push(
1832
- "one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
1833
- );
1926
+ if (gatewayUnavailable) {
1927
+ errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1928
+ }
1929
+ if (anyUnwitnessed) {
1930
+ // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
1931
+ // the independent part, so the events resting on it inherit `partial`.
1932
+ errors.push(
1933
+ "one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
1934
+ );
1935
+ }
1834
1936
  } else {
1835
1937
  status = "verified";
1836
1938
  }
@@ -1841,7 +1943,7 @@ async function verifyStampEvidence(
1841
1943
  bodyHashOk: true,
1842
1944
  signatureOk: true,
1843
1945
  bodyType: STAMP_EVIDENCE_BODY_TYPE,
1844
- onChainChecked: false,
1946
+ onChainChecked,
1845
1947
  checkpoints: [],
1846
1948
  events: eventResults,
1847
1949
  assertedStatus,
@@ -1850,6 +1952,83 @@ async function verifyStampEvidence(
1850
1952
  };
1851
1953
  }
1852
1954
 
1955
+ /**
1956
+ * The optional on-chain re-fetch over `stamps[]`, shared by §5.2 and §5.3.
1957
+ *
1958
+ * ── Why the results land on `stamps[]` and `checkpoints` stays empty ──
1959
+ *
1960
+ * A stamp body carries no checkpoints. Synthesising `CheckpointResult`s to
1961
+ * carry these outcomes would make a stamp indistinguishable from a checkpoint
1962
+ * in every consumer that reads `checkpoints[]` — the exact conflation §5.2.1
1963
+ * and §5.3.1 refused twice, most sharply in "a Sanning attestation
1964
+ * indistinguishable on the wire from a producer's". They are different
1965
+ * artifacts with different signers and different meanings when absent, and a
1966
+ * reader must never have to guess which one it is holding. So the stamp
1967
+ * results go on the stamps, and `checkpoints: []` keeps saying the true thing:
1968
+ * this body has none.
1969
+ *
1970
+ * ── Why an unwitnessed stamp gets `onChain: null` and NOT all-unreachable ──
1971
+ *
1972
+ * The anchor-trace path synthesises an all-`unreachable` result for a
1973
+ * checkpoint with no `anchor_ref`. That is not copied here, for two reasons.
1974
+ *
1975
+ * It would be untrue: nothing was asked of any gateway, so reporting that
1976
+ * every gateway failed to answer invents a network event. "Not checked" and
1977
+ * "we asked and nobody answered" are different facts, and collapsing them is
1978
+ * precisely the defect that had a surface reporting an unreachable gateway
1979
+ * when it had contacted none.
1980
+ *
1981
+ * And it would be load-bearing in the wrong direction. Over there the
1982
+ * synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
1983
+ * run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
1984
+ * `partial` on its own, with a better error. Copying the synthesis would add
1985
+ * nothing to the status and would silently move the CLI exit code from 0 to 3
1986
+ * for an unwitnessed stamp — changing what an auditor's script does, based on
1987
+ * nothing but whether the caller happened to pass a gateway list.
1988
+ */
1989
+ async function refetchStamps(
1990
+ stamps: StampResult[],
1991
+ witnessed: (string | undefined)[],
1992
+ options: VerifyEvidenceOptions,
1993
+ errors: string[],
1994
+ ): Promise<{ onChainChecked: boolean; gatewayUnavailable: boolean }> {
1995
+ const gateways = options.gateways ?? [];
1996
+ const onChainChecked = gateways.length > 0;
1997
+ if (!onChainChecked) return { onChainChecked, gatewayUnavailable: false };
1998
+
1999
+ const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
2000
+ if (typeof fetchImpl !== "function") {
2001
+ errors.push("on-chain re-fetch requested but no fetch implementation is available");
2002
+ return { onChainChecked, gatewayUnavailable: true };
2003
+ }
2004
+
2005
+ let gatewayUnavailable = false;
2006
+ for (let i = 0; i < stamps.length; i++) {
2007
+ const st = stamps[i]!;
2008
+ const witnessedHex = witnessed[i];
2009
+ // No witness named, or bytes we could not read: nothing to re-fetch, and
2010
+ // nothing a gateway could confirm or deny. Left as `onChain: null`.
2011
+ if (st.anchorRef === null || witnessedHex === undefined) continue;
2012
+
2013
+ const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
2014
+ st.onChain = outcome.onChain;
2015
+ st.onChainOk = outcome.onChain.onChainOk;
2016
+ if (outcome.unavailable) gatewayUnavailable = true;
2017
+ if (outcome.error) st.errors.push(outcome.error);
2018
+ // Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
2019
+ // (false) fails the stamp — the chain does not hold what the bundle says
2020
+ // was witnessed. UNREACHABLE (null) leaves an offline-sound stamp ok and
2021
+ // downgrades the run to `partial` via gatewayUnavailable. A confirm (true)
2022
+ // changes nothing that was not already true offline.
2023
+ //
2024
+ // `st.ok` is kept and narrowed rather than recomputed: its offline form
2025
+ // includes `errors.length === 0`, and re-evaluating that AFTER pushing an
2026
+ // unreachable note would fail a stamp for a gateway being down.
2027
+ st.ok = st.ok && st.onChainOk !== false;
2028
+ }
2029
+ return { onChainChecked, gatewayUnavailable };
2030
+ }
2031
+
1853
2032
  // ===========================================================================
1854
2033
  // Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
1855
2034
  //
@@ -2621,6 +2800,10 @@ async function refetchCheckpoint(
2621
2800
  ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
2622
2801
  let expectedHex: string | undefined;
2623
2802
  try {
2803
+ // A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
2804
+ // bundle carries the envelope as a JSON object, not as bytes, so what was
2805
+ // uploaded has to be recomputed from it. See `refetchStamp` for the case
2806
+ // where this step is not merely unnecessary but WRONG.
2624
2807
  expectedHex = toHex(utf8(jcs(cp.envelope)));
2625
2808
  } catch {
2626
2809
  // Cannot canonicalize the checkpoint envelope — every gateway is a mismatch
@@ -2628,6 +2811,54 @@ async function refetchCheckpoint(
2628
2811
  expectedHex = undefined;
2629
2812
  }
2630
2813
 
2814
+ return refetchAnchored("checkpoint", expectedHex, anchorRef, gateways, fetchImpl);
2815
+ }
2816
+
2817
+ // Re-fetch a STAMP's witnessed bytes (evidence-bundle.md §5.2.2 / §5.3.2).
2818
+ //
2819
+ // ── The one step this path must NOT copy from the checkpoint path ──
2820
+ //
2821
+ // `refetchCheckpoint` recomputes `JCS(envelope)` because a checkpoint's
2822
+ // witnessed bytes are not in the bundle — only the object they were derived
2823
+ // from is. A stamp is the opposite case: §5.2.2 defines `stamp_bytes` as
2824
+ // "lowercase hex of the exact JCS bytes that were handed to the witness", and
2825
+ // §5.3.2 repeats it — *"the EXACT bytes that were witnessed, verbatim"*. They
2826
+ // are already the bytes, already hex, already lowercase (`isLowerHex` gated
2827
+ // them), and `stamp_ref` is their SHA-256.
2828
+ //
2829
+ // So the expected hex IS `stamp_bytes`, and re-canonicalizing would be a
2830
+ // defect, not a redundancy: it would compare the chain against bytes NOBODY
2831
+ // SIGNED. On a canonical body the two agree and the bug hides; on a body that
2832
+ // is not canonical — the exact case §5.2.2 makes the kernel check for, because
2833
+ // the plane replays these bytes as text precisely so a normalizing serializer
2834
+ // cannot alter them — a re-canonicalizing verifier reports `mismatch` against
2835
+ // a chain that faithfully holds what it was given. A false accusation, from
2836
+ // the verifier, about untampered evidence.
2837
+ //
2838
+ // The bytes handed here are the SAME value `verifyStampEntry` hashed to derive
2839
+ // `stamp_ref`, threaded through rather than re-read from the body, so the bytes
2840
+ // graded and the bytes compared cannot drift apart.
2841
+ async function refetchStamp(
2842
+ witnessedHex: string,
2843
+ anchorRef: AnchorRef,
2844
+ gateways: string[],
2845
+ fetchImpl: typeof fetch,
2846
+ ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
2847
+ return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
2848
+ }
2849
+
2850
+ // The part both paths share: probe every gateway, roll the outcomes up under
2851
+ // the ONE rule (`rollupOnChain`), and phrase the finding. Extracted rather than
2852
+ // copied — a second rollup rule is a second place for "mismatch beats confirm
2853
+ // beats unreachable" to drift, and the drifted copy is the one nobody reads.
2854
+ // The subject only ever selects wording; it never changes a verdict.
2855
+ async function refetchAnchored(
2856
+ subject: "checkpoint" | "stamp",
2857
+ expectedHex: string | undefined,
2858
+ anchorRef: AnchorRef,
2859
+ gateways: string[],
2860
+ fetchImpl: typeof fetch,
2861
+ ): Promise<{ onChain: OnChainResult; unavailable: boolean; error?: string }> {
2631
2862
  const perGateway: PerGatewayOutcome[] = [];
2632
2863
  for (const gw of gateways) {
2633
2864
  perGateway.push({
@@ -2639,9 +2870,12 @@ async function refetchCheckpoint(
2639
2870
  const onChain = rollupOnChain(perGateway);
2640
2871
  let error: string | undefined;
2641
2872
  if (onChain.rollup === "mismatch") {
2642
- error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
2873
+ error =
2874
+ subject === "checkpoint"
2875
+ ? `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`
2876
+ : `on-chain bytes at ${anchorRef.id} do not match the stamp's witnessed bytes`;
2643
2877
  } else if (onChain.rollup === "unreachable") {
2644
- error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
2878
+ error = `${subject} ${anchorRef.id} unreachable on all gateways`;
2645
2879
  }
2646
2880
  return { onChain, unavailable: onChain.rollup === "unreachable", error };
2647
2881
  }
package/vectors/README.md CHANGED
@@ -8,6 +8,24 @@ That distinction is the whole point. A rule that consumers must agree with needs
8
8
  a statement they can *fetch*, not one they have to be trusted to have copied
9
9
  correctly.
10
10
 
11
+ ## `stamp-on-chain.json`
12
+
13
+ The rule that decides what re-fetching a **stamp's witness** from a gateway does
14
+ and does not settle — the optional, online half of `sanning.stamp.trace/v1` and
15
+ `sanning.stamp.evidence/v1` (`evidence-bundle.md` §5.2.4 / §5.3.4). Implemented
16
+ by [`../src/evidence.ts`](../src/evidence.ts) and asserted against this table by
17
+ [`../test/stamp-on-chain-vectors.test.ts`](../test/stamp-on-chain-vectors.test.ts)
18
+ — **and by the Python kernel against the same bytes**, in
19
+ `tests/test_stamp_on_chain_vectors.py`, so neither kernel can pass by agreeing
20
+ with the other.
21
+
22
+ **The one thing a consumer must not do with it: collapse the four states into a
23
+ boolean.** `not checked` · `confirm` · `mismatch` · `unreachable`. A reader that
24
+ cannot tell the first from the last will report that no gateway could be reached
25
+ on a run that contacted none — a false accusation manufactured by a missing
26
+ state, and the defect this table was written after. Vendoring rules are the same
27
+ three steps as below, against `vectors/stamp-on-chain.json`.
28
+
11
29
  ## `pack-grading.json`
12
30
 
13
31
  The rule that decides whether a **pack** — a signed bundle plus whatever