@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.
Files changed (98) hide show
  1. package/README.md +30 -29
  2. package/dist/agent-record.d.ts +62 -0
  3. package/dist/agent-record.js +527 -0
  4. package/dist/agent-schemas.generated.d.ts +15 -0
  5. package/dist/agent-schemas.generated.js +5759 -0
  6. package/dist/case-file.d.ts +32 -89
  7. package/dist/case-file.js +41 -93
  8. package/dist/case.d.ts +339 -822
  9. package/dist/case.js +667 -1160
  10. package/dist/chain.d.ts +19 -47
  11. package/dist/chain.js +78 -205
  12. package/dist/claims.d.ts +211 -0
  13. package/dist/claims.js +1081 -0
  14. package/dist/cli.d.ts +45 -68
  15. package/dist/cli.js +618 -1022
  16. package/dist/compose.js +23 -65
  17. package/dist/crypto.d.ts +7 -8
  18. package/dist/crypto.js +60 -147
  19. package/dist/declaration.d.ts +68 -194
  20. package/dist/declaration.js +175 -518
  21. package/dist/environment.d.ts +9 -30
  22. package/dist/environment.js +17 -66
  23. package/dist/evidence.d.ts +119 -160
  24. package/dist/evidence.js +744 -1103
  25. package/dist/exit-codes.d.ts +11 -27
  26. package/dist/exit-codes.js +24 -59
  27. package/dist/file-match.d.ts +33 -79
  28. package/dist/file-match.js +33 -69
  29. package/dist/grading.d.ts +98 -135
  30. package/dist/grading.js +224 -380
  31. package/dist/handoff.d.ts +62 -160
  32. package/dist/handoff.js +153 -393
  33. package/dist/index.d.ts +14 -10
  34. package/dist/index.js +60 -167
  35. package/dist/json-schema.d.ts +29 -0
  36. package/dist/json-schema.js +298 -0
  37. package/dist/merkle.js +16 -28
  38. package/dist/message-parts.d.ts +56 -212
  39. package/dist/message-parts.js +142 -502
  40. package/dist/side-content.d.ts +11 -22
  41. package/dist/side-content.js +22 -65
  42. package/dist/types.d.ts +17 -0
  43. package/dist/types.js +19 -5
  44. package/dist/verifier.d.ts +40 -114
  45. package/dist/verifier.js +196 -535
  46. package/dist/witness-cache.d.ts +24 -45
  47. package/dist/witness-cache.js +38 -81
  48. package/dist/witness-lookup.d.ts +36 -57
  49. package/dist/witness-lookup.js +89 -139
  50. package/dist/zip-reader.d.ts +18 -33
  51. package/dist/zip-reader.js +19 -34
  52. package/package.json +11 -3
  53. package/src/agent-record.ts +608 -0
  54. package/src/agent-schemas.generated.ts +5764 -0
  55. package/src/case-file.ts +45 -109
  56. package/src/case.ts +814 -1328
  57. package/src/chain.ts +85 -224
  58. package/src/claims.ts +1253 -0
  59. package/src/cli.ts +714 -1176
  60. package/src/compose.ts +33 -79
  61. package/src/crypto.ts +60 -147
  62. package/src/declaration.ts +219 -656
  63. package/src/environment.ts +17 -65
  64. package/src/evidence.ts +958 -1381
  65. package/src/exit-codes.ts +24 -63
  66. package/src/file-match.ts +40 -89
  67. package/src/grading.ts +287 -421
  68. package/src/handoff.ts +175 -427
  69. package/src/index.ts +131 -177
  70. package/src/json-schema.ts +295 -0
  71. package/src/merkle.ts +16 -28
  72. package/src/message-parts.ts +155 -533
  73. package/src/side-content.ts +25 -68
  74. package/src/types.ts +55 -36
  75. package/src/verifier.ts +202 -526
  76. package/src/witness-cache.ts +45 -94
  77. package/src/witness-lookup.ts +107 -177
  78. package/src/zip-reader.ts +20 -38
  79. package/vectors/README.md +52 -34
  80. package/vectors/agent-record.json +2175 -0
  81. package/vectors/case-file-index.json +1 -1
  82. package/vectors/case-split.json +2 -2
  83. package/vectors/chain-reading.json +3 -3
  84. package/vectors/file-match.json +285 -148
  85. package/vectors/jcs-reader.json +6 -6
  86. package/vectors/pack-grading.json +102 -25
  87. package/vectors/sandbox-evidence.json +573 -581
  88. package/vectors/stamp-on-chain.json +18 -18
  89. package/vectors/witness-cache.json +49 -49
  90. package/dist/agent-proof.d.ts +0 -28
  91. package/dist/agent-proof.js +0 -226
  92. package/dist/run-adapters.d.ts +0 -10
  93. package/dist/run-adapters.js +0 -35
  94. package/dist/run.d.ts +0 -209
  95. package/dist/run.js +0 -1405
  96. package/src/agent-proof.ts +0 -264
  97. package/src/run-adapters.ts +0 -44
  98. package/src/run.ts +0 -1598
