@sanning/proof 0.6.0 → 0.8.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/index.ts CHANGED
@@ -64,6 +64,9 @@ export {
64
64
  ANCHOR_TRACE_BODY_TYPE_V1,
65
65
  ANCHOR_TRACE_BODY_TYPES,
66
66
  EXPORT_BODY_TYPE,
67
+ STAMP_BODY_VERSION,
68
+ STAMP_TRACE_BODY_TYPE,
69
+ STAMP_EVIDENCE_BODY_TYPE,
67
70
  disclosedContent,
68
71
  recomputeExportVerdict,
69
72
  verifyEvidenceBundle,
@@ -90,6 +93,15 @@ export type {
90
93
  OnChainResult,
91
94
  PerGatewayOutcome,
92
95
  AnchorRef,
96
+ StampBody,
97
+ StampEntry,
98
+ StampResult,
99
+ StampSubtree,
100
+ StampSubtreeResult,
101
+ StampTraceBody,
102
+ StampEvidenceBody,
103
+ StampEvidenceEvent,
104
+ StampEvidenceInclusion,
93
105
  TraceCheckpoint,
94
106
  TraceCheckpointV1,
95
107
  TraceEvent,
@@ -136,6 +148,40 @@ export type {
136
148
  PackVerdict,
137
149
  } from "./grading.js";
138
150
 
151
+ // Chain reading (src/chain.ts): following the pointer every sanning.events/v1
152
+ // record carries to the record before it, over the records ONE bundle presents.
153
+ //
154
+ // Every anchored event has carried that pointer since the profile was ratified
155
+ // and nothing we shipped read it, so a bundle with an event removed — and a
156
+ // chain that restarted after a crash and now asserts two beginnings — passed
157
+ // every check we had (proof#43). The verifier was right not to do this: it
158
+ // verifies a single envelope and says so. Nothing above it did it either.
159
+ //
160
+ // Pure — data in, data out, no I/O — so the kernel's scope boundary
161
+ // (specs/architecture.md §3) is unchanged, and the rule ships as
162
+ // `vectors/chain-reading.json` beside the pack-grading table for the same
163
+ // reason: a rule consumers must agree with needs a statement they can fetch.
164
+ //
165
+ // It returns FINDINGS, never a verdict, and it feeds no status. Chain links are
166
+ // 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
168
+ // chain and nothing dangles. `CHAIN_READING_SCOPE` is that sentence, exported so
169
+ // every surface prints the same limit rather than paraphrasing it into a claim.
170
+ // Additive — no existing export changes.
171
+ export {
172
+ ADAPTER_CHAIN_NAMESPACES,
173
+ CHAIN_READING_SCOPE,
174
+ readChains,
175
+ renderChainFinding,
176
+ } from "./chain.js";
177
+ export type {
178
+ ChainFinding,
179
+ ChainFindingKind,
180
+ ChainReading,
181
+ ChainSource,
182
+ ChainSummary,
183
+ } from "./chain.js";
184
+
139
185
  // The `proof export` composer (specs/evidence-export.md §5, phase 2): produce a
140
186
  // signed, offline-verifiable sanning.evidence.export/v1 artifact from a source
141
187
  // anchor-trace bundle + operator attestations + an exporter Ed25519 key. The
package/src/verifier.ts CHANGED
@@ -228,6 +228,20 @@ export async function verifyEnvelope(
228
228
  typeof optionsOrContentHash === "string"
229
229
  ? { expectedContentHash: optionsOrContentHash }
230
230
  : (optionsOrContentHash ?? {});
231
+ // REFUSE an unknown option key rather than ignore it. `expected_content_hash`
232
+ // — the Python kernel's spelling — previously fell through as `undefined`, so
233
+ // the content bind silently never ran and the caller got a clean pass with no
234
+ // errors at all. A check that quietly does not happen is worse than one that
235
+ // fails: the caller believes they verified an artifact they never checked.
236
+ const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash"]);
237
+ const unknownOptions = Object.keys(opts).filter((k) => !KNOWN_VERIFY_OPTIONS.has(k));
238
+ if (unknownOptions.length > 0) {
239
+ throw new TypeError(
240
+ `verifyEnvelope: unknown option(s) ${unknownOptions.join(", ")}. ` +
241
+ `Did you mean ${[...KNOWN_VERIFY_OPTIONS].join(" / ")}? ` +
242
+ "Option keys are camelCase in the TypeScript kernel; the Python kernel uses snake_case.",
243
+ );
244
+ }
231
245
  const expectedContentHash = opts.expectedContentHash;
232
246
  const errors: string[] = [];
233
247
 
@@ -313,19 +327,47 @@ export async function verifyEnvelope(
313
327
  let contentHashOk: boolean | null = null;
314
328
  let contentRole: ContentRole | null = null;
315
329
  if (expectedContentHash !== undefined) {
316
- const want = expectedContentHash.toLowerCase();
317
- const match = contentHashes(env).find((c) => c.hash.toLowerCase() === want);
318
- contentHashOk = match !== undefined;
319
- contentRole = match ? match.role : null;
320
- if (!contentHashOk) {
321
- errors.push("provided content hash does not match any hash this envelope commits to");
330
+ const committed = contentHashes(env);
331
+ if (committed.length === 0) {
332
+ // UNDETERMINED, not failed. An external-commitment envelope
333
+ // (`sanning.events/v1`) has no inline payload, so it commits to no
334
+ // envelope-readable content hash and there is nothing here to compare
335
+ // against. Reporting that as a failure would render "we cannot check
336
+ // this" as "it did not match" — what P-10 and I-5 exist to prevent — and
337
+ // would fail every envelope on the DL-52 path.
338
+ //
339
+ // The caller is still told the check did not happen; silence would be
340
+ // worse than either verdict.
341
+ contentHashOk = null;
342
+ errors.push(
343
+ "this envelope commits to no readable content hash (external-commitment " +
344
+ "profile), so the provided content hash could not be checked — " +
345
+ "undetermined, not failed",
346
+ );
347
+ } else {
348
+ const want = expectedContentHash.toLowerCase();
349
+ const match = committed.find((c) => c.hash.toLowerCase() === want);
350
+ contentHashOk = match !== undefined;
351
+ contentRole = match ? match.role : null;
352
+ if (!contentHashOk) {
353
+ errors.push("provided content hash does not match any hash this envelope commits to");
354
+ }
322
355
  }
323
356
  }
324
357
 
325
358
  return {
326
359
  // null (binding not checkable) does not fail the verdict — Python-kernel
327
360
  // parity: ok = spec && sig && payloadHashOk is not False.
328
- ok: specVersionOk && signatureOk && payloadHashOk !== false,
361
+ // A supplied `expectedContentHash` that matches nothing this envelope
362
+ // commits to is a FAILING verdict, not a side note (proof#42). This is the
363
+ // check an external auditor runs — "does this file match what was
364
+ // anchored" — and reporting it only in `contentHashOk` meant the top-level
365
+ // verdict, the field a caller naturally reads, said pass over a file that
366
+ // does not match.
367
+ //
368
+ // Absence stays undetermined: `contentHashOk` is null when no hash was
369
+ // supplied, and null must not fail (P-10 — absence is not failure).
370
+ ok: specVersionOk && signatureOk && payloadHashOk !== false && contentHashOk !== false,
329
371
  specVersionOk,
330
372
  payloadHashOk,
331
373
  signatureOk,
@@ -0,0 +1,293 @@
1
+ {
2
+ "rule": "chain-reading",
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.",
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
+ "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`.",
8
+ "placeholders": {
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
+ "GENESIS": "In previous_hash: this record claims to begin its chain.",
11
+ "null": "In prev_event_id: this record claims to begin its adapter chain.",
12
+ "publicKey": "An alias, not a real Ed25519 key. The reader treats it as an opaque grouping token — a chain is one head per (public_key, chain_key), profile section 3.2 — so only its distinctness is load-bearing."
13
+ },
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
+ "recordScaffold": {
16
+ "payload_version": 1,
17
+ "spec_version": "sanning.events/v1",
18
+ "event_type": "langchain.tool_start",
19
+ "subject": { "type": "producer", "producer_id": "vector" },
20
+ "event": {
21
+ "content_hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
22
+ "content_length": 0
23
+ },
24
+ "extras": {}
25
+ },
26
+ "cases": [
27
+ {
28
+ "id": "intact-record-chain",
29
+ "why": "the baseline that must stay silent — four linked records, nothing to report. Asserted with the chain summary, not with an empty findings list alone: a reader that matched no records at all would also report no findings, and would pass a bare emptiness check forever.",
30
+ "events": [
31
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": { "chain_key": "agent-run-1" } },
32
+ { "id": "e2", "publicKey": "kA", "previous_hash": "@e1", "context": { "chain_key": "agent-run-1" } },
33
+ { "id": "e3", "publicKey": "kA", "previous_hash": "@e2", "context": { "chain_key": "agent-run-1" } },
34
+ { "id": "e4", "publicKey": "kA", "previous_hash": "@e3", "context": { "chain_key": "agent-run-1" } }
35
+ ],
36
+ "expect": {
37
+ "chains": [
38
+ { "chainKey": "agent-run-1", "source": "record", "publicKey": "kA", "events": 4 }
39
+ ],
40
+ "findings": []
41
+ }
42
+ },
43
+ {
44
+ "id": "record-chain-one-event-dropped",
45
+ "why": "the failure the whole reader exists for. Remove one record and every SURVIVOR still verifies perfectly — signature, payload binding, inclusion proof, all green. Only the pointer shows it: e4 points at a record nobody presents.",
46
+ "events": [
47
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": { "chain_key": "agent-run-1" } },
48
+ { "id": "e2", "publicKey": "kA", "previous_hash": "@e1", "context": { "chain_key": "agent-run-1" } },
49
+ { "id": "e3", "publicKey": "kA", "previous_hash": "@e2", "context": { "chain_key": "agent-run-1" }, "dropped": true },
50
+ { "id": "e4", "publicKey": "kA", "previous_hash": "@e3", "context": { "chain_key": "agent-run-1" } }
51
+ ],
52
+ "expect": {
53
+ "chains": [
54
+ { "chainKey": "agent-run-1", "source": "record", "publicKey": "kA", "events": 3 }
55
+ ],
56
+ "findings": [
57
+ {
58
+ "kind": "chain-link-dangling",
59
+ "chainKey": "agent-run-1",
60
+ "source": "record",
61
+ "publicKey": "kA",
62
+ "count": 1,
63
+ "of": 3,
64
+ "eventIds": ["e4"],
65
+ "unresolvedPointer": "@e3"
66
+ }
67
+ ]
68
+ }
69
+ },
70
+ {
71
+ "id": "record-chain-restarted",
72
+ "why": "a process crashed, the SDK lost its chain head, and it began again under the same chain key. A link-walk alone calls this CLEAN — both halves walk back to a legitimate GENESIS and nothing dangles — so it is caught only by counting beginnings. anchor's own envelope-anchorer documents the case and measures it: 2 GENESIS roots under one chain_key.",
73
+ "events": [
74
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": { "chain_key": "agent-run-1" } },
75
+ { "id": "e2", "publicKey": "kA", "previous_hash": "@e1", "context": { "chain_key": "agent-run-1" } },
76
+ { "id": "e3", "publicKey": "kA", "previous_hash": "GENESIS", "context": { "chain_key": "agent-run-1" } },
77
+ { "id": "e4", "publicKey": "kA", "previous_hash": "@e3", "context": { "chain_key": "agent-run-1" } }
78
+ ],
79
+ "expect": {
80
+ "chains": [
81
+ { "chainKey": "agent-run-1", "source": "record", "publicKey": "kA", "events": 4 }
82
+ ],
83
+ "findings": [
84
+ {
85
+ "kind": "chain-second-root",
86
+ "chainKey": "agent-run-1",
87
+ "source": "record",
88
+ "publicKey": "kA",
89
+ "count": 2,
90
+ "of": 4,
91
+ "eventIds": ["e1", "e3"]
92
+ }
93
+ ]
94
+ }
95
+ },
96
+ {
97
+ "id": "record-chain-window-is-not-a-finding",
98
+ "why": "an export is a WINDOW onto a chain that began before it. Its earliest record necessarily points at a predecessor nobody handed us, and reporting that would put a finding on every second export until people stopped reading them. Exactly one unresolved pointer is expected where the bundle shows no GENESIS.",
99
+ "events": [
100
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": { "chain_key": "agent-run-1" }, "dropped": true },
101
+ { "id": "e2", "publicKey": "kA", "previous_hash": "@e1", "context": { "chain_key": "agent-run-1" }, "dropped": true },
102
+ { "id": "e3", "publicKey": "kA", "previous_hash": "@e2", "context": { "chain_key": "agent-run-1" } },
103
+ { "id": "e4", "publicKey": "kA", "previous_hash": "@e3", "context": { "chain_key": "agent-run-1" } },
104
+ { "id": "e5", "publicKey": "kA", "previous_hash": "@e4", "context": { "chain_key": "agent-run-1" } }
105
+ ],
106
+ "expect": {
107
+ "chains": [
108
+ { "chainKey": "agent-run-1", "source": "record", "publicKey": "kA", "events": 3 }
109
+ ],
110
+ "findings": []
111
+ }
112
+ },
113
+ {
114
+ "id": "record-chain-window-with-a-drop",
115
+ "why": "the window allowance is spent once and only once. Three records from mid-chain, one of them removed: the earliest unresolved pointer is the window edge, the second is a deletion, and only the second is reported.",
116
+ "events": [
117
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": { "chain_key": "agent-run-1" }, "dropped": true },
118
+ { "id": "e2", "publicKey": "kA", "previous_hash": "@e1", "context": { "chain_key": "agent-run-1" }, "dropped": true },
119
+ { "id": "e3", "publicKey": "kA", "previous_hash": "@e2", "context": { "chain_key": "agent-run-1" } },
120
+ { "id": "e4", "publicKey": "kA", "previous_hash": "@e3", "context": { "chain_key": "agent-run-1" }, "dropped": true },
121
+ { "id": "e5", "publicKey": "kA", "previous_hash": "@e4", "context": { "chain_key": "agent-run-1" } }
122
+ ],
123
+ "expect": {
124
+ "chains": [
125
+ { "chainKey": "agent-run-1", "source": "record", "publicKey": "kA", "events": 2 }
126
+ ],
127
+ "findings": [
128
+ {
129
+ "kind": "chain-link-dangling",
130
+ "chainKey": "agent-run-1",
131
+ "source": "record",
132
+ "publicKey": "kA",
133
+ "count": 1,
134
+ "of": 2,
135
+ "eventIds": ["e5"],
136
+ "unresolvedPointer": "@e4"
137
+ }
138
+ ]
139
+ }
140
+ },
141
+ {
142
+ "id": "unchained-singletons-are-not-a-chain",
143
+ "why": "an anchor call with no chain is a singleton and its record says GENESIS (profile section 5). Every singleton a producer ever wrote says GENESIS, so a reader that grouped on the pointer alone would assemble one enormous fictional chain and report a second root for every pair of unrelated events. context.chain_key is present IFF the record is chained, and that is what gates grouping.",
144
+ "events": [
145
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": {} },
146
+ { "id": "e2", "publicKey": "kA", "previous_hash": "GENESIS", "context": {} },
147
+ { "id": "e3", "publicKey": "kA", "previous_hash": "GENESIS", "context": {} }
148
+ ],
149
+ "expect": { "chains": [], "findings": [] }
150
+ },
151
+ {
152
+ "id": "same-chain-name-two-producers",
153
+ "why": "a chain is one head per (public_key, chain_key). Two producers that both call their chain session-1 are two chains, and merging them would manufacture a second root out of a naming coincidence.",
154
+ "events": [
155
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": { "chain_key": "session-1" } },
156
+ { "id": "e2", "publicKey": "kB", "previous_hash": "GENESIS", "context": { "chain_key": "session-1" } }
157
+ ],
158
+ "expect": {
159
+ "chains": [
160
+ { "chainKey": "session-1", "source": "record", "publicKey": "kA", "events": 1 },
161
+ { "chainKey": "session-1", "source": "record", "publicKey": "kB", "events": 1 }
162
+ ],
163
+ "findings": []
164
+ }
165
+ },
166
+ {
167
+ "id": "intact-adapter-chain-run",
168
+ "why": "the batched LangChain path writes its linkage only in metadata; its records all say GENESIS and carry no chain_key. Three of them must read as ONE chain with nothing to report — not as three roots of a fictional one. The namespace is `run`, not `langchain`: anchor#68 moved it, and a reader still pinned on the old name would find no chain here and report nothing, which is indistinguishable from a clean bundle.",
169
+ "events": [
170
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "run": { "run_id": "r1", "parent_run_id": null, "root_run_id": "root-1", "seq": 0, "prev_event_id": null } } },
171
+ { "id": "e2", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "run": { "run_id": "r2", "parent_run_id": "r1", "root_run_id": "root-1", "seq": 1, "prev_event_id": "@e1" } } },
172
+ { "id": "e3", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "run": { "run_id": "r3", "parent_run_id": "r1", "root_run_id": "root-1", "seq": 2, "prev_event_id": "@e2" } } }
173
+ ],
174
+ "expect": {
175
+ "chains": [
176
+ { "chainKey": "root-1", "source": "adapter", "namespace": "run", "publicKey": "kA", "events": 3 }
177
+ ],
178
+ "findings": []
179
+ }
180
+ },
181
+ {
182
+ "id": "adapter-chain-one-event-dropped-run",
183
+ "why": "a deletion on the adapter path shows twice, and both are worth saying: the pointer dangles AND the sequence has a hole. anchor's own comment is that the slot is burned BEFORE the hash, so a dropped event consumed a seq number and leaves a permanent gap.",
184
+ "events": [
185
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "run": { "run_id": "r1", "parent_run_id": null, "root_run_id": "root-1", "seq": 0, "prev_event_id": null } } },
186
+ { "id": "e2", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "run": { "run_id": "r2", "parent_run_id": "r1", "root_run_id": "root-1", "seq": 1, "prev_event_id": "@e1" } } },
187
+ { "id": "e3", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "run": { "run_id": "r3", "parent_run_id": "r1", "root_run_id": "root-1", "seq": 2, "prev_event_id": "@e2" } }, "dropped": true },
188
+ { "id": "e4", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "run": { "run_id": "r4", "parent_run_id": "r1", "root_run_id": "root-1", "seq": 3, "prev_event_id": "@e3" } } }
189
+ ],
190
+ "expect": {
191
+ "chains": [
192
+ { "chainKey": "root-1", "source": "adapter", "namespace": "run", "publicKey": "kA", "events": 3 }
193
+ ],
194
+ "findings": [
195
+ {
196
+ "kind": "chain-link-dangling",
197
+ "chainKey": "root-1",
198
+ "source": "adapter",
199
+ "namespace": "run",
200
+ "publicKey": "kA",
201
+ "count": 1,
202
+ "of": 3,
203
+ "eventIds": ["e4"],
204
+ "unresolvedPointer": "@e3"
205
+ },
206
+ {
207
+ "kind": "chain-gap",
208
+ "chainKey": "root-1",
209
+ "source": "adapter",
210
+ "namespace": "run",
211
+ "publicKey": "kA",
212
+ "count": 1,
213
+ "of": 3,
214
+ "eventIds": ["e4"],
215
+ "firstMissingSeq": 2
216
+ }
217
+ ]
218
+ }
219
+ },
220
+ {
221
+ "id": "adapter-window-starting-mid-sequence-vercel",
222
+ "why": "only INTERIOR holes are gaps. A window that starts at seq 7 is a window, and reporting its first seven absent slots as missing records would bury the one hole that means something. Pairs with the dangling window rule: the earliest record's pointer leaves the bundle and that is expected.",
223
+ "events": [
224
+ { "id": "e0", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "vercel_ai": { "chain_key": "req-9", "caller_supplied_chain": true, "seq": 6, "prev_event_id": null } }, "dropped": true },
225
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "vercel_ai": { "chain_key": "req-9", "caller_supplied_chain": true, "seq": 7, "prev_event_id": "@e0" } } },
226
+ { "id": "e2", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "vercel_ai": { "chain_key": "req-9", "caller_supplied_chain": true, "seq": 8, "prev_event_id": "@e1" } } },
227
+ { "id": "e3", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "vercel_ai": { "chain_key": "req-9", "caller_supplied_chain": true, "seq": 9, "prev_event_id": "@e2" } } }
228
+ ],
229
+ "expect": {
230
+ "chains": [
231
+ { "chainKey": "req-9", "source": "adapter", "namespace": "vercel_ai", "publicKey": "kA", "events": 3 }
232
+ ],
233
+ "findings": []
234
+ }
235
+ },
236
+ {
237
+ "id": "interchange-gap-without-a-dangling-link",
238
+ "why": "Interchange passes through its own reactor-owned seq, and a record the caller opted out of is never committed — so the adapter does not advance the pointer for it. The result is a hole in the sequence with an INTACT link chain, which is why the two causes are separate findings and not one. A reader that only walked pointers would see nothing here.",
239
+ "events": [
240
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "interchange": { "session_id": "sess-3", "call_id": "c1", "seq": 0, "tool": "search", "blocked": false, "prev_event_id": null } } },
241
+ { "id": "e2", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "interchange": { "session_id": "sess-3", "call_id": "c2", "seq": 1, "tool": "search", "blocked": false, "prev_event_id": "@e1" } } },
242
+ { "id": "e4", "publicKey": "kA", "previous_hash": "GENESIS", "context": {}, "metadata": { "interchange": { "session_id": "sess-3", "call_id": "c4", "seq": 3, "tool": "search", "blocked": false, "prev_event_id": "@e2" } } }
243
+ ],
244
+ "expect": {
245
+ "chains": [
246
+ { "chainKey": "sess-3", "source": "adapter", "namespace": "interchange", "publicKey": "kA", "events": 3 }
247
+ ],
248
+ "findings": [
249
+ {
250
+ "kind": "chain-gap",
251
+ "chainKey": "sess-3",
252
+ "source": "adapter",
253
+ "namespace": "interchange",
254
+ "publicKey": "kA",
255
+ "count": 1,
256
+ "of": 3,
257
+ "eventIds": ["e4"],
258
+ "firstMissingSeq": 2
259
+ }
260
+ ]
261
+ }
262
+ },
263
+ {
264
+ "id": "the-record-chain-wins-over-adapter-metadata",
265
+ "why": "the envelope path sets BOTH the record's own previous_hash and the adapter metadata. One record must land in one chain, and it must be the one a third party can walk from the spec alone — otherwise the same events are counted twice and every chain reads as two.",
266
+ "events": [
267
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": { "chain_key": "root-1" }, "metadata": { "run": { "run_id": "r1", "parent_run_id": null, "root_run_id": "root-1", "seq": 0, "prev_event_id": null } } },
268
+ { "id": "e2", "publicKey": "kA", "previous_hash": "@e1", "context": { "chain_key": "root-1" }, "metadata": { "run": { "run_id": "r2", "parent_run_id": "r1", "root_run_id": "root-1", "seq": 1, "prev_event_id": "@e1" } } }
269
+ ],
270
+ "expect": {
271
+ "chains": [
272
+ { "chainKey": "root-1", "source": "record", "publicKey": "kA", "events": 2 }
273
+ ],
274
+ "findings": []
275
+ }
276
+ },
277
+ {
278
+ "id": "withheld-records-take-no-part",
279
+ "why": "a bundle may present an event with its record withheld (minimal disclosure). A link we cannot read is UNKNOWN, never an accusation — verifyEvidenceBundle already reports the binding as undetermined, and a second, harsher finding for the same fact would be the false-integrity failure in reverse. The chain that IS readable is still read.",
280
+ "events": [
281
+ { "id": "e1", "publicKey": "kA", "previous_hash": "GENESIS", "context": { "chain_key": "agent-run-1" } },
282
+ { "id": "e2", "publicKey": "kA", "previous_hash": "@e1", "context": { "chain_key": "agent-run-1" }, "withheld": true },
283
+ { "id": "e3", "publicKey": "kA", "previous_hash": "@e2", "context": { "chain_key": "agent-run-1" }, "withheld": true }
284
+ ],
285
+ "expect": {
286
+ "chains": [
287
+ { "chainKey": "agent-run-1", "source": "record", "publicKey": "kA", "events": 1 }
288
+ ],
289
+ "findings": []
290
+ }
291
+ }
292
+ ]
293
+ }