@sanning/proof 0.19.0 → 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 +343 -810
  9. package/dist/case.js +693 -1151
  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 +848 -1322
  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 +132 -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 +597 -580
  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/dist/chain.d.ts CHANGED
@@ -1,25 +1,15 @@
1
1
  /**
2
- * The one sentence a surface should print beside these findings, so the reader
3
- * is never left to infer that "no findings" means "nothing is missing".
4
- *
5
- * Lifted from the profile (§5) rather than reworded, because a paraphrase of a
6
- * scope limit is how a scope limit turns into a claim.
2
+ * The sentence a surface prints beside these findings, so "no findings" is
3
+ * never read as "nothing is missing". Quoted from the profile (§5), because a
4
+ * paraphrased scope limit turns into a claim.
7
5
  */
8
6
  export declare const CHAIN_READING_SCOPE = "Chain links are deletion/reorder-evidence for the events present; they are not a completeness proof.";
9
7
  /** Where the linkage was read from. */
10
8
  export type ChainSource = "record" | "adapter";
11
9
  /**
12
10
  * The adapter metadata namespaces that carry a chain when the record does not.
13
- *
14
- * VERIFIED AGAINST `sanning-io/anchor` @ main, 2026-09-07. `run` is NOT a typo
15
- * for `langchain`: anchor#68 promoted the LangChain linkage out of a
16
- * framework-named namespace into a shared one, so the same fact has one path in
17
- * every SDK. A table pinned on the old name would find no chains, report
18
- * nothing, and look exactly like a clean bundle — the rename trap, in the
19
- * direction that fails silently.
20
- *
21
- * Exported so a test can assert the table itself, and so a consumer can see
22
- * what this reader knows about rather than guessing.
11
+ * The names must match the `@sanning/anchor` SDKs exactly: a stale name finds no
12
+ * chains and reads like a clean bundle.
23
13
  */
