@sanning/proof 0.5.0 → 0.7.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 +31 -4
- package/dist/chain.d.ts +99 -0
- package/dist/chain.js +351 -0
- package/dist/cli.js +253 -38
- package/dist/compose.js +1 -1
- package/dist/evidence.d.ts +204 -7
- package/dist/evidence.js +621 -67
- package/dist/grading.d.ts +160 -0
- package/dist/grading.js +469 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +38 -1
- package/dist/side-content.d.ts +36 -0
- package/dist/side-content.js +98 -0
- package/dist/verifier.js +46 -7
- package/package.json +2 -1
- package/src/chain.ts +460 -0
- package/src/cli.ts +324 -35
- package/src/compose.ts +1 -1
- package/src/evidence.ts +875 -81
- package/src/grading.ts +623 -0
- package/src/index.ts +79 -0
- package/src/side-content.ts +119 -0
- package/src/verifier.ts +49 -7
- package/vectors/README.md +66 -0
- package/vectors/chain-reading.json +293 -0
- package/vectors/pack-grading.json +136 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
{
|
|
2
|
+
"rule": "pack-grading",
|
|
3
|
+
"ruleVersion": 2,
|
|
4
|
+
"statement": "What a verification surface may CLAIM about a pack it was handed. verified is reachable ONLY when every record's own bytes bound to its own commitment and nothing is missing, unreadable, surplus or omitted. Absent or unreadable evidence is incomplete (UNKNOWN), never failed and never verified. A hard kernel verdict wins: malformed > failed > incomplete > the kernel's own headline.",
|
|
5
|
+
"home": "@sanning/proof — ts/vectors/pack-grading.json. This file is the RULE, not a test fixture of one implementation. The kernel implements it in ts/src/grading.ts and is held to it by ts/test/grading.test.ts.",
|
|
6
|
+
"consumers": "A surface that grades packs SHOULD call the kernel's gradePack 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/pack-grading.json. A vendored copy that is not byte-identical to this one is a defect, not a variant.",
|
|
7
|
+
"howToUse": "Adapt a kernel verification result and a disclosure account into the shapes below, call gradePack, and assert the expectations. `kernel` gives the per-event outcome classes the kernel returns; `logs` gives the account of which records disclosed bytes; `omissions` gives the signed-leaf_count completeness notes. Nothing here depends on how the bytes were transported — a directory read and a browser drop produce the same account and must produce the same verdict.",
|
|
8
|
+
"kernelEventClasses": {
|
|
9
|
+
"okBound": "{ ok: true, contentOk: true } — the record's own bytes hashed to its own commitment",
|
|
10
|
+
"okUnknown": "{ ok: true, contentOk: null } — structurally fine, but no bytes were disclosed",
|
|
11
|
+
"mismatched": "{ ok: false, contentOk: false } — the bytes disagree with the commitment",
|
|
12
|
+
"brokenNoContent": "{ ok: false, contentOk: null } — the record itself does not verify"
|
|
13
|
+
},
|
|
14
|
+
"cases": [
|
|
15
|
+
{
|
|
16
|
+
"id": "clean-pack",
|
|
17
|
+
"why": "a real, untouched pack: every record's own bytes bound to its own commitment",
|
|
18
|
+
"kernel": { "status": "verified", "okBound": 26, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
|
|
19
|
+
"logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
|
|
20
|
+
"omissions": [],
|
|
21
|
+
"expect": { "verdict": "verified", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 0 }
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "one-log-tampered",
|
|
25
|
+
"why": "the scripted decision-word tamper — one record's bytes disagree",
|
|
26
|
+
"kernel": { "status": "failed", "okBound": 25, "okUnknown": 0, "mismatched": 1, "brokenNoContent": 0 },
|
|
27
|
+
"logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
|
|
28
|
+
"omissions": [],
|
|
29
|
+
"expect": { "verdict": "failed", "recordsVerified": 25, "recordsFailed": 1, "recordsUndetermined": 0, "causeCount": 0 }
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "two-logs-swapped",
|
|
33
|
+
"why": "the case a set-membership check passes: both digests are still in the set, but each is now bound to the wrong record",
|
|
34
|
+
"kernel": { "status": "failed", "okBound": 24, "okUnknown": 0, "mismatched": 2, "brokenNoContent": 0 },
|
|
35
|
+
"logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
|
|
36
|
+
"omissions": [],
|
|
37
|
+
"expect": { "verdict": "failed", "recordsVerified": 24, "recordsFailed": 2, "recordsUndetermined": 0, "causeCount": 0 }
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "one-log-deleted",
|
|
41
|
+
"why": "absent bytes are UNKNOWN — never verified, never failed (P-10)",
|
|
42
|
+
"kernel": { "status": "verified", "okBound": 25, "okUnknown": 1, "mismatched": 0, "brokenNoContent": 0 },
|
|
43
|
+
"logs": { "expected": 26, "present": 25, "missing": 1, "unreadable": 0, "extra": 0 },
|
|
44
|
+
"omissions": [],
|
|
45
|
+
"expect": { "verdict": "incomplete", "recordsVerified": 25, "recordsFailed": 0, "recordsUndetermined": 1, "causeCount": 1 }
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "every-log-deleted",
|
|
49
|
+
"why": "the silent-catch regression: zero bytes hashed must never read as a verified pack",
|
|
50
|
+
"kernel": { "status": "verified", "okBound": 0, "okUnknown": 26, "mismatched": 0, "brokenNoContent": 0 },
|
|
51
|
+
"logs": { "expected": 26, "present": 0, "missing": 26, "unreadable": 0, "extra": 0 },
|
|
52
|
+
"omissions": [],
|
|
53
|
+
"expect": { "verdict": "incomplete", "recordsVerified": 0, "recordsFailed": 0, "recordsUndetermined": 26, "causeCount": 1 }
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "one-log-unreadable",
|
|
57
|
+
"why": "a file we could not read is our fault, not the record's — UNKNOWN",
|
|
58
|
+
"kernel": { "status": "verified", "okBound": 25, "okUnknown": 1, "mismatched": 0, "brokenNoContent": 0 },
|
|
59
|
+
"logs": { "expected": 26, "present": 25, "missing": 0, "unreadable": 1, "extra": 0 },
|
|
60
|
+
"omissions": [],
|
|
61
|
+
"expect": { "verdict": "incomplete", "recordsVerified": 25, "recordsFailed": 0, "recordsUndetermined": 1, "causeCount": 1 }
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"id": "unaccounted-file",
|
|
65
|
+
"why": "a log no record in the bundle accounts for: the pack is not the pack it claims to be",
|
|
66
|
+
"kernel": { "status": "verified", "okBound": 26, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
|
|
67
|
+
"logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 1 },
|
|
68
|
+
"omissions": [],
|
|
69
|
+
"expect": { "verdict": "incomplete", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 1 }
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "records-omitted",
|
|
73
|
+
"why": "every surviving record verifies perfectly; only the checkpoint's signed leaf_count sees the gap",
|
|
74
|
+
"kernel": { "status": "verified", "okBound": 24, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
|
|
75
|
+
"logs": { "expected": 24, "present": 24, "missing": 0, "unreadable": 0, "extra": 0 },
|
|
76
|
+
"omissions": [{ "kind": "omitted", "checkpointRef": "TX", "signed": 26, "presented": 24 }],
|
|
77
|
+
"expect": { "verdict": "incomplete", "recordsVerified": 24, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 1 }
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "records-surplus",
|
|
81
|
+
"why": "more records than the signed tree accounts for is equally not-verified",
|
|
82
|
+
"kernel": { "status": "verified", "okBound": 26, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
|
|
83
|
+
"logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
|
|
84
|
+
"omissions": [{ "kind": "surplus", "checkpointRef": "TX", "signed": 24, "presented": 26 }],
|
|
85
|
+
"expect": { "verdict": "incomplete", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 1 }
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"id": "leaf-count-unreadable",
|
|
89
|
+
"why": "a completeness check we could not run is UNKNOWN, not a pass",
|
|
90
|
+
"kernel": { "status": "verified", "okBound": 26, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
|
|
91
|
+
"logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
|
|
92
|
+
"omissions": [{ "kind": "unreadable", "checkpointRef": "TX", "signed": null, "presented": null }],
|
|
93
|
+
"expect": { "verdict": "incomplete", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 1 }
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"id": "malformed-wins",
|
|
97
|
+
"why": "an unreadable bundle is named for what it is, whatever else is true",
|
|
98
|
+
"kernel": { "status": "malformed", "okBound": 0, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
|
|
99
|
+
"logs": { "expected": 0, "present": 0, "missing": 0, "unreadable": 0, "extra": 0 },
|
|
100
|
+
"omissions": [],
|
|
101
|
+
"expect": { "verdict": "malformed", "recordsVerified": 0, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 0 }
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"id": "tamper-beats-absence",
|
|
105
|
+
"why": "a proven mismatch is still a failure even when other records were withheld — absence never masks tamper",
|
|
106
|
+
"kernel": { "status": "failed", "okBound": 24, "okUnknown": 1, "mismatched": 1, "brokenNoContent": 0 },
|
|
107
|
+
"logs": { "expected": 26, "present": 25, "missing": 1, "unreadable": 0, "extra": 0 },
|
|
108
|
+
"omissions": [],
|
|
109
|
+
"expect": { "verdict": "failed", "recordsVerified": 24, "recordsFailed": 1, "recordsUndetermined": 1, "causeCount": 1 }
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"id": "broken-record-is-failure-not-unknown",
|
|
113
|
+
"why": "a record that does not verify is a failure even with no content disclosed for it",
|
|
114
|
+
"kernel": { "status": "failed", "okBound": 25, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 1 },
|
|
115
|
+
"logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
|
|
116
|
+
"omissions": [],
|
|
117
|
+
"expect": { "verdict": "failed", "recordsVerified": 25, "recordsFailed": 1, "recordsUndetermined": 0, "causeCount": 0 }
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"id": "kernel-headline-passes-through",
|
|
121
|
+
"why": "this rule downgrades a verdict; it never invents one",
|
|
122
|
+
"kernel": { "status": "partial", "okBound": 26, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
|
|
123
|
+
"logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
|
|
124
|
+
"omissions": [],
|
|
125
|
+
"expect": { "verdict": "partial", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 0 }
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"id": "unknown-headline-is-not-repeated",
|
|
129
|
+
"why": "a headline this rule cannot interpret is a check it could not run — UNKNOWN, never repeated verbatim. The status a surface prints must not be attacker-chosen: passing an uninterpreted string through is how a pack could be headlined with the one word the trust model forbids",
|
|
130
|
+
"kernel": { "status": "SAFE", "okBound": 26, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
|
|
131
|
+
"logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
|
|
132
|
+
"omissions": [],
|
|
133
|
+
"expect": { "verdict": "incomplete", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 0 }
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
}
|