@sanning/proof 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/grading.ts CHANGED
@@ -185,7 +185,7 @@ interface BundleLike {
185
185
  envelope?: { event_id?: unknown };
186
186
  inclusion?: { checkpoint_ref?: unknown; checkpoint_tx_id?: unknown };
187
187
  }[];
188
- checkpoints?: { checkpoint_ref?: unknown; tx_id?: unknown; record_bytes?: unknown }[];
188
+ checkpoints?: { checkpoint_ref?: unknown; tx_id?: unknown; event_bytes?: unknown }[];
189
189
  };
190
190
  }
191
191
 
@@ -225,6 +225,22 @@ function inclusionKeyOf(
225
225
  return (hasV2 ? incl.checkpoint_ref : incl.checkpoint_tx_id) as string;
226
226
  }
227
227
 
228
+ // The key a checkpoint and an inclusion are MATCHED on. A `checkpoint_ref` is
229
+ // folded to lower case, because the verifier lowercases it before comparing.
230
+ // A retired `tx_id` / `checkpoint_tx_id` is compared exactly as written, as the
231
+ // retired reader did, even when it happens to look like hex.
232
+ function checkpointMatchKey(o: { checkpoint_ref?: unknown; tx_id?: unknown } | null | undefined): string | null {
233
+ const k = joinKeyOf(o);
234
+ return k !== null && typeof o?.checkpoint_ref === "string" ? k.toLowerCase() : k;
235
+ }
236
+
237
+ function inclusionMatchKey(
238
+ incl: { checkpoint_ref?: unknown; checkpoint_tx_id?: unknown } | null | undefined,
239
+ ): string | null {
240
+ const k = inclusionKeyOf(incl);
241
+ return k !== null && typeof incl?.checkpoint_ref === "string" ? k.toLowerCase() : k;
242
+ }
243
+
228
244
  const eventsOf = (bundle: unknown) => {
229
245
  const e = (bundle as BundleLike | null)?.body?.events;
230
246
  return Array.isArray(e) ? e : [];
@@ -402,8 +418,8 @@ export function accountDisclosure(
402
418
  }
403
419
 
404
420
  /** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
405
- export function signedLeafCount(checkpoint: { record_bytes?: unknown } | null | undefined): number | null {
406
- const hex = checkpoint?.record_bytes;
421
+ export function signedLeafCount(checkpoint: { event_bytes?: unknown } | null | undefined): number | null {
422
+ const hex = checkpoint?.event_bytes;
407
423
  if (typeof hex !== "string" || hex.length === 0) return null;
408
424
  try {
409
425
  const record = JSON.parse(new TextDecoder().decode(hexToBytes(hex))) as {
@@ -442,7 +458,14 @@ export function completenessNotes(bundle: unknown): CompletenessNote[] {
442
458
  notes.push({ checkpointRef, signed: null, presented: null, kind: "unreadable" });
443
459
  continue;
444
460
  }
445
- const presented = events.filter((e) => inclusionKeyOf(e?.inclusion) === key).length;
461
+ // Compared case-folded. The verifier recomputes `checkpoint_ref` and
462
+ // accepts it in any case (evidence-bundle.md §5.1 names lowercase hex as
463
+ // the form, and the kernel lowercases before comparing), so a pack whose
464
+ // checkpoint spells its ref in upper case still verifies. Grading it
465
+ // case-sensitively reported every record as omitted: a completeness
466
+ // accusation manufactured by letter case. The ref shown is the pack's own.
467
+ const want = checkpointMatchKey(cp);
468
+ const presented = events.filter((e) => inclusionMatchKey(e?.inclusion) === want).length;
446
469
  if (presented < signed) notes.push({ checkpointRef, signed, presented, kind: "omitted" });
447
470
  else if (presented > signed) notes.push({ checkpointRef, signed, presented, kind: "surplus" });
448
471
  }
@@ -604,7 +627,7 @@ export function renderCause(cause: PackGradeCause, subject = "this pack"): strin
604
627
  return `checkpoint ${cause.checkpointRef} has no readable signed leaf_count — completeness is UNKNOWN`;
605
628
  case "no-disclosure":
606
629
  return (
607
- `no record bytes were disclosed — content binding is UNKNOWN for all ${cause.count} ` +
630
+ `no event bytes were disclosed — content binding is UNKNOWN for all ${cause.count} ` +
608
631
  `record(s), not verified`
609
632
  );
610
633
  case "missing-disclosure":
package/src/index.ts CHANGED
@@ -46,9 +46,9 @@ export {
46
46
  export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions } from "./types.js";
47
47
 
48
48
  // Evidence-bundle verification (specs/evidence-bundle.md): the signed
49
- // sanning.evidence/v1 wrapper carrying the sanning.anchor.trace/v1 body. The CLI
49
+ // sanning.evidence/v1 wrapper carrying the sanning.anchor.trace/v3 body. The CLI
50
50
  // (`npx @sanning/proof verify`) is built on this. The same path dispatches on the
51
- // issuer-composed sanning.evidence.export/v1 body (specs/evidence-export.md) to
51
+ // issuer-composed sanning.evidence.export/v2 body (specs/evidence-export.md) to
52
52
  // verifyExportBody (embedded RSA-PSS operator attestations + a recomputed §4
53
53
  // verdict object with per-gateway on-chain outcomes).
54
54
  //
@@ -61,8 +61,6 @@ export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions
61
61
  // content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
62
62
  export {
63
63
  ANCHOR_TRACE_BODY_TYPE,
64
- ANCHOR_TRACE_BODY_TYPE_V1,
65
- ANCHOR_TRACE_BODY_TYPES,
66
64
  EXPORT_BODY_TYPE,
67
65
  STAMP_BODY_VERSION,
68
66
  STAMP_TRACE_BODY_TYPE,
@@ -103,7 +101,6 @@ export type {
103
101
  StampEvidenceEvent,
104
102
  StampEvidenceInclusion,
105
103
  TraceCheckpoint,
106
- TraceCheckpointV1,
107
104
  TraceEvent,
108
105
  TraceInclusion,
109
106
  VerdictAttestation,
@@ -148,7 +145,7 @@ export type {
148
145
  PackVerdict,
149
146
  } from "./grading.js";
150
147
 
151
- // Chain reading (src/chain.ts): following the pointer every sanning.events/v1
148
+ // Chain reading (src/chain.ts): following the pointer every sanning.events/v2
152
149
  // record carries to the record before it, over the records ONE bundle presents.
153
150
  //
154
151
  // Every anchored event has carried that pointer since the profile was ratified
@@ -164,7 +161,7 @@ export type {
164
161
  //
165
162
  // It returns FINDINGS, never a verdict, and it feeds no status. Chain links are
166
163
  // deletion/reorder-evidence for the events present and NOT a completeness proof
167
- // (`anchor/docs/profile-sanning.events-v1.md` §5) — drop the last N events of a
164
+ // (`anchor/docs/profile-sanning.events-v2.md` §5), drop the last N events of a
168
165
  // chain and nothing dangles. `CHAIN_READING_SCOPE` is that sentence, exported so
169
166
  // every surface prints the same limit rather than paraphrasing it into a claim.
170
167
  // Additive — no existing export changes.
@@ -183,7 +180,7 @@ export type {
183
180
  } from "./chain.js";
184
181
 
185
182
  // The `proof export` composer (specs/evidence-export.md §5, phase 2): produce a
186
- // signed, offline-verifiable sanning.evidence.export/v1 artifact from a source
183
+ // signed, offline-verifiable sanning.evidence.export/v2 artifact from a source
187
184
  // anchor-trace bundle + operator attestations + an exporter Ed25519 key. The
188
185
  // inverse of verifyEvidenceBundle's verifyExportBody dispatch.
189
186
  export { attestationSignatureToHex, composeExport } from "./compose.js";
package/src/verifier.ts CHANGED
@@ -17,7 +17,7 @@ import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from ".
17
17
  // major ("sanning.agent/v1.<minor>") are additive and tolerated — matching the Go
18
18
  // reference kernel's semantics (pkg/proof isSupportedSpec) so the kernels do
19
19
  // not diverge on minors. Accepting a new profile is a deliberate one-entry
20
- // addition HERE and only here. sanning.events/v1 (external commitment + Minimal
20
+ // addition HERE and only here. sanning.events/v2 (external commitment + Minimal
21
21
  // disclosure; envelope-spec §4) is verified through the SAME primitives — the
22
22
  // signed scope is still envelope-minus-signature-minus-co_signatures, with no
23
23
  // profile-specific dialect (no underscore strip, that is mlflow-only).
@@ -39,9 +39,13 @@ import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from ".
39
39
  // always strict mode) and the `readonly string[]` type rejects it at compile
40
40
  // time. The elements are strings, so a shallow freeze is a deep one. Pinned by
41
41
  // test/accept-set.test.ts "the exported registry cannot be widened at runtime".
42
+ //
43
+ // `sanning.events/v2` replaced `sanning.events/v1` in the producer-to-agent cut
44
+ // (envelope-spec A10, governance.md §8 W-3). The old major is rejected like any
45
+ // other unknown one: DL-73 keeps no reader for it.
42
46
  export const ACCEPTED_SPEC_MAJORS: readonly string[] = Object.freeze([
43
47
  "sanning.agent/v1",
44
- "sanning.events/v1",
48
+ "sanning.events/v2",
45
49
  ]);
46
50
 
47
51
  // Maximum JSON container-nesting depth a verifier will canonicalize. Inputs
@@ -488,7 +492,7 @@ export async function verifyEnvelope(
488
492
  const committed = contentHashes(env);
489
493
  if (committed.length === 0) {
490
494
  // UNDETERMINED, not failed. An external-commitment envelope
491
- // (`sanning.events/v1`) has no inline payload, so it commits to no
495
+ // (`sanning.events/v2`) has no inline payload, so it commits to no
492
496
  // envelope-readable content hash and there is nothing here to compare
493
497
  // against. Reporting that as a failure would render "we cannot check
494
498
  // this" as "it did not match" — what P-10 and I-5 exist to prevent — and
package/vectors/README.md CHANGED
@@ -12,7 +12,7 @@ correctly.
12
12
 
13
13
  The rule that decides what re-fetching a **stamp's witness** from a gateway does
14
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
15
+ `sanning.stamp.evidence/v2` (`evidence-bundle.md` §5.2.4 / §5.3.4). Implemented
16
16
  by [`../src/evidence.ts`](../src/evidence.ts) and asserted against this table by
17
17
  [`../test/stamp-on-chain-vectors.test.ts`](../test/stamp-on-chain-vectors.test.ts)
18
18
  — **and by the Python kernel against the same bytes**, in
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "rule": "chain-reading",
3
3
  "ruleVersion": 1,
4
- "statement": "What following an event chain DOES and DOES NOT show. Every sanning.events/v1 record points at the record before it; reading those pointers over the records a bundle presents yields deletion- and reorder-EVIDENCE for those records, and nothing else. It is not a completeness proof and no finding here is a verdict: a chain whose last events were never exported dangles nowhere, and a record that is withheld or unreadable takes no part in a chain rather than becoming an accusation. Findings are the specific links that do not resolve inside the bundle in front of the reader: chain-link-dangling, chain-gap, chain-second-root.",
4
+ "statement": "What following an event chain DOES and DOES NOT show. Every sanning.events/v2 record points at the record before it; reading those pointers over the records a bundle presents yields deletion- and reorder-EVIDENCE for those records, and nothing else. It is not a completeness proof and no finding here is a verdict: a chain whose last events were never exported dangles nowhere, and a record that is withheld or unreadable takes no part in a chain rather than becoming an accusation. Findings are the specific links that do not resolve inside the bundle in front of the reader: chain-link-dangling, chain-gap, chain-second-root.",
5
5
  "home": "@sanning/proof — ts/vectors/chain-reading.json. This file is the RULE, not a test fixture of one implementation. The kernel implements it in ts/src/chain.ts and is held to it by ts/test/chain.test.ts.",
6
6
  "consumers": "A surface that follows event chains SHOULD call the kernel's readChains rather than derive the rule again. A surface that cannot yet (an older pinned kernel, another language) vendors this file byte-for-byte, asserts its own derivation against it, and runs a drift check that compares its copy against the copy shipped in the @sanning/proof tarball at vectors/chain-reading.json — and that fails when it cannot find a copy to compare against. A vendored copy that is not byte-identical to this one is a defect, not a variant.",
7
- "howToUse": "Each case is a chain as it was WRITTEN, in order. Build one event record per entry from `recordScaffold`, overriding `previous_hash`, `context` and `metadata` from the entry and setting `event.content_length` to the entry's index so no two records share bytes. Canonicalize each record (RFC 8785), hex-encode the bytes as `record_bytes`, and place it in a bundle body as `{ envelope: { event_id, public_key }, record_bytes }` — in array order, SKIPPING every entry marked `dropped`. A dropped entry is still built and still hashed, because that is what deletion is: the record existed, its successor still points at it, and the pointer now leads nowhere. Then call readChains and assert `expect`.",
7
+ "howToUse": "Each case is a chain as it was WRITTEN, in order. Build one event record per entry from `recordScaffold`, overriding `previous_hash`, `context` and `metadata` from the entry and setting `event.content_length` to the entry's index so no two records share bytes. Canonicalize each record (RFC 8785), hex-encode the bytes as `event_bytes`, and place it in a bundle body as `{ envelope: { event_id, public_key }, event_bytes }`, in array order, SKIPPING every entry marked `dropped`. A dropped entry is still built and still hashed, because that is what deletion is: the record existed, its successor still points at it, and the pointer now leads nowhere. Then call readChains and assert `expect`.",
8
8
  "placeholders": {
9
9
  "@<id>": "The LINK IDENTITY of the entry with that id — for a record chain the SHA-256 of its canonical record bytes, for an adapter chain its event_id. Resolvable for dropped entries too; that is the point of them.",
10
10
  "GENESIS": "In previous_hash: this record claims to begin its chain.",
@@ -14,9 +14,9 @@
14
14
  "adapterNamespaces": "Verified against sanning-io/anchor @ main, 2026-09-07: `run` (LangChain callbacks, TS and Python — anchor#68 promoted this out of the old `langchain` namespace), `vercel_ai` (Vercel AI SDK middleware), `interchange` (Interchange adapter). All three carry `prev_event_id` and `seq`; their chain key is `root_run_id`, `chain_key` and `session_id` respectively. These are read ONLY where the record carries no chain of its own, and they are transitional: once every adapter sets the record's own previous_hash, a reader needs no adapter knowledge at all.",
15
15
  "recordScaffold": {
16
16
  "payload_version": 1,
17
- "spec_version": "sanning.events/v1",
17
+ "spec_version": "sanning.events/v2",
18
18
  "event_type": "langchain.tool_start",
19
- "subject": { "type": "producer", "producer_id": "vector" },
19
+ "subject": { "type": "agent", "agent_id": "vector" },
20
20
  "event": {
21
21
  "content_hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
22
22
  "content_length": 0
@@ -4,7 +4,7 @@
4
4
  "statement": "What re-fetching a stamp's witness from a gateway DOES and DOES NOT settle. A stamp body carries no producer signature and no inclusion proof of its own, so the witness is the entire independent part of it (evidence-bundle.md §5.2.4): confirming that a gateway holds the exact bytes named by anchor_ref is the only check that turns Sanning's own assertion into evidence. It is an ONLINE act and always optional — an offline verdict is unchanged by it and never claims it happened. Four states, never three: not-checked (no gateway was asked, or the stamp names no witness), confirm (a gateway returned the witnessed bytes), mismatch (a gateway returned DIFFERENT bytes — the only outcome that fails anything), unreachable (we asked and nobody answered — an availability gap, never an accusation). The expected bytes are stamps[].stamp_bytes VERBATIM, because §5.2.2 defines that field as the exact bytes handed to the witness; re-canonicalizing them would compare the chain against bytes nobody signed.",
5
5
  "home": "@sanning/proof — ts/vectors/stamp-on-chain.json. This file is the RULE, not a test fixture of one implementation. The TypeScript kernel implements it in ts/src/evidence.ts (refetchStamps / refetchStamp / probeGateway / rollupOnChain) and is held to it by ts/test/stamp-on-chain-vectors.test.ts; the Python kernel implements it in src/sanning_proof/evidence.py (_refetch_stamps / _refetch_stamp / _probe_gateway / _rollup_on_chain) and is held to the SAME file by tests/test_stamp_on_chain_vectors.py. Neither kernel can pass by agreeing with the other.",
6
6
  "consumers": "A surface that re-fetches a stamp witness SHOULD call the kernel's verifyEvidenceBundle with `gateways` rather than derive the rule again. A surface that cannot yet (an older pinned kernel, another language) vendors this file byte-for-byte, asserts its own derivation against it, and runs a drift check against the copy shipped in the @sanning/proof tarball at vectors/stamp-on-chain.json — failing when it cannot find a copy to compare against. A vendored copy that is not byte-identical to this one is a defect, not a variant. In particular: a surface MUST NOT collapse the four states into a boolean. A reader that cannot distinguish not-checked from unreachable will report that no gateway could be reached when none was tried, which is a false accusation manufactured by a missing state, and is the defect this table was written after.",
7
- "howToUse": "Each case describes one sanning.stamp.evidence/v1 pack and one set of scripted gateway responses. Build the pack from `packScaffold`: for each entry of `stamps` (index i, 0-based) sign ONE sanning.events/v1 envelope over the record {\"event\":{\"content_hash\":\"<64×a>\"},\"i\":i} with event_id `ev-<i>` and the fixed seed, take its leaf under RFC 9162 (SHA-256(0x00 ‖ JCS(envelope))), make a one-leaf tree, and build the stamp body from `stampBodyScaffold` with merkle_root = that root, leaf_count = 1 and one sub-tree covering [0,1). stamp_bytes is JCS(stamp body) hex-encoded; stamp_ref is SHA-256 of the DECODED bytes. When the entry's `witnessed` is true attach anchor_ref = {kind: \"arweave\", id: `<anchorIdPrefix><i>`}, otherwise omit anchor_ref entirely. Give each stamp one event whose inclusion is {stamp_ref, leaf_index: 0, leaf_count: 1, leaf_hash, audit_path: []}. Wrap the body per `wrapperScaffold`, set body_hash = SHA-256(JCS(body)) and sign the wrapper (minus `signature`) with the same seed. Then call the kernel with the case's `gateways` and a fetch seam that answers `{gateway}/{anchor id}` per the `returns` entry for that stamp at that gateway's index, and assert `expect`. A case with an empty `gateways` list is called with no gateway list at all — that is the not-checked state and it must be reachable without a fetch seam.",
7
+ "howToUse": "Each case describes one sanning.stamp.evidence/v2 pack and one set of scripted gateway responses. Build the pack from `packScaffold`: for each entry of `stamps` (index i, 0-based) sign ONE sanning.events/v2 envelope over the record {\"event\":{\"content_hash\":\"<64×a>\"},\"i\":i} with event_id `ev-<i>` and the fixed seed, take its leaf under RFC 9162 (SHA-256(0x00 ‖ JCS(envelope))), make a one-leaf tree, and build the stamp body from `stampBodyScaffold` with merkle_root = that root, leaf_count = 1 and one sub-tree covering [0,1). stamp_bytes is JCS(stamp body) hex-encoded; stamp_ref is SHA-256 of the DECODED bytes. When the entry's `witnessed` is true attach anchor_ref = {kind: \"arweave\", id: `<anchorIdPrefix><i>`}, otherwise omit anchor_ref entirely. Give each stamp one event whose inclusion is {stamp_ref, leaf_index: 0, leaf_count: 1, leaf_hash, audit_path: []}. Wrap the body per `wrapperScaffold`, set body_hash = SHA-256(JCS(body)) and sign the wrapper (minus `signature`) with the same seed. Then call the kernel with the case's `gateways` and a fetch seam that answers `{gateway}/{anchor id}` per the `returns` entry for that stamp at that gateway's index, and assert `expect`. A case with an empty `gateways` list is called with no gateway list at all, that is the not-checked state and it must be reachable without a fetch seam.",
8
8
  "responseKinds": {
9
9
  "witnessed": "The exact decoded stamp_bytes. The honest chain.",
10
10
  "altered": "JCS of the same stamp body with interval_end changed to 2026-09-09T13:00:00.000Z — a well-formed, canonical, semantically different stamp. This is the forgery shape that matters: a pack built on it is fully self-consistent OFFLINE and lies only about what was witnessed.",
@@ -13,7 +13,7 @@
13
13
  "unreachable": "The fetch fails: a network error, a timeout, a non-ok response, or no such transaction. Never a mismatch."
14
14
  },
15
15
  "alsoAppliesTo": "sanning.stamp.trace/v1 — the same case, with body_type changed and events[] dropped. Every case's `expect` holds UNCHANGED for that body, and a grader MUST run all of them twice. §5.2 and §5.3 carry the same stamps[] array under the same §5.2.2 definition, so they must give the same on-chain answer: a stamp that can be re-fetched inside one wrapper and not the other forces every consumer to know which wrapper it is holding before it can read `onChain: null`, and that is the kind of per-body exception a reader eventually gets wrong. The one case-level difference is outside this rule: a §5.2 body with no stamps at all is `partial` for carrying nothing, which no case here exercises.",
16
- "anchorIdPrefix": "stampOnChainFixtureAnchorId0000000000-",
16
+ "anchorIdPrefix": "stampOnChainFixtureAnchorId00000000000000-",
17
17
  "packScaffold": {
18
18
  "seedHex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
19
19
  "eventIdPrefix": "ev-",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "wrapperScaffold": {
32
32
  "spec_version": "sanning.evidence/v1",
33
- "body_type": "sanning.stamp.evidence/v1",
33
+ "body_type": "sanning.stamp.evidence/v2",
34
34
  "issuer": { "kind": "sanning" },
35
35
  "generated_at": "2026-09-09T12:20:00.000Z",
36
36
  "gateway": null,