24
14
  export declare const ADAPTER_CHAIN_NAMESPACES: readonly {
25
15
  namespace: string;
@@ -33,11 +23,8 @@ export type ChainFindingKind =
33
23
  /** More than one record claims to begin the chain. */
34
24
  | "chain-second-root";
35
25
  /**
36
- * One link that does not resolve, as data.
37
- *
38
- * Structured for the same reason `PackGradeCause` is: prose is the part that
39
- * drifts hardest between surfaces and matters least. `renderChainFinding`
40
- * produces the house wording; a surface with its own voice reads the fields.
26
+ * One link that does not resolve, as data. `renderChainFinding` gives the house
27
+ * wording.
41
28
  */
42
29
  export interface ChainFinding {
43
30
  kind: ChainFindingKind;
@@ -61,12 +48,8 @@ export interface ChainFinding {
61
48
  firstMissingSeq?: number;
62
49
  }
63
50
  /**
64
- * One chain this bundle presents records for.
65
- *
66
- * `events` is HOW MANY RECORDS ARE HERE — never how many the chain has. The
67
- * summary exists so a surface can say "read 3 chains, nothing to report"
68
- * instead of "nothing to report", which is also what a reader that silently
69
- * matched no records would say.
51
+ * One chain this bundle presents records for. `events` counts the records
52
+ * here, never the chain's length.
70
53
  */
71
54
  export interface ChainSummary {
72
55
  chainKey: string;
@@ -80,10 +63,8 @@ export interface ChainReading {
80
63
  findings: ChainFinding[];
81
64
  }
82
65
  /**
83
- * One record's place in a chain, as the reader grouped it, for a check that
84
- * needs the links themselves rather than the findings: rule 11 of
85
- * evidence-bundle.md 5.4.2, which holds a claimed chain to every link. The
86
- * twin of Python's `ChainLink`.
66
+ * One record's place in a chain, for rule 11 of evidence-bundle.md §5.4.2.
67
+ * Python's twin is `ChainLink`.
87
68
  */
88
69
  export interface ChainLink {
89
70
  eventId: string;
@@ -100,28 +81,19 @@ export interface ChainLink {
100
81
  order: number;
101
82
  }
102
83
  /**
103
- * Every record the chain reader places in a chain, in bundle order, with the
104
- * identity and pointer it read: the grouping `readChains` reads, over every
105
- * record an envelope binds, `event_bytes` or the inline `payload` where no
106
- * `event_bytes` travel, with the identity the SHA-256 of those bytes, exactly
107
- * as the envelope verifier binds `payload_hash`.
84
+ * Every record placed in a chain, in bundle order, with its identity and
85
+ * pointer. Reads `event_bytes`, or the inline `payload` where none travel.
108
86
  */
109
87
  export declare function readChainLinks(bundle: unknown): Promise<ChainLink[]>;
110
88
  /**
111
- * Follow every chain the bundle's records describe, and report the links that
112
- * do not resolve within it.
113
- *
114
- * Reports NOTHING for an intact chain, and nothing for a chain whose records
115
- * are withheld — a link we could not read is unknown, never an accusation.
116
- * `chains` is what makes that distinguishable from having read nothing at all.
89
+ * Report the links that do not resolve within the bundle. An intact chain, and
90
+ * one whose records are withheld, report nothing: an unreadable link is
91
+ * unknown, never an accusation.
117
92
  */
118
93
  export declare function readChains(bundle: unknown): Promise<ChainReading>;
119
94
  /**
120
- * The house wording for one finding.
121
- *
122
- * Deliberately free of verdict vocabulary. Every sentence says what does not
123
- * resolve IN THIS BUNDLE, because that is the whole of what was checked; none
124
- * of them says failed, invalid, tampered or complete. A surface with its own
125
- * voice should read the fields instead — but it must not upgrade the claim.
95
+ * The house wording for one finding. Says only what does not resolve in this
96
+ * bundle, never failed, tampered or complete; a surface rewording it must not
97
+ * upgrade the claim.
126
98
  */
127
99
  export declare function renderChainFinding(finding: ChainFinding, subject?: string): string;
package/dist/chain.js CHANGED
@@ -1,134 +1,64 @@
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
  import { hexToBytes, sha256Hex } from "./crypto.js";
58
23
  import { inlinePayloadBytes, jcsParse } from "./verifier.js";
59
24
  import { resolveTraceEvents } from "./evidence.js";
60
25
  /**
61
- * The one sentence a surface should print beside these findings, so the reader
62
- * is never left to infer that "no findings" means "nothing is missing".
63
- *
64
- * Lifted from the profile (§5) rather than reworded, because a paraphrase of a
65
- * scope limit is how a scope limit turns into a claim.
26
+ * The sentence a surface prints beside these findings, so "no findings" is
27
+ * never read as "nothing is missing". Quoted from the profile (§5), because a
28
+ * paraphrased scope limit turns into a claim.
66
29
  */
67
30
  export const CHAIN_READING_SCOPE = "Chain links are deletion/reorder-evidence for the events present; they are not a completeness proof.";
68
31
  /**
69
32
  * The adapter metadata namespaces that carry a chain when the record does not.
70
- *
71
- * VERIFIED AGAINST `sanning-io/anchor` @ main, 2026-09-07. `run` is NOT a typo
72
- * for `langchain`: anchor#68 promoted the LangChain linkage out of a
73
- * framework-named namespace into a shared one, so the same fact has one path in
74
- * every SDK. A table pinned on the old name would find no chains, report
75
- * nothing, and look exactly like a clean bundle — the rename trap, in the
76
- * direction that fails silently.
77
- *
78
- * Exported so a test can assert the table itself, and so a consumer can see
79
- * what this reader knows about rather than guessing.
33
+ * The names must match the `@sanning/anchor` SDKs exactly: a stale name finds no
34
+ * chains and reads like a clean bundle.
80
35
  */
81
36
  export const ADAPTER_CHAIN_NAMESPACES = [
82
- // @sanning/anchor LangChain callbacks, TS + Python (anchor#68). One chain per
83
- // root run, so the root run id is the chain.
37
+ // LangChain callbacks: one chain per root run.
84
38
  { namespace: "run", keyField: "root_run_id" },
85
- // @sanning/anchor Vercel AI SDK middleware. A flat sequence per correlation key.
39
+ // Vercel AI SDK middleware: a flat sequence per correlation key.
86
40
  { namespace: "vercel_ai", keyField: "chain_key" },
87
- // @sanning/anchor Interchange adapter. One chain per session; `seq` is
88
- // Interchange's own reactor-owned sequence, passed through.
41
+ // Interchange: one chain per session; `seq` is Interchange's own.
89
42
  { namespace: "interchange", keyField: "session_id" },
90
43
  ];
91
44
  /**
92
- * The events this bundle is about, unwrapping any evidence-export wrapper to
93
- * the inline source bundle. DEFERRED to `evidence.ts`, which is the same
94
- * descent `verifyEvidenceBundle` and `disclosedContent` already use — a second
95
- * answer to "which events" inside one kernel is a second implementation of one
96
- * rule, and it is how a reader ends up walking a different set of events than
97
- * the verifier reported on.
45
+ * The events this bundle is about, through the same descent
46
+ * `verifyEvidenceBundle` uses, so the reader walks the events the verifier
47
+ * reported on.
98
48
  */
99
49
  const eventsOf = (bundle) => resolveTraceEvents(bundle) ?? [];
100
50
  /**
101
- * Decode one event record from its committed bytes.
102
- *
103
- * Same discipline as `signedLeafCount`: hex, UTF-8, JSON, and an undecodable
104
- * record is `null` rather than a throw or a guess. A withheld or unreadable
105
- * record simply takes no part in a chain — `verifyEvidenceBundle` already
106
- * reports its binding as undetermined, and inventing a second accusation here
107
- * would say the same thing twice in a harsher voice.
51
+ * Decode one event record from its committed bytes, or `null`. A withheld or
52
+ * unreadable record takes no part in a chain: the bundle verifier already
53
+ * reports its binding as undetermined.
108
54
  */
109
55
  function decodeRecord(hex) {
110
56
  if (typeof hex !== "string" || hex.length === 0)
111
57
  return null;
112
58
  try {
113
59
  const bytes = hexToBytes(hex);
114
- // PINNED STRICT via `jcsParse`, and the twin at
115
- // `src/sanning_proof/chain.py` is `jcs_loads` (proof#149). This was the
116
- // LAST lenient decode pair in either kernel, and it was lenient on BOTH
117
- // sides in different directions, so it agreed nowhere while looking like
118
- // it agreed everywhere:
119
- //
120
- // * A bare `new TextDecoder()` here eats a leading byte-order mark and
121
- // substitutes U+FFFD for every invalid sequence, so `BOM + JCS(r)`, a
122
- // raw `0x80`, a CESU-8 surrogate pair and a truncated UTF-8 sequence
123
- // each joined a chain HERE and joined none there.
124
- // * Python's `json.loads` accepted `NaN`, `Infinity` and `-Infinity`,
125
- // which RFC 8785 cannot emit and this kernel's `JSON.parse` rejects,
126
- // so a record carrying one joined a chain THERE and none here.
127
- //
128
- // Five split readings on one pair, in two directions, and no test in
129
- // either kernel could see them: each kernel agreed with itself. That is
130
- // why agreement between two lenient readers is not evidence of anything,
131
- // and why the comparison has to run on the same bytes.
60
+ // Strict, matching Python's `jcs_loads`: a lenient decoder on either side
61
+ // (BOM, invalid UTF-8, non-finite literals) splits the two kernels.
132
62
  const record = jcsParse(bytes);
133
63
  if (record === null || typeof record !== "object")
134
64
  return null;
@@ -140,10 +70,8 @@ function decodeRecord(hex) {
140
70
  }
141
71
  const str = (v) => (typeof v === "string" && v.length > 0 ? v : null);
142
72
  /**
143
- * The adapter linkage, if this record carries one this reader knows about.
144
- *
145
- * Order is the table's order and the first match wins; a record carrying two
146
- * adapter namespaces is not a shape any SDK emits.
73
+ * The adapter linkage, if the record carries a known one. The first namespace
74
+ * in table order wins.
147
75
  */
148
76
  function adapterLinkage(record) {
149
77
  const metadata = record.metadata;
@@ -157,41 +85,20 @@ function adapterLinkage(record) {
157
85
  const chainKey = str(fields[keyField]);
158
86
  if (chainKey === null)
159
87
  continue;
160
- // `prev_event_id` is explicitly null at the head of an adapter chain — the
161
- // adapters write the null rather than omitting the key, because absent and
162
- // null mean different things to a reader. Absent is treated the same way
163
- // here: nothing to resolve is a beginning.
88
+ // Adapters write `null` at the head; absent reads the same: a beginning.
164
89
  const prev = str(fields.prev_event_id);
165
90
  const seqRaw = fields.seq;
166
- // SAFE integer, not merely integral. Beyond 2^53 a JSON number does not
167
- // survive `JSON.parse` intact — `9007199254740993` arrives as
168
- // `...992` — while Python's parser keeps it exactly, so the two kernels
169
- // read DIFFERENT sequence numbers off identical bytes and report gaps of
170
- // different widths. Measured at 2^53+1: a `chain-gap` count of
171
- // 9007199254740990 here against 9007199254740992 there (proof#49). A value
172
- // this kernel cannot represent is one it cannot read, which is the same
173
- // answer it gives every other unreadable field: no sequence, take no part.
91
+ // Safe integers only: beyond 2^53 `JSON.parse` loses precision where Python
92
+ // does not, and the kernels would report different gaps on the same bytes.
174
93
  const seq = Number.isSafeInteger(seqRaw) ? seqRaw : null;
175
94
  return { namespace, chainKey, prev, seq };
176
95
  }
177
96
  return null;
178
97
  }
179
98
  /**
180
- * Group the bundle's records into chains.
181
- *
182
- * Self-ids are DERIVED, never read off the envelope. A record chain's pointer
183
- * is the previous record's `payload_hash`, and `payload_hash` is an asserted
184
- * label sitting next to the bytes it claims to be about; matching one asserted
185
- * label against another is how a relabelled checkpoint once verified cleanly
186
- * (`evidence.ts`, `checkpoint_ref`). Hashing the bytes we already hold costs
187
- * nothing and means a relabelled record DANGLES instead of quietly resolving.
188
- * That is why this function is async.
189
- */
190
- /**
191
- * The record an envelope binds, for rule 11's links: `event_bytes` where the
192
- * entry carries them, else the inline `payload` read through
193
- * `inlinePayloadBytes`, the bytes `payload_hash` binds. Its identity is the
194
- * SHA-256 of whichever bytes it is read from. Python's `_bound_record`.
99
+ * The record an envelope binds, for rule 11: `event_bytes` where present, else
100
+ * the inline `payload`. Its identity is the SHA-256 of those bytes. Python's
101
+ * twin is `_bound_record`.
195
102
  */
196
103
  function boundRecord(entry) {
197
104
  const eventBytes = entry?.event_bytes;
@@ -213,6 +120,11 @@ function boundRecord(entry) {
213
120
  return null;
214
121
  }
215
122
  }
123
+ /**
124
+ * Group the bundle's records into chains. Self-ids are derived by hashing the
125
+ * bytes, never read off the envelope, so a relabelled record dangles instead of
126
+ * resolving.
127
+ */
216
128
  async function groupChains(bundle, inline = false) {
217
129
  const groups = new Map();
218
130
  const events = eventsOf(bundle);
@@ -220,22 +132,17 @@ async function groupChains(bundle, inline = false) {
220
132
  const entry = events[order];
221
133
  const eventId = str(entry?.envelope?.event_id);
222
134
  const publicKey = str(entry?.envelope?.public_key);
223
- // Without a producing key there is no chain identity (§3.2), and without an
224
- // event id an adapter pointer has nothing to resolve against.
135
+ // No producing key, no chain identity; no event id, nothing to resolve to.
225
136
  if (eventId === null || publicKey === null)
226
137
  continue;
227
- // `inline` (rule 11's links only): a record carried as the envelope's
228
- // inline `payload` with no `event_bytes` is read too. The chain reading
229
- // itself keeps reading `event_bytes` alone, so its findings and its rule
230
- // table do not move.
138
+ // `inline` is for rule 11 only; the chain reading itself reads
139
+ // `event_bytes` alone.
231
140
  const decoded = inline ? boundRecord(entry) : decodeRecord(entry?.event_bytes);
232
141
  if (decoded === null)
233
142
  continue;
234
143
  const { record, bytes } = decoded;
235
- // 1. The record's own chain. `context.chain_key` is present IFF the record
236
- // is chained (profile §5) — which is what keeps every unchained
237
- // singleton, all of them carrying `previous_hash: "GENESIS"`, out of one
238
- // enormous fictional chain that would report a second root per pair.
144
+ // 1. `context.chain_key` is present only on chained records, which keeps
145
+ // unchained `GENESIS` singletons out of one fictional chain.
239
146
  const recordChainKey = str(record.context?.chain_key);
240
147
  let member;
241
148
  let key;
@@ -253,10 +160,7 @@ async function groupChains(bundle, inline = false) {
253
160
  key = `record ${publicKey} ${recordChainKey}`;
254
161
  }
255
162
  else {
256
- // 2. Fallback only. A record that carries its own chain is never read
257
- // through its adapter metadata: one record must not land in two
258
- // chains, and the profile's own field is the one a third party can
259
- // walk without knowing which SDK wrote it.
163
+ // 2. Fallback only: one record must not land in two chains.
260
164
  const linkage = adapterLinkage(record);
261
165
  if (linkage === null)
262
166
  continue;
@@ -277,7 +181,6 @@ async function groupChains(bundle, inline = false) {
277
181
  }
278
182
  return [...groups.values()];
279
183
  }
280
- // ---- The rule ----------------------------------------------------------------
281
184
  /** By `seq` where both have one, else by position in the bundle. */
282
185
  function byChainOrder(a, b) {
283
186
  if (a.seq !== null && b.seq !== null && a.seq !== b.seq)
@@ -285,10 +188,8 @@ function byChainOrder(a, b) {
285
188
  return a.order - b.order;
286
189
  }
287
190
  /**
288
- * The smallest slot between the lowest and highest occupied ones that no record
289
- * occupies, found by walking the SORTED occupants rather than the range they
290
- * span. See the call site: the range's width is chosen by whoever wrote the
291
- * bundle.
191
+ * The smallest unoccupied slot between the lowest and highest occupied ones,
192
+ * found by walking the sorted occupants: the range width is attacker-chosen.
292
193
  */
293
194
  function firstMissingSlot(present) {
294
195
  const ordered = [...present].sort((a, b) => a - b);
@@ -314,11 +215,8 @@ function findingsFor(group) {
314
215
  const dangling = members
315
216
  .filter((m) => m.prev !== null && !selfIds.has(m.prev))
316
217
  .sort(byChainOrder);
317
- // A SECOND BEGINNING. One chain has one head per (public_key, chain_key), so
318
- // two records claiming GENESIS under one key is a fork — the case a link-walk
319
- // alone reports as CLEAN, because both halves walk back to a legitimate
320
- // beginning and neither dangles. It is what a crash looks like when the SDK
321
- // lost its chain head and started over under the same name.
218
+ // Two GENESIS records under one key: a fork, which a link-walk alone reports
219
+ // as clean. Typically a crash that lost the chain head.
322
220
  if (roots.length > 1) {
323
221
  findings.push({
324
222
  kind: "chain-second-root",
@@ -327,12 +225,8 @@ function findingsFor(group) {
327
225
  eventIds: roots.map((m) => m.eventId),
328
226
  });
329
227
  }
330
- // A POINTER THAT LEAVES THIS BUNDLE. Exactly one such pointer is expected and
331
- // is NOT a finding: a bundle is a window onto a chain that may have begun
332
- // before this export, so the window's earliest record necessarily points at a
333
- // predecessor nobody handed us. The allowance is spent only when the chain
334
- // shows no GENESIS root — a chain that already has its beginning here has no
335
- // window edge left to explain the next one.
228
+ // One pointer out of the bundle is the window's edge, not a finding, unless
229
+ // the chain already has its GENESIS root here.
336
230
  const unexplained = roots.length === 0 ? dangling.slice(1) : dangling;
337
231
  if (unexplained.length > 0) {
338
232
  const first = unexplained[0];
@@ -344,24 +238,15 @@ function findingsFor(group) {
344
238
  unresolvedPointer: first.prev,
345
239
  });
346
240
  }
347
- // A HOLE IN THE SEQUENCE, where an adapter numbers its events. Only INTERIOR
348
- // holes count: a window that starts at seq 7 is a window, not a deletion, and
349
- // reporting its first six absent slots would bury the one that matters. A
350
- // repeated seq is a real defect but a different one, and the present-set below
351
- // is deliberately duplicate-safe rather than count-based so it cannot be
352
- // mistaken for a gap.
241
+ // Interior holes only: a window starting at seq 7 is not a deletion. The
242
+ // present-set is duplicate-safe so a repeated seq is not mistaken for a gap.
353
243
  const sequenced = members.filter((m) => m.seq !== null);
354
244
  if (sequenced.length === of && of > 0) {
355
245
  const present = new Set(sequenced.map((m) => m.seq));
356
246
  const low = Math.min(...present);
357
247
  const high = Math.max(...present);
358
- // Counted, never walked. `seq` is attacker-chosen, so a bundle presenting
359
- // two records at seq 0 and seq 1e15 made the old `for (let s = low; s <=
360
- // high; s++)` spin for days — a verifier that HANGS on adversarial input is
361
- // worse than one that raises, and this reader promises neither (proof#49).
362
- // The present set is a subset of [low, high], so the absent slots are the
363
- // width minus the occupants, and the first of them is found by walking the
364
- // occupants in order.
248
+ // Counted, never walked: `seq` is attacker-chosen, and a loop over
249
+ // [low, high] hangs on 0 and 1e15.
365
250
  const missingCount = high - low + 1 - present.size;
366
251
  const firstMissing = missingCount > 0 ? firstMissingSlot(present) : null;
367
252
  if (firstMissing !== null) {
@@ -378,11 +263,8 @@ function findingsFor(group) {
378
263
  return findings;
379
264
  }
380
265
  /**
381
- * Every record the chain reader places in a chain, in bundle order, with the
382
- * identity and pointer it read: the grouping `readChains` reads, over every
383
- * record an envelope binds, `event_bytes` or the inline `payload` where no
384
- * `event_bytes` travel, with the identity the SHA-256 of those bytes, exactly
385
- * as the envelope verifier binds `payload_hash`.
266
+ * Every record placed in a chain, in bundle order, with its identity and
267
+ * pointer. Reads `event_bytes`, or the inline `payload` where none travel.
386
268
  */
387
269
  export async function readChainLinks(bundle) {
388
270
  const links = [];
@@ -404,23 +286,15 @@ export async function readChainLinks(bundle) {
404
286
  return links.sort((a, b) => a.order - b.order);
405
287
  }
406
288
  /**
407
- * Follow every chain the bundle's records describe, and report the links that
408
- * do not resolve within it.
409
- *
410
- * Reports NOTHING for an intact chain, and nothing for a chain whose records
411
- * are withheld — a link we could not read is unknown, never an accusation.
412
- * `chains` is what makes that distinguishable from having read nothing at all.
289
+ * Report the links that do not resolve within the bundle. An intact chain, and
290
+ * one whose records are withheld, report nothing: an unreadable link is
291
+ * unknown, never an accusation.
413
292
  */
414
293
  export async function readChains(bundle) {
415
294
  const groups = await groupChains(bundle);
416
295
  const order = (g) => `${g.source} ${g.namespace ?? ""} ${g.chainKey}`;
417
- // Ordered by UTF-16 code unit, NOT `localeCompare`. A chain key is caller
418
- // text, and `localeCompare` with no locale argument answers from the host's
419
- // ICU collation: measured here, it puts "chain-2" AFTER "chain_1" where every
420
- // code-wise comparison puts it before, and it can differ between two
421
- // TypeScript installs, let alone between kernels. The Python reader sorts the
422
- // same key encoded as `utf-16-be`, which is exactly this comparison — so the
423
- // two kernels emit findings in one order (proof#49).
296
+ // By UTF-16 code unit, not `localeCompare` (host-dependent ICU collation).
297
+ // Python sorts the key as `utf-16-be`, so both kernels emit one order.
424
298
  groups.sort((a, b) => {
425
299
  const x = order(a);
426
300
  const y = order(b);
@@ -438,12 +312,9 @@ export async function readChains(bundle) {
438
312
  };
439
313
  }
440
314
  /**
441
- * The house wording for one finding.
442
- *
443
- * Deliberately free of verdict vocabulary. Every sentence says what does not
444
- * resolve IN THIS BUNDLE, because that is the whole of what was checked; none
445
- * of them says failed, invalid, tampered or complete. A surface with its own
446
- * voice should read the fields instead — but it must not upgrade the claim.
315
+ * The house wording for one finding. Says only what does not resolve in this
316
+ * bundle, never failed, tampered or complete; a surface rewording it must not
317
+ * upgrade the claim.
447
318
  */
448
319
  export function renderChainFinding(finding, subject = "this bundle") {
449
320
  const where = finding.source === "adapter"
@@ -451,12 +322,14 @@ export function renderChainFinding(finding, subject = "this bundle") {
451
322
  : `chain ${finding.chainKey}`;
452
323
  switch (finding.kind) {
453
324
  case "chain-link-dangling":
454
- return (`${where}: ${finding.count} of ${finding.of} record(s) point at a predecessor ` +
325
+ return (`${where}: ${finding.count} of ${finding.of} ` +
326
+ `${finding.of === 1 ? "record points" : "records point"} at a predecessor ` +
455
327
  `${subject} does not present (first: ${finding.eventIds[0]}, pointer ` +
456
328
  `${finding.unresolvedPointer})`);
457
329
  case "chain-gap":
458
- return (`${where}: ${finding.count} sequence slot(s) between the records ${subject} presents ` +
459
- `carry no record (first missing seq ${finding.firstMissingSeq}` +
330
+ return (`${where}: ${finding.count} sequence ` +
331
+ `${finding.count === 1 ? "slot" : "slots"} between the records ${subject} presents ` +
332
+ `${finding.count === 1 ? "carries" : "carry"} no record (first missing seq ${finding.firstMissingSeq}` +
460
333
  `${finding.eventIds[0] === undefined ? "" : `, next present: ${finding.eventIds[0]}`})`);
461
334
  case "chain-second-root":
462
335
  return (`${where}: ${finding.count} records begin the chain where one chain has one beginning ` +