@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/README.md +5 -5
- package/dist/chain.js +4 -4
- package/dist/cli.js +13 -10
- package/dist/compose.js +3 -3
- package/dist/crypto.js +1 -1
- package/dist/evidence.d.ts +29 -54
- package/dist/evidence.js +334 -141
- package/dist/grading.d.ts +1 -1
- package/dist/grading.js +22 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -6
- package/dist/verifier.js +7 -3
- package/package.json +2 -2
- package/src/chain.ts +4 -4
- package/src/cli.ts +13 -10
- package/src/compose.ts +4 -4
- package/src/crypto.ts +1 -1
- package/src/evidence.ts +409 -196
- package/src/grading.ts +28 -5
- package/src/index.ts +5 -8
- package/src/verifier.ts +7 -3
- package/vectors/README.md +1 -1
- package/vectors/chain-reading.json +4 -4
- package/vectors/stamp-on-chain.json +3 -3
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;
|
|
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: {
|
|
406
|
-
const hex = checkpoint?.
|
|
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
|
-
|
|
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
|
|
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/
|
|
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/
|
|
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/
|
|
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-
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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 `
|
|
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/
|
|
17
|
+
"spec_version": "sanning.events/v2",
|
|
18
18
|
"event_type": "langchain.tool_start",
|
|
19
|
-
"subject": { "type": "
|
|
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/
|
|
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": "
|
|
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/
|
|
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,
|