@sanning/proof 0.19.1 → 0.20.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 +30 -29
- package/dist/agent-record.d.ts +62 -0
- package/dist/agent-record.js +527 -0
- package/dist/agent-schemas.generated.d.ts +15 -0
- package/dist/agent-schemas.generated.js +5759 -0
- package/dist/case-file.d.ts +32 -89
- package/dist/case-file.js +41 -93
- package/dist/case.d.ts +339 -822
- package/dist/case.js +667 -1160
- package/dist/chain.d.ts +19 -47
- package/dist/chain.js +78 -205
- package/dist/claims.d.ts +211 -0
- package/dist/claims.js +1081 -0
- package/dist/cli.d.ts +45 -68
- package/dist/cli.js +618 -1022
- package/dist/compose.js +23 -65
- package/dist/crypto.d.ts +7 -8
- package/dist/crypto.js +60 -147
- package/dist/declaration.d.ts +68 -194
- package/dist/declaration.js +175 -518
- package/dist/environment.d.ts +9 -30
- package/dist/environment.js +17 -66
- package/dist/evidence.d.ts +119 -160
- package/dist/evidence.js +744 -1103
- package/dist/exit-codes.d.ts +11 -27
- package/dist/exit-codes.js +24 -59
- package/dist/file-match.d.ts +33 -79
- package/dist/file-match.js +33 -69
- package/dist/grading.d.ts +98 -135
- package/dist/grading.js +224 -380
- package/dist/handoff.d.ts +62 -160
- package/dist/handoff.js +153 -393
- package/dist/index.d.ts +14 -10
- package/dist/index.js +60 -167
- package/dist/json-schema.d.ts +29 -0
- package/dist/json-schema.js +298 -0
- package/dist/merkle.js +16 -28
- package/dist/message-parts.d.ts +56 -212
- package/dist/message-parts.js +142 -502
- package/dist/side-content.d.ts +11 -22
- package/dist/side-content.js +22 -65
- package/dist/types.d.ts +17 -0
- package/dist/types.js +19 -5
- package/dist/verifier.d.ts +40 -114
- package/dist/verifier.js +196 -535
- package/dist/witness-cache.d.ts +24 -45
- package/dist/witness-cache.js +38 -81
- package/dist/witness-lookup.d.ts +36 -57
- package/dist/witness-lookup.js +89 -139
- package/dist/zip-reader.d.ts +18 -33
- package/dist/zip-reader.js +19 -34
- package/package.json +11 -3
- package/src/agent-record.ts +608 -0
- package/src/agent-schemas.generated.ts +5764 -0
- package/src/case-file.ts +45 -109
- package/src/case.ts +814 -1328
- package/src/chain.ts +85 -224
- package/src/claims.ts +1253 -0
- package/src/cli.ts +714 -1176
- package/src/compose.ts +33 -79
- package/src/crypto.ts +60 -147
- package/src/declaration.ts +219 -656
- package/src/environment.ts +17 -65
- package/src/evidence.ts +958 -1381
- package/src/exit-codes.ts +24 -63
- package/src/file-match.ts +40 -89
- package/src/grading.ts +287 -421
- package/src/handoff.ts +175 -427
- package/src/index.ts +131 -177
- package/src/json-schema.ts +295 -0
- package/src/merkle.ts +16 -28
- package/src/message-parts.ts +155 -533
- package/src/side-content.ts +25 -68
- package/src/types.ts +55 -36
- package/src/verifier.ts +202 -526
- package/src/witness-cache.ts +45 -94
- package/src/witness-lookup.ts +107 -177
- package/src/zip-reader.ts +20 -38
- package/vectors/README.md +52 -34
- package/vectors/agent-record.json +2175 -0
- package/vectors/case-file-index.json +1 -1
- package/vectors/case-split.json +2 -2
- package/vectors/chain-reading.json +3 -3
- package/vectors/file-match.json +285 -148
- package/vectors/jcs-reader.json +6 -6
- package/vectors/pack-grading.json +102 -25
- package/vectors/sandbox-evidence.json +573 -581
- package/vectors/stamp-on-chain.json +18 -18
- package/vectors/witness-cache.json +49 -49
- package/dist/agent-proof.d.ts +0 -28
- package/dist/agent-proof.js +0 -226
- package/dist/run-adapters.d.ts +0 -10
- package/dist/run-adapters.js +0 -35
- package/dist/run.d.ts +0 -209
- package/dist/run.js +0 -1405
- package/src/agent-proof.ts +0 -264
- package/src/run-adapters.ts +0 -44
- package/src/run.ts +0 -1598
package/src/chain.ts
CHANGED
|
@@ -1,70 +1,33 @@
|
|
|
1
|
-
// Chain reading
|
|
2
|
-
//
|
|
1
|
+
// Chain reading: follow the pointer each anchored event carries to the one
|
|
2
|
+
// before it, and report what does not resolve. A pure rule over the records a
|
|
3
|
+
// bundle presents, separate from envelope verification and pack grading.
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
// `sanning.events
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// it. But the consumer layer never did it either, so a bundle with an event
|
|
10
|
-
// removed, and a chain that restarted after a crash and now asserts two
|
|
11
|
-
// beginnings, both passed every shipped check (proof#43).
|
|
5
|
+
// This is not a completeness statement. Drop the last N events of a chain and
|
|
6
|
+
// nothing here dangles (`anchor/docs/profile-sanning.events-v2.md` §5);
|
|
7
|
+
// completeness is the checkpoint `leaf_count` in `grading.ts`. So nothing here
|
|
8
|
+
// returns a verdict: only findings, and a count of the chains read so a surface
|
|
9
|
+
// can tell "nothing to report" from "read nothing".
|
|
12
10
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// boundary (`specs/architecture.md` §3) is unchanged.
|
|
11
|
+
// A chain is one head per `(public_key, chain_key)` (profile §3.2): two
|
|
12
|
+
// producers that share a chain name are two chains. The linkage is read two
|
|
13
|
+
// ways:
|
|
17
14
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// Completeness is the checkpoint `leaf_count` mechanism, which lives in
|
|
26
|
-
// `grading.ts` and answers a different question.
|
|
27
|
-
//
|
|
28
|
-
// So nothing in this module returns a verdict, a status, an `ok`, or the word
|
|
29
|
-
// "complete". It returns FINDINGS — each one a specific link that does not
|
|
30
|
-
// resolve within the bundle in front of us — and a count of the chains it read,
|
|
31
|
-
// so a surface can tell "nothing to report" apart from "read nothing".
|
|
32
|
-
//
|
|
33
|
-
// ---- HOW A CHAIN IS IDENTIFIED ---------------------------------------------
|
|
34
|
-
//
|
|
35
|
-
// A chain is one head per `(public_key, chain_key)` — profile §3.2, and the
|
|
36
|
-
// reason `public_key` is part of every group key below. Two producers using the
|
|
37
|
-
// chain name "session-1" are two chains; merging them would manufacture a
|
|
38
|
-
// second root out of a naming coincidence.
|
|
39
|
-
//
|
|
40
|
-
// The linkage is written two ways today, and this reads both:
|
|
41
|
-
//
|
|
42
|
-
// 1. THE RECORD'S OWN CHAIN (`previous_hash` + `context.chain_key`). The
|
|
43
|
-
// profile's mechanism, and the one a third party can walk from the spec
|
|
44
|
-
// alone. Links resolve in PAYLOAD-HASH space.
|
|
45
|
-
// 2. THE ADAPTER'S METADATA (`metadata.<namespace>.prev_event_id` + `seq`).
|
|
46
|
-
// The batched paths write the linkage only here; their records say
|
|
47
|
-
// GENESIS. Links resolve in EVENT-ID space. Read as a FALLBACK, only when
|
|
48
|
-
// the record carries no chain of its own.
|
|
49
|
-
//
|
|
50
|
-
// (2) is a transitional accommodation, not the design. `metadata` is
|
|
51
|
-
// caller-owned and the profile says a verifier MUST NOT read it for
|
|
52
|
-
// verification — which is exactly why nothing read through it feeds a
|
|
53
|
-
// verdict, a claimed chain included (rule 11 reads linkage (1) only). The fix is
|
|
54
|
-
// for every adapter to set the record's own `previous_hash` (filed separately
|
|
55
|
-
// against `anchor`); when it lands, the namespace table below stops mattering
|
|
56
|
-
// and can go.
|
|
15
|
+
// 1. The record's own chain (`previous_hash` + `context.chain_key`), resolved
|
|
16
|
+
// in payload-hash space. The profile's mechanism.
|
|
17
|
+
// 2. Adapter metadata (`metadata.<namespace>.prev_event_id` + `seq`),
|
|
18
|
+
// resolved in event-id space, read only when the record carries no chain
|
|
19
|
+
// of its own. `metadata` is caller-owned and the profile forbids
|
|
20
|
+
// verifying from it, so nothing read through it feeds a verdict, a claimed
|
|
21
|
+
// chain included (rule 11 reads linkage 1 only).
|
|
57
22
|
|
|
58
23
|
import { hexToBytes, sha256Hex } from "./crypto.js";
|
|
59
24
|
import { inlinePayloadBytes, jcsParse } from "./verifier.js";
|
|
60
25
|
import { resolveTraceEvents } from "./evidence.js";
|
|
61
26
|
|
|
62
27
|
/**
|
|
63
|
-
* The
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* Lifted from the profile (§5) rather than reworded, because a paraphrase of a
|
|
67
|
-
* scope limit is how a scope limit turns into a claim.
|
|
28
|
+
* The sentence a surface prints beside these findings, so "no findings" is
|
|
29
|
+
* never read as "nothing is missing". Quoted from the profile (§5), because a
|
|
30
|
+
* paraphrased scope limit turns into a claim.
|
|
68
31
|
*/
|
|
69
32
|
export const CHAIN_READING_SCOPE =
|
|
70
33
|
"Chain links are deletion/reorder-evidence for the events present; they are not a completeness proof.";
|
|
@@ -74,25 +37,15 @@ export type ChainSource = "record" | "adapter";
|
|
|
74
37
|
|
|
75
38
|
/**
|
|
76
39
|
* The adapter metadata namespaces that carry a chain when the record does not.
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* for `langchain`: anchor#68 promoted the LangChain linkage out of a
|
|
80
|
-
* framework-named namespace into a shared one, so the same fact has one path in
|
|
81
|
-
* every SDK. A table pinned on the old name would find no chains, report
|
|
82
|
-
* nothing, and look exactly like a clean bundle — the rename trap, in the
|
|
83
|
-
* direction that fails silently.
|
|
84
|
-
*
|
|
85
|
-
* Exported so a test can assert the table itself, and so a consumer can see
|
|
86
|
-
* what this reader knows about rather than guessing.
|
|
40
|
+
* The names must match the `@sanning/anchor` SDKs exactly: a stale name finds no
|
|
41
|
+
* chains and reads like a clean bundle.
|
|
87
42
|
*/
|
|
88
43
|
export const ADAPTER_CHAIN_NAMESPACES: readonly { namespace: string; keyField: string }[] = [
|
|
89
|
-
//
|
|
90
|
-
// root run, so the root run id is the chain.
|
|
44
|
+
// LangChain callbacks: one chain per root run.
|
|
91
45
|
{ namespace: "run", keyField: "root_run_id" },
|
|
92
|
-
//
|
|
46
|
+
// Vercel AI SDK middleware: a flat sequence per correlation key.
|
|
93
47
|
{ namespace: "vercel_ai", keyField: "chain_key" },
|
|
94
|
-
//
|
|
95
|
-
// Interchange's own reactor-owned sequence, passed through.
|
|
48
|
+
// Interchange: one chain per session; `seq` is Interchange's own.
|
|
96
49
|
{ namespace: "interchange", keyField: "session_id" },
|
|
97
50
|
];
|
|
98
51
|
|
|
@@ -105,11 +58,8 @@ export type ChainFindingKind =
|
|
|
105
58
|
| "chain-second-root";
|
|
106
59
|
|
|
107
60
|
/**
|
|
108
|
-
* One link that does not resolve, as data.
|
|
109
|
-
*
|
|
110
|
-
* Structured for the same reason `PackGradeCause` is: prose is the part that
|
|
111
|
-
* drifts hardest between surfaces and matters least. `renderChainFinding`
|
|
112
|
-
* produces the house wording; a surface with its own voice reads the fields.
|
|
61
|
+
* One link that does not resolve, as data. `renderChainFinding` gives the house
|
|
62
|
+
* wording.
|
|
113
63
|
*/
|
|
114
64
|
export interface ChainFinding {
|
|
115
65
|
kind: ChainFindingKind;
|
|
@@ -134,12 +84,8 @@ export interface ChainFinding {
|
|
|
134
84
|
}
|
|
135
85
|
|
|
136
86
|
/**
|
|
137
|
-
* One chain this bundle presents records for.
|
|
138
|
-
*
|
|
139
|
-
* `events` is HOW MANY RECORDS ARE HERE — never how many the chain has. The
|
|
140
|
-
* summary exists so a surface can say "read 3 chains, nothing to report"
|
|
141
|
-
* instead of "nothing to report", which is also what a reader that silently
|
|
142
|
-
* matched no records would say.
|
|
87
|
+
* One chain this bundle presents records for. `events` counts the records
|
|
88
|
+
* here, never the chain's length.
|
|
143
89
|
*/
|
|
144
90
|
export interface ChainSummary {
|
|
145
91
|
chainKey: string;
|
|
@@ -154,8 +100,6 @@ export interface ChainReading {
|
|
|
154
100
|
findings: ChainFinding[];
|
|
155
101
|
}
|
|
156
102
|
|
|
157
|
-
// ---- Reading the records -----------------------------------------------------
|
|
158
|
-
|
|
159
103
|
interface EventRecord {
|
|
160
104
|
previous_hash?: unknown;
|
|
161
105
|
context?: { chain_key?: unknown };
|
|
@@ -165,7 +109,7 @@ interface EventRecord {
|
|
|
165
109
|
/** One record's place in one chain, normalised across the two linkage forms. */
|
|
166
110
|
interface Member {
|
|
167
111
|
eventId: string;
|
|
168
|
-
/** What the
|
|
112
|
+
/** What the next record's pointer must equal: payload hash, or event id. */
|
|
169
113
|
selfId: string;
|
|
170
114
|
/** The pointer this record carries, or `null` when it claims to begin the chain. */
|
|
171
115
|
prev: string | null;
|
|
@@ -183,46 +127,23 @@ interface Group {
|
|
|
183
127
|
}
|
|
184
128
|
|
|
185
129
|
/**
|
|
186
|
-
* The events this bundle is about,
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* answer to "which events" inside one kernel is a second implementation of one
|
|
190
|
-
* rule, and it is how a reader ends up walking a different set of events than
|
|
191
|
-
* the verifier reported on.
|
|
130
|
+
* The events this bundle is about, through the same descent
|
|
131
|
+
* `verifyEvidenceBundle` uses, so the reader walks the events the verifier
|
|
132
|
+
* reported on.
|
|
192
133
|
*/
|
|
193
134
|
const eventsOf = (bundle: unknown) => resolveTraceEvents(bundle) ?? [];
|
|
194
135
|
|
|
195
136
|
/**
|
|
196
|
-
* Decode one event record from its committed bytes
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* record is `null` rather than a throw or a guess. A withheld or unreadable
|
|
200
|
-
* record simply takes no part in a chain — `verifyEvidenceBundle` already
|
|
201
|
-
* reports its binding as undetermined, and inventing a second accusation here
|
|
202
|
-
* would say the same thing twice in a harsher voice.
|
|
137
|
+
* Decode one event record from its committed bytes, or `null`. A withheld or
|
|
138
|
+
* unreadable record takes no part in a chain: the bundle verifier already
|
|
139
|
+
* reports its binding as undetermined.
|
|
203
140
|
*/
|
|
204
141
|
function decodeRecord(hex: unknown): { record: EventRecord; bytes: Uint8Array } | null {
|
|
205
142
|
if (typeof hex !== "string" || hex.length === 0) return null;
|
|
206
143
|
try {
|
|
207
144
|
const bytes = hexToBytes(hex);
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
// LAST lenient decode pair in either kernel, and it was lenient on BOTH
|
|
211
|
-
// sides in different directions, so it agreed nowhere while looking like
|
|
212
|
-
// it agreed everywhere:
|
|
213
|
-
//
|
|
214
|
-
// * A bare `new TextDecoder()` here eats a leading byte-order mark and
|
|
215
|
-
// substitutes U+FFFD for every invalid sequence, so `BOM + JCS(r)`, a
|
|
216
|
-
// raw `0x80`, a CESU-8 surrogate pair and a truncated UTF-8 sequence
|
|
217
|
-
// each joined a chain HERE and joined none there.
|
|
218
|
-
// * Python's `json.loads` accepted `NaN`, `Infinity` and `-Infinity`,
|
|
219
|
-
// which RFC 8785 cannot emit and this kernel's `JSON.parse` rejects,
|
|
220
|
-
// so a record carrying one joined a chain THERE and none here.
|
|
221
|
-
//
|
|
222
|
-
// Five split readings on one pair, in two directions, and no test in
|
|
223
|
-
// either kernel could see them: each kernel agreed with itself. That is
|
|
224
|
-
// why agreement between two lenient readers is not evidence of anything,
|
|
225
|
-
// and why the comparison has to run on the same bytes.
|
|
145
|
+
// Strict, matching Python's `jcs_loads`: a lenient decoder on either side
|
|
146
|
+
// (BOM, invalid UTF-8, non-finite literals) splits the two kernels.
|
|
226
147
|
const record = jcsParse(bytes) as EventRecord;
|
|
227
148
|
if (record === null || typeof record !== "object") return null;
|
|
228
149
|
return { record, bytes };
|
|
@@ -234,10 +155,8 @@ function decodeRecord(hex: unknown): { record: EventRecord; bytes: Uint8Array }
|
|
|
234
155
|
const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null);
|
|
235
156
|
|
|
236
157
|
/**
|
|
237
|
-
* The adapter linkage, if
|
|
238
|
-
*
|
|
239
|
-
* Order is the table's order and the first match wins; a record carrying two
|
|
240
|
-
* adapter namespaces is not a shape any SDK emits.
|
|
158
|
+
* The adapter linkage, if the record carries a known one. The first namespace
|
|
159
|
+
* in table order wins.
|
|
241
160
|
*/
|
|
242
161
|
function adapterLinkage(
|
|
243
162
|
record: EventRecord,
|
|
@@ -250,20 +169,11 @@ function adapterLinkage(
|
|
|
250
169
|
const fields = ns as Record<string, unknown>;
|
|
251
170
|
const chainKey = str(fields[keyField]);
|
|
252
171
|
if (chainKey === null) continue;
|
|
253
|
-
//
|
|
254
|
-
// adapters write the null rather than omitting the key, because absent and
|
|
255
|
-
// null mean different things to a reader. Absent is treated the same way
|
|
256
|
-
// here: nothing to resolve is a beginning.
|
|
172
|
+
// Adapters write `null` at the head; absent reads the same: a beginning.
|
|
257
173
|
const prev = str(fields.prev_event_id);
|
|
258
174
|
const seqRaw = fields.seq;
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
// `...992` — while Python's parser keeps it exactly, so the two kernels
|
|
262
|
-
// read DIFFERENT sequence numbers off identical bytes and report gaps of
|
|
263
|
-
// different widths. Measured at 2^53+1: a `chain-gap` count of
|
|
264
|
-
// 9007199254740990 here against 9007199254740992 there (proof#49). A value
|
|
265
|
-
// this kernel cannot represent is one it cannot read, which is the same
|
|
266
|
-
// answer it gives every other unreadable field: no sequence, take no part.
|
|
175
|
+
// Safe integers only: beyond 2^53 `JSON.parse` loses precision where Python
|
|
176
|
+
// does not, and the kernels would report different gaps on the same bytes.
|
|
267
177
|
const seq = Number.isSafeInteger(seqRaw) ? (seqRaw as number) : null;
|
|
268
178
|
return { namespace, chainKey, prev, seq };
|
|
269
179
|
}
|
|
@@ -271,21 +181,9 @@ function adapterLinkage(
|
|
|
271
181
|
}
|
|
272
182
|
|
|
273
183
|
/**
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* is the previous record's `payload_hash`, and `payload_hash` is an asserted
|
|
278
|
-
* label sitting next to the bytes it claims to be about; matching one asserted
|
|
279
|
-
* label against another is how a relabelled checkpoint once verified cleanly
|
|
280
|
-
* (`evidence.ts`, `checkpoint_ref`). Hashing the bytes we already hold costs
|
|
281
|
-
* nothing and means a relabelled record DANGLES instead of quietly resolving.
|
|
282
|
-
* That is why this function is async.
|
|
283
|
-
*/
|
|
284
|
-
/**
|
|
285
|
-
* The record an envelope binds, for rule 11's links: `event_bytes` where the
|
|
286
|
-
* entry carries them, else the inline `payload` read through
|
|
287
|
-
* `inlinePayloadBytes`, the bytes `payload_hash` binds. Its identity is the
|
|
288
|
-
* SHA-256 of whichever bytes it is read from. Python's `_bound_record`.
|
|
184
|
+
* The record an envelope binds, for rule 11: `event_bytes` where present, else
|
|
185
|
+
* the inline `payload`. Its identity is the SHA-256 of those bytes. Python's
|
|
186
|
+
* twin is `_bound_record`.
|
|
289
187
|
*/
|
|
290
188
|
function boundRecord(
|
|
291
189
|
entry: { event_bytes?: unknown; envelope?: unknown } | undefined,
|
|
@@ -305,6 +203,11 @@ function boundRecord(
|
|
|
305
203
|
}
|
|
306
204
|
}
|
|
307
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Group the bundle's records into chains. Self-ids are derived by hashing the
|
|
208
|
+
* bytes, never read off the envelope, so a relabelled record dangles instead of
|
|
209
|
+
* resolving.
|
|
210
|
+
*/
|
|
308
211
|
async function groupChains(bundle: unknown, inline = false): Promise<Group[]> {
|
|
309
212
|
const groups = new Map<string, Group>();
|
|
310
213
|
const events = eventsOf(bundle);
|
|
@@ -313,22 +216,17 @@ async function groupChains(bundle: unknown, inline = false): Promise<Group[]> {
|
|
|
313
216
|
const entry = events[order];
|
|
314
217
|
const eventId = str(entry?.envelope?.event_id);
|
|
315
218
|
const publicKey = str(entry?.envelope?.public_key);
|
|
316
|
-
//
|
|
317
|
-
// event id an adapter pointer has nothing to resolve against.
|
|
219
|
+
// No producing key, no chain identity; no event id, nothing to resolve to.
|
|
318
220
|
if (eventId === null || publicKey === null) continue;
|
|
319
221
|
|
|
320
|
-
// `inline`
|
|
321
|
-
//
|
|
322
|
-
// itself keeps reading `event_bytes` alone, so its findings and its rule
|
|
323
|
-
// table do not move.
|
|
222
|
+
// `inline` is for rule 11 only; the chain reading itself reads
|
|
223
|
+
// `event_bytes` alone.
|
|
324
224
|
const decoded = inline ? boundRecord(entry) : decodeRecord(entry?.event_bytes);
|
|
325
225
|
if (decoded === null) continue;
|
|
326
226
|
const { record, bytes } = decoded;
|
|
327
227
|
|
|
328
|
-
// 1.
|
|
329
|
-
//
|
|
330
|
-
// singleton, all of them carrying `previous_hash: "GENESIS"`, out of one
|
|
331
|
-
// enormous fictional chain that would report a second root per pair.
|
|
228
|
+
// 1. `context.chain_key` is present only on chained records, which keeps
|
|
229
|
+
// unchained `GENESIS` singletons out of one fictional chain.
|
|
332
230
|
const recordChainKey = str(record.context?.chain_key);
|
|
333
231
|
let member: Member;
|
|
334
232
|
let key: string;
|
|
@@ -346,10 +244,7 @@ async function groupChains(bundle: unknown, inline = false): Promise<Group[]> {
|
|
|
346
244
|
group = { chainKey: recordChainKey, source: "record", publicKey };
|
|
347
245
|
key = `record ${publicKey} ${recordChainKey}`;
|
|
348
246
|
} else {
|
|
349
|
-
// 2. Fallback only
|
|
350
|
-
// through its adapter metadata: one record must not land in two
|
|
351
|
-
// chains, and the profile's own field is the one a third party can
|
|
352
|
-
// walk without knowing which SDK wrote it.
|
|
247
|
+
// 2. Fallback only: one record must not land in two chains.
|
|
353
248
|
const linkage = adapterLinkage(record);
|
|
354
249
|
if (linkage === null) continue;
|
|
355
250
|
member = { eventId, selfId: eventId, prev: linkage.prev, seq: linkage.seq, order };
|
|
@@ -370,8 +265,6 @@ async function groupChains(bundle: unknown, inline = false): Promise<Group[]> {
|
|
|
370
265
|
return [...groups.values()];
|
|
371
266
|
}
|
|
372
267
|
|
|
373
|
-
// ---- The rule ----------------------------------------------------------------
|
|
374
|
-
|
|
375
268
|
/** By `seq` where both have one, else by position in the bundle. */
|
|
376
269
|
function byChainOrder(a: Member, b: Member): number {
|
|
377
270
|
if (a.seq !== null && b.seq !== null && a.seq !== b.seq) return a.seq - b.seq;
|
|
@@ -379,10 +272,8 @@ function byChainOrder(a: Member, b: Member): number {
|
|
|
379
272
|
}
|
|
380
273
|
|
|
381
274
|
/**
|
|
382
|
-
* The smallest slot between the lowest and highest occupied ones
|
|
383
|
-
*
|
|
384
|
-
* span. See the call site: the range's width is chosen by whoever wrote the
|
|
385
|
-
* bundle.
|
|
275
|
+
* The smallest unoccupied slot between the lowest and highest occupied ones,
|
|
276
|
+
* found by walking the sorted occupants: the range width is attacker-chosen.
|
|
386
277
|
*/
|
|
387
278
|
function firstMissingSlot(present: Set<number>): number | null {
|
|
388
279
|
const ordered = [...present].sort((a, b) => a - b);
|
|
@@ -410,11 +301,8 @@ function findingsFor(group: Group): ChainFinding[] {
|
|
|
410
301
|
.filter((m): m is Member & { prev: string } => m.prev !== null && !selfIds.has(m.prev))
|
|
411
302
|
.sort(byChainOrder);
|
|
412
303
|
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
// alone reports as CLEAN, because both halves walk back to a legitimate
|
|
416
|
-
// beginning and neither dangles. It is what a crash looks like when the SDK
|
|
417
|
-
// lost its chain head and started over under the same name.
|
|
304
|
+
// Two GENESIS records under one key: a fork, which a link-walk alone reports
|
|
305
|
+
// as clean. Typically a crash that lost the chain head.
|
|
418
306
|
if (roots.length > 1) {
|
|
419
307
|
findings.push({
|
|
420
308
|
kind: "chain-second-root",
|
|
@@ -424,12 +312,8 @@ function findingsFor(group: Group): ChainFinding[] {
|
|
|
424
312
|
});
|
|
425
313
|
}
|
|
426
314
|
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
// before this export, so the window's earliest record necessarily points at a
|
|
430
|
-
// predecessor nobody handed us. The allowance is spent only when the chain
|
|
431
|
-
// shows no GENESIS root — a chain that already has its beginning here has no
|
|
432
|
-
// window edge left to explain the next one.
|
|
315
|
+
// One pointer out of the bundle is the window's edge, not a finding, unless
|
|
316
|
+
// the chain already has its GENESIS root here.
|
|
433
317
|
const unexplained = roots.length === 0 ? dangling.slice(1) : dangling;
|
|
434
318
|
if (unexplained.length > 0) {
|
|
435
319
|
const first = unexplained[0]!;
|
|
@@ -442,24 +326,15 @@ function findingsFor(group: Group): ChainFinding[] {
|
|
|
442
326
|
});
|
|
443
327
|
}
|
|
444
328
|
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
// reporting its first six absent slots would bury the one that matters. A
|
|
448
|
-
// repeated seq is a real defect but a different one, and the present-set below
|
|
449
|
-
// is deliberately duplicate-safe rather than count-based so it cannot be
|
|
450
|
-
// mistaken for a gap.
|
|
329
|
+
// Interior holes only: a window starting at seq 7 is not a deletion. The
|
|
330
|
+
// present-set is duplicate-safe so a repeated seq is not mistaken for a gap.
|
|
451
331
|
const sequenced = members.filter((m) => m.seq !== null);
|
|
452
332
|
if (sequenced.length === of && of > 0) {
|
|
453
333
|
const present = new Set(sequenced.map((m) => m.seq as number));
|
|
454
334
|
const low = Math.min(...present);
|
|
455
335
|
const high = Math.max(...present);
|
|
456
|
-
// Counted, never walked
|
|
457
|
-
//
|
|
458
|
-
// high; s++)` spin for days — a verifier that HANGS on adversarial input is
|
|
459
|
-
// worse than one that raises, and this reader promises neither (proof#49).
|
|
460
|
-
// The present set is a subset of [low, high], so the absent slots are the
|
|
461
|
-
// width minus the occupants, and the first of them is found by walking the
|
|
462
|
-
// occupants in order.
|
|
336
|
+
// Counted, never walked: `seq` is attacker-chosen, and a loop over
|
|
337
|
+
// [low, high] hangs on 0 and 1e15.
|
|
463
338
|
const missingCount = high - low + 1 - present.size;
|
|
464
339
|
const firstMissing = missingCount > 0 ? firstMissingSlot(present) : null;
|
|
465
340
|
if (firstMissing !== null) {
|
|
@@ -478,10 +353,8 @@ function findingsFor(group: Group): ChainFinding[] {
|
|
|
478
353
|
}
|
|
479
354
|
|
|
480
355
|
/**
|
|
481
|
-
* One record's place in a chain,
|
|
482
|
-
*
|
|
483
|
-
* evidence-bundle.md 5.4.2, which holds a claimed chain to every link. The
|
|
484
|
-
* twin of Python's `ChainLink`.
|
|
356
|
+
* One record's place in a chain, for rule 11 of evidence-bundle.md §5.4.2.
|
|
357
|
+
* Python's twin is `ChainLink`.
|
|
485
358
|
*/
|
|
486
359
|
export interface ChainLink {
|
|
487
360
|
eventId: string;
|
|
@@ -499,11 +372,8 @@ export interface ChainLink {
|
|
|
499
372
|
}
|
|
500
373
|
|
|
501
374
|
/**
|
|
502
|
-
* Every record
|
|
503
|
-
*
|
|
504
|
-
* record an envelope binds, `event_bytes` or the inline `payload` where no
|
|
505
|
-
* `event_bytes` travel, with the identity the SHA-256 of those bytes, exactly
|
|
506
|
-
* as the envelope verifier binds `payload_hash`.
|
|
375
|
+
* Every record placed in a chain, in bundle order, with its identity and
|
|
376
|
+
* pointer. Reads `event_bytes`, or the inline `payload` where none travel.
|
|
507
377
|
*/
|
|
508
378
|
export async function readChainLinks(bundle: unknown): Promise<ChainLink[]> {
|
|
509
379
|
const links: ChainLink[] = [];
|
|
@@ -526,23 +396,15 @@ export async function readChainLinks(bundle: unknown): Promise<ChainLink[]> {
|
|
|
526
396
|
}
|
|
527
397
|
|
|
528
398
|
/**
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* Reports NOTHING for an intact chain, and nothing for a chain whose records
|
|
533
|
-
* are withheld — a link we could not read is unknown, never an accusation.
|
|
534
|
-
* `chains` is what makes that distinguishable from having read nothing at all.
|
|
399
|
+
* Report the links that do not resolve within the bundle. An intact chain, and
|
|
400
|
+
* one whose records are withheld, report nothing: an unreadable link is
|
|
401
|
+
* unknown, never an accusation.
|
|
535
402
|
*/
|
|
536
403
|
export async function readChains(bundle: unknown): Promise<ChainReading> {
|
|
537
404
|
const groups = await groupChains(bundle);
|
|
538
405
|
const order = (g: Group) => `${g.source} ${g.namespace ?? ""} ${g.chainKey}`;
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
// ICU collation: measured here, it puts "chain-2" AFTER "chain_1" where every
|
|
542
|
-
// code-wise comparison puts it before, and it can differ between two
|
|
543
|
-
// TypeScript installs, let alone between kernels. The Python reader sorts the
|
|
544
|
-
// same key encoded as `utf-16-be`, which is exactly this comparison — so the
|
|
545
|
-
// two kernels emit findings in one order (proof#49).
|
|
406
|
+
// By UTF-16 code unit, not `localeCompare` (host-dependent ICU collation).
|
|
407
|
+
// Python sorts the key as `utf-16-be`, so both kernels emit one order.
|
|
546
408
|
groups.sort((a, b) => {
|
|
547
409
|
const x = order(a);
|
|
548
410
|
const y = order(b);
|
|
@@ -562,12 +424,9 @@ export async function readChains(bundle: unknown): Promise<ChainReading> {
|
|
|
562
424
|
}
|
|
563
425
|
|
|
564
426
|
/**
|
|
565
|
-
* The house wording for one finding.
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
* resolve IN THIS BUNDLE, because that is the whole of what was checked; none
|
|
569
|
-
* of them says failed, invalid, tampered or complete. A surface with its own
|
|
570
|
-
* voice should read the fields instead — but it must not upgrade the claim.
|
|
427
|
+
* The house wording for one finding. Says only what does not resolve in this
|
|
428
|
+
* bundle, never failed, tampered or complete; a surface rewording it must not
|
|
429
|
+
* upgrade the claim.
|
|
571
430
|
*/
|
|
572
431
|
export function renderChainFinding(finding: ChainFinding, subject = "this bundle"): string {
|
|
573
432
|
const where =
|
|
@@ -577,14 +436,16 @@ export function renderChainFinding(finding: ChainFinding, subject = "this bundle
|
|
|
577
436
|
switch (finding.kind) {
|
|
578
437
|
case "chain-link-dangling":
|
|
579
438
|
return (
|
|
580
|
-
`${where}: ${finding.count} of ${finding.of}
|
|
439
|
+
`${where}: ${finding.count} of ${finding.of} ` +
|
|
440
|
+
`${finding.of === 1 ? "record points" : "records point"} at a predecessor ` +
|
|
581
441
|
`${subject} does not present (first: ${finding.eventIds[0]}, pointer ` +
|
|
582
442
|
`${finding.unresolvedPointer})`
|
|
583
443
|
);
|
|
584
444
|
case "chain-gap":
|
|
585
445
|
return (
|
|
586
|
-
`${where}: ${finding.count} sequence
|
|
587
|
-
|
|
446
|
+
`${where}: ${finding.count} sequence ` +
|
|
447
|
+
`${finding.count === 1 ? "slot" : "slots"} between the records ${subject} presents ` +
|
|
448
|
+
`${finding.count === 1 ? "carries" : "carry"} no record (first missing seq ${finding.firstMissingSeq}` +
|
|
588
449
|
`${finding.eventIds[0] === undefined ? "" : `, next present: ${finding.eventIds[0]}`})`
|
|
589
450
|
);
|
|
590
451
|
case "chain-second-root":
|