package/src/chain.ts CHANGED
@@ -1,70 +1,33 @@
1
- // Chain reading — following the pointer every anchored event carries to the
2
- // event before it, and reporting what does not line up.
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
- // WHY THIS LIVES IN THE KERNEL, BESIDE PACK GRADING.
5
- // `sanning.events/v2` records carry `previous_hash`, and until now nothing we
6
- // shipped read it. The envelope verifier hashes each event's `event_bytes`
7
- // and stops — `evidence.ts` says following the link is "consumer-layer logic
8
- // above it", and it was right that a SINGLE-envelope verifier should not do
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
- // Reading the link is not envelope verification and it is not pack grading: it
14
- // is a third, pure rule over the records a bundle presents. Same seam as
15
- // grading — data in, data out, no `fs`, no `fetch` — so the kernel's scope
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
- // ---- WHAT THIS IS NOT ------------------------------------------------------
19
- //
20
- // It is not a completeness statement and it must never be read as one. The
21
- // profile says so in its own words (`anchor/docs/profile-sanning.events-v2.md`
22
- // §5): chain semantics are "deletion/reorder-evidence for the events present
23
- // ... not a completeness proof." Drop the last N events of a chain and nothing
24
- // here dangles; the record that would have pointed at them was never written.
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 one sentence a surface should print beside these findings, so the reader
64
- * is never left to infer that "no findings" means "nothing is missing".
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
- * VERIFIED AGAINST `sanning-io/anchor` @ main, 2026-09-07. `run` is NOT a typo
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
- // @sanning/anchor LangChain callbacks, TS + Python (anchor#68). One chain per
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
- // @sanning/anchor Vercel AI SDK middleware. A flat sequence per correlation key.
46
+ // Vercel AI SDK middleware: a flat sequence per correlation key.
93
47
  { namespace: "vercel_ai", keyField: "chain_key" },
94
- // @sanning/anchor Interchange adapter. One chain per session; `seq` is
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 NEXT record's pointer must equal: payload hash, or event id. */
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, unwrapping any evidence-export wrapper to
187
- * the inline source bundle. DEFERRED to `evidence.ts`, which is the same
188
- * descent `verifyEvidenceBundle` and `disclosedContent` already use — a second
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
- * Same discipline as `signedLeafCount`: hex, UTF-8, JSON, and an undecodable
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
- // PINNED STRICT via `jcsParse`, and the twin at
209
- // `src/sanning_proof/chain.py` is `jcs_loads` (proof#149). This was the
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 this record carries one this reader knows about.
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
- // `prev_event_id` is explicitly null at the head of an adapter chain — the
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
- // SAFE integer, not merely integral. Beyond 2^53 a JSON number does not
260
- // survive `JSON.parse` intact — `9007199254740993` arrives as
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
- * Group the bundle's records into chains.
275
- *
276
- * Self-ids are DERIVED, never read off the envelope. A record chain's pointer
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
- // Without a producing key there is no chain identity (§3.2), and without an
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` (rule 11's links only): a record carried as the envelope's
321
- // inline `payload` with no `event_bytes` is read too. The chain reading
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. The record's own chain. `context.chain_key` is present IFF the record
329
- // is chained (profile §5) — which is what keeps every unchained
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. A record that carries its own chain is never read
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 that no record
383
- * occupies, found by walking the SORTED occupants rather than the range they
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
- // A SECOND BEGINNING. One chain has one head per (public_key, chain_key), so
414
- // two records claiming GENESIS under one key is a fork — the case a link-walk
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
- // A POINTER THAT LEAVES THIS BUNDLE. Exactly one such pointer is expected and
428
- // is NOT a finding: a bundle is a window onto a chain that may have begun
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
- // A HOLE IN THE SEQUENCE, where an adapter numbers its events. Only INTERIOR
446
- // holes count: a window that starts at seq 7 is a window, not a deletion, and
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. `seq` is attacker-chosen, so a bundle presenting
457
- // two records at seq 0 and seq 1e15 made the old `for (let s = low; s <=
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, as the reader grouped it, for a check that
482
- * needs the links themselves rather than the findings: rule 11 of
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 the chain reader places in a chain, in bundle order, with the
503
- * identity and pointer it read: the grouping `readChains` reads, over every
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
- * Follow every chain the bundle's records describe, and report the links that
530
- * do not resolve within it.
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
- // Ordered by UTF-16 code unit, NOT `localeCompare`. A chain key is caller
540
- // text, and `localeCompare` with no locale argument answers from the host's
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
- * Deliberately free of verdict vocabulary. Every sentence says what does not
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} record(s) point at a predecessor ` +
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 slot(s) between the records ${subject} presents ` +
587
- `carry no record (first missing seq ${finding.firstMissingSeq}` +
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":