@sanning/proof 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -92,23 +92,48 @@ A bundle a producer built with disclosure carries the bytes **in the signed body
92
92
  npx @sanning/proof verify trace-bundle.json # in-body content verified inline
93
93
  ```
94
94
 
95
- If the logs travel **alongside** a minimal bundle instead, pass them with `--logs` — a JSON object mapping `event_id` → bytes, where an even-length lowercase-hex string is read as hex and anything else as UTF-8 text:
95
+ If the logs travel **alongside** a minimal bundle instead, pass them with `--logs`. It takes either shape below, and which one you passed is decided by `stat`, never by the extension.
96
+
97
+ **A materialized pack — a DIRECTORY.** `materializeBundle` (in [`@sanning/anchor`](https://github.com/sanning-io/anchor)) writes `bundle.json` + `logs/<event_id>.json`, and each file's **raw bytes are the disclosure**. Point `--logs` at that directory and the pack verifies with nothing of ours in the path:
98
+
99
+ ```bash
100
+ npx @sanning/proof verify pack/bundle.json --logs pack/logs
101
+ ```
102
+
103
+ Nothing is decoded, re-parsed or re-serialised on the way in: the bytes on disk are the bytes hashed against the committed `content_hash` — the same bytes a verify page hashes from the same files. That is the whole point of the directory form. The alternative — reading the directory and hex-encoding it into a map yourself — is a step that has been got wrong twice in shipped code, once by writing the hex *text* into the log files, so `SHA-256(file)` was the hash of the encoding rather than of the bytes and genuine, untampered evidence failed.
104
+
105
+ **A side input — a JSON FILE** mapping `event_id` → bytes, where an even-length lowercase-hex value is read as hex and anything else as UTF-8 text:
96
106
 
97
107
  ```bash
98
108
  npx @sanning/proof verify trace-bundle.json --logs logs.json
99
109
  ```
100
110
 
111
+ Emit hex, not text, into that map: a UTF-8 decode is lossy for a non-text payload, and a log line that happens to *look* like hex would be decoded into something the producer never wrote. `logsSideInput` in `@sanning/anchor` builds the map correctly.
112
+
113
+ The directory form's edge cases, **none of which is a verification failure** — an absent disclosure is not evidence of tampering:
114
+
115
+ | Situation | What happens |
116
+ |---|---|
117
+ | A record with no file | That event's content is **undetermined** (`~`) and reported as undisclosed. Exit code unchanged. |
118
+ | An empty directory | Valid. Discloses nothing, says so, exit code unchanged. |
119
+ | A file whose name is no event id | Read, binds to nothing, and is **named** in a note — a typo'd filename is otherwise indistinguishable from minimal disclosure. |
120
+ | A subdirectory | Skipped, never recursed: the layout is flat. Noted. |
121
+ | Anything not named `<event_id>.json`, or not a regular file | Skipped, noted. |
122
+ | A file we selected but cannot read | **Exit 2.** We were pointed at it, so a permissions or broken-link problem must not read as minimal disclosure. |
123
+
101
124
  Either way each event gains a `contentOk`: **`true`** match · **`false`** mismatch (the event *and* the rollup fail, exit `1`) · **`null`** undetermined — nothing disclosed, or no committed `content_hash` (a withheld record); never a failure, mirroring the payload-binding rule. The CLI prints a `logs ✓/✗/~` mark per event and a `logs: N/M disclosed verified` line.
102
125
 
103
- Programmatically the same side input is `content`, keyed by `event_id` — a `Uint8Array` is used as-is, a `string` is hex:
126
+ Programmatically the same side input is `content`, keyed by `event_id` — a `Uint8Array` is used as-is, a `string` is hex. **Prefer a `Map`:**
104
127
 
105
128
  ```ts
106
129
  const result = await verifyEvidenceBundle(bundle, {
107
- content: { [eventId]: rawLogBytes }, // Uint8Array, or a hex string
130
+ content: new Map([[eventId, rawLogBytes]]), // Uint8Array, or a hex string
108
131
  });
109
132
  result.events[0].contentOk; // true | false | null
110
133
  ```
111
134
 
135
+ A plain object still works and is fully guarded on the way in. But **an `event_id` is producer-chosen**, and on a plain object the names that live on `Object.prototype` are not free: `content["__proto__"] = bytes` does not store anything — it replaces the object's prototype, and the bytes you meant to disclose are lost before the verifier ever sees them. If you are building the map from names you did not choose — a directory listing, a JSON key, an id off the wire — use a `Map` (or `Object.create(null)`). It is also what `disclosedContent` hands back, so its output feeds straight back in.
136
+
112
137
  In-body `events[].content` (signed) takes precedence over the side input; if both are present for an event and disagree, the event fails.
113
138
 
114
139
  ### …and get those raw logs back out (ingest)
@@ -161,6 +186,7 @@ const ok = await verifyInclusion(
161
186
  | `verifyEnvelope(env, optionsOrContentHash?)` | The three load-bearing checks (spec-version registry, payload-hash recompute, Ed25519 over the signed scope) + optional content bind. Pass `{ payloadBytes }` for external-commitment envelopes. Returns `VerificationResult`. |
162
187
  | `verifyEvidenceBundle(bundle, { gateways?, fetchImpl? })` | Verify a signed `sanning.evidence/v1` / `sanning.anchor.trace/v1` bundle: wrapper signature + `body_hash` + every checkpoint + every event's inclusion, offline (optional on-chain re-fetch). Returns `EvidenceBundleResult`. Powers the `npx @sanning/proof verify` CLI. |
163
188
  | `disclosedContent(bundle, { gateways?, content?, fetchImpl? })` | **Ingest:** verify a bundle, then hand back the DECODED raw bytes (`Uint8Array`) of every event whose disclosure bound to its committed `content_hash` — so materializing a received pack never means re-implementing the hex convention. Returns `DisclosedContentResult` (`content` map + per-event `verified` / `not_disclosed` / `undetermined` / `failed`, plus the full `verification`). |
189
+ | `gradePack(result, account, notes)` + `accountDisclosure(bundle, content)` / `completenessNotes(bundle)` / `renderCause(cause)` | **Pack grading:** what a SURFACE may claim about a pack — the bundle plus whatever disclosed bytes travelled with it. Three states, never two: `verified` only when every record's own bytes bound to its own commitment and nothing is missing, unreadable, surplus or omitted; `failed` on a proven mismatch; `incomplete` when a check could not be run. Absent evidence is UNKNOWN, never an accusation. Pure — no I/O; transport stays yours. Conformance table: [`vectors/pack-grading.json`](https://github.com/sanning-io/proof/blob/main/ts/vectors/pack-grading.json), shipped in the tarball. |
164
190
  | `verifyAgentProofBundle(bundle, { gateways?, fetchImpl? })` / `isAgentProofSpec(v)` | Verify the agent producer's `sanning.agent.proof/v1` inclusion bundle. |
165
191
  | `contentHashes(env)` | The content hash(es) an envelope commits to, by event type — the reverse-provenance join keys. |
166
192
  | `specVersionSupported(v)` | Fail-closed accept-check: `sanning.agent/v1` and additive minors (`sanning.agent/v1.<n>`). Unknown majors/profiles are rejected. |
@@ -177,12 +203,13 @@ The primary signature covers `JCS(envelope minus signature minus co_signatures)`
177
203
 
178
204
  ## Why you can trust it (without trusting us)
179
205
 
180
- Three **independent implementations** of this kernel exist — this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and the MIT-licensed Go reference, vendored into this repo under [`cross-kernel/vendor-agent/`](https://github.com/sanning-io/proof/tree/main/cross-kernel/vendor-agent) so anyone can build and run it. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against the shared [`test-vectors` corpus](https://github.com/sanning-io/proof/tree/main/test-vectors) (per-file SHA-256 pins; current cut tag `test-vectors-v2.0`), and the [cross-kernel agreement gate](https://github.com/sanning-io/proof/tree/main/cross-kernel) re-runs the corpus plus adversarial negatives through both, asserting identical verdicts on identical bytes. The Go reference runs as a third leg of that same gate, built at the commit its vendoring pins.
206
+ Three **independent implementations** of this kernel exist — this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and the MIT-licensed Go reference, vendored into this repo under [`cross-kernel/vendor-agent/`](https://github.com/sanning-io/proof/tree/main/cross-kernel/vendor-agent) so anyone can build and run it. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against the shared [`test-vectors` corpus](https://github.com/sanning-io/proof/tree/main/test-vectors) (per-file SHA-256 pins; current cut tag `test-vectors-v3.1`), and the [cross-kernel agreement gate](https://github.com/sanning-io/proof/tree/main/cross-kernel) re-runs the corpus plus adversarial negatives through both, asserting identical verdicts on identical bytes. The Go reference runs as a third leg of that same gate, built at the commit its vendoring pins.
181
207
 
182
208
  That mutual gate is the point: anyone can verify these provenance records with **no Sanning code in the trust path** — write your own verifier against the [public specs](https://github.com/sanning-io/proof/tree/main/specs) and the corpus will tell you if it's conformant.
183
209
 
184
210
  ## What this package deliberately does NOT do
185
211
 
212
+ - **No I/O, ever.** Including in `gradePack`: it takes an account of what was disclosed, it does not go and read a directory. How a pack reached you is yours.
186
213
  - **Single-envelope scope.** Chain walking (`previous_hash`), checkpoint reconciliation, and gateway/transport logic are consumer-layer concerns composed above the kernel.
187
214
  - **Provenance is history, not endorsement.** A verified envelope proves *these bytes have this signed, anchored history* — never "safe," "approved," or "currently in production."
188
215
  - **Key identity is out-of-band.** The kernel proves the signature matches `env.public_key`; binding that key to a real-world identity is yours to establish.
@@ -0,0 +1,99 @@
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.
7
+ */
8
+ export declare const CHAIN_READING_SCOPE = "Chain links are deletion/reorder-evidence for the events present; they are not a completeness proof.";
9
+ /** Where the linkage was read from. */
10
+ export type ChainSource = "record" | "adapter";
11
+ /**
12
+ * 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.
23
+ */
24
+ export declare const ADAPTER_CHAIN_NAMESPACES: readonly {
25
+ namespace: string;
26
+ keyField: string;
27
+ }[];
28
+ export type ChainFindingKind =
29
+ /** A record points at a predecessor no record in this bundle presents. */
30
+ "chain-link-dangling"
31
+ /** A `seq` slot between two records this bundle presents has no record. */
32
+ | "chain-gap"
33
+ /** More than one record claims to begin the chain. */
34
+ | "chain-second-root";
35
+ /**
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.
41
+ */
42
+ export interface ChainFinding {
43
+ kind: ChainFindingKind;
44
+ /** The chain this is about, as the records spell it. */
45
+ chainKey: string;
46
+ /** Whether the linkage came from the record core or an adapter namespace. */
47
+ source: ChainSource;
48
+ /** The adapter metadata namespace, when `source` is `"adapter"`. */
49
+ namespace?: string;
50
+ /** The producing key, since a chain is one head per (public_key, chain_key). */
51
+ publicKey: string;
52
+ /** How many links, slots or roots the finding is about. */
53
+ count: number;
54
+ /** How many records this bundle presents for the chain, framing `count`. */
55
+ of: number;
56
+ /** The affected `event_id`s, so a reader has somewhere to look. */
57
+ eventIds: string[];
58
+ /** The pointer that resolved to no record here (`chain-link-dangling`). */
59
+ unresolvedPointer?: string;
60
+ /** The first sequence slot with no record here (`chain-gap`). */
61
+ firstMissingSeq?: number;
62
+ }
63
+ /**
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.
70
+ */
71
+ export interface ChainSummary {
72
+ chainKey: string;
73
+ source: ChainSource;
74
+ namespace?: string;
75
+ publicKey: string;
76
+ events: number;
77
+ }
78
+ export interface ChainReading {
79
+ chains: ChainSummary[];
80
+ findings: ChainFinding[];
81
+ }
82
+ /**
83
+ * Follow every chain the bundle's records describe, and report the links that
84
+ * do not resolve within it.
85
+ *
86
+ * Reports NOTHING for an intact chain, and nothing for a chain whose records
87
+ * are withheld — a link we could not read is unknown, never an accusation.
88
+ * `chains` is what makes that distinguishable from having read nothing at all.
89
+ */
90
+ export declare function readChains(bundle: unknown): Promise<ChainReading>;
91
+ /**
92
+ * The house wording for one finding.
93
+ *
94
+ * Deliberately free of verdict vocabulary. Every sentence says what does not
95
+ * resolve IN THIS BUNDLE, because that is the whole of what was checked; none
96
+ * of them says failed, invalid, tampered or complete. A surface with its own
97
+ * voice should read the fields instead — but it must not upgrade the claim.
98
+ */
99
+ export declare function renderChainFinding(finding: ChainFinding, subject?: string): string;
package/dist/chain.js ADDED
@@ -0,0 +1,351 @@
1
+ // Chain reading — following the pointer every anchored event carries to the
2
+ // event before it, and reporting what does not line up.
3
+ //
4
+ // WHY THIS LIVES IN THE KERNEL, BESIDE PACK GRADING.
5
+ // `sanning.events/v1` records carry `previous_hash`, and until now nothing we
6
+ // shipped read it. The envelope verifier hashes each event's `record_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).
12
+ //
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.
17
+ //
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-v1.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 here feeds a verdict. The fix is
53
+ // for every adapter to set the record's own `previous_hash` (filed separately
54
+ // against `anchor`); when it lands, the namespace table below stops mattering
55
+ // and can go.
56
+ import { hexToBytes, sha256Hex } from "./crypto.js";
57
+ import { resolveTraceEvents } from "./evidence.js";
58
+ /**
59
+ * The one sentence a surface should print beside these findings, so the reader
60
+ * is never left to infer that "no findings" means "nothing is missing".
61
+ *
62
+ * Lifted from the profile (§5) rather than reworded, because a paraphrase of a
63
+ * scope limit is how a scope limit turns into a claim.
64
+ */
65
+ export const CHAIN_READING_SCOPE = "Chain links are deletion/reorder-evidence for the events present; they are not a completeness proof.";
66
+ /**
67
+ * The adapter metadata namespaces that carry a chain when the record does not.
68
+ *
69
+ * VERIFIED AGAINST `sanning-io/anchor` @ main, 2026-09-07. `run` is NOT a typo
70
+ * for `langchain`: anchor#68 promoted the LangChain linkage out of a
71
+ * framework-named namespace into a shared one, so the same fact has one path in
72
+ * every SDK. A table pinned on the old name would find no chains, report
73
+ * nothing, and look exactly like a clean bundle — the rename trap, in the
74
+ * direction that fails silently.
75
+ *
76
+ * Exported so a test can assert the table itself, and so a consumer can see
77
+ * what this reader knows about rather than guessing.
78
+ */
79
+ export const ADAPTER_CHAIN_NAMESPACES = [
80
+ // @sanning/anchor LangChain callbacks, TS + Python (anchor#68). One chain per
81
+ // root run, so the root run id is the chain.
82
+ { namespace: "run", keyField: "root_run_id" },
83
+ // @sanning/anchor Vercel AI SDK middleware. A flat sequence per correlation key.
84
+ { namespace: "vercel_ai", keyField: "chain_key" },
85
+ // @sanning/anchor Interchange adapter. One chain per session; `seq` is
86
+ // Interchange's own reactor-owned sequence, passed through.
87
+ { namespace: "interchange", keyField: "session_id" },
88
+ ];
89
+ /**
90
+ * The events this bundle is about, unwrapping any evidence-export wrapper to
91
+ * the inline source bundle. DEFERRED to `evidence.ts`, which is the same
92
+ * descent `verifyEvidenceBundle` and `disclosedContent` already use — a second
93
+ * answer to "which events" inside one kernel is a second implementation of one
94
+ * rule, and it is how a reader ends up walking a different set of events than
95
+ * the verifier reported on.
96
+ */
97
+ const eventsOf = (bundle) => resolveTraceEvents(bundle) ?? [];
98
+ /**
99
+ * Decode one event record from its committed bytes.
100
+ *
101
+ * Same discipline as `signedLeafCount`: hex, UTF-8, JSON, and an undecodable
102
+ * record is `null` rather than a throw or a guess. A withheld or unreadable
103
+ * record simply takes no part in a chain — `verifyEvidenceBundle` already
104
+ * reports its binding as undetermined, and inventing a second accusation here
105
+ * would say the same thing twice in a harsher voice.
106
+ */
107
+ function decodeRecord(hex) {
108
+ if (typeof hex !== "string" || hex.length === 0)
109
+ return null;
110
+ try {
111
+ const bytes = hexToBytes(hex);
112
+ const record = JSON.parse(new TextDecoder().decode(bytes));
113
+ if (record === null || typeof record !== "object")
114
+ return null;
115
+ return { record, bytes };
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ const str = (v) => (typeof v === "string" && v.length > 0 ? v : null);
122
+ /**
123
+ * The adapter linkage, if this record carries one this reader knows about.
124
+ *
125
+ * Order is the table's order and the first match wins; a record carrying two
126
+ * adapter namespaces is not a shape any SDK emits.
127
+ */
128
+ function adapterLinkage(record) {
129
+ const metadata = record.metadata;
130
+ if (metadata === null || typeof metadata !== "object")
131
+ return null;
132
+ for (const { namespace, keyField } of ADAPTER_CHAIN_NAMESPACES) {
133
+ const ns = metadata[namespace];
134
+ if (ns === null || typeof ns !== "object")
135
+ continue;
136
+ const fields = ns;
137
+ const chainKey = str(fields[keyField]);
138
+ if (chainKey === null)
139
+ continue;
140
+ // `prev_event_id` is explicitly null at the head of an adapter chain — the
141
+ // adapters write the null rather than omitting the key, because absent and
142
+ // null mean different things to a reader. Absent is treated the same way
143
+ // here: nothing to resolve is a beginning.
144
+ const prev = str(fields.prev_event_id);
145
+ const seqRaw = fields.seq;
146
+ const seq = Number.isInteger(seqRaw) ? seqRaw : null;
147
+ return { namespace, chainKey, prev, seq };
148
+ }
149
+ return null;
150
+ }
151
+ /**
152
+ * Group the bundle's records into chains.
153
+ *
154
+ * Self-ids are DERIVED, never read off the envelope. A record chain's pointer
155
+ * is the previous record's `payload_hash`, and `payload_hash` is an asserted
156
+ * label sitting next to the bytes it claims to be about; matching one asserted
157
+ * label against another is how a relabelled checkpoint once verified cleanly
158
+ * (`evidence.ts`, `checkpoint_ref`). Hashing the bytes we already hold costs
159
+ * nothing and means a relabelled record DANGLES instead of quietly resolving.
160
+ * That is why this function is async.
161
+ */
162
+ async function groupChains(bundle) {
163
+ const groups = new Map();
164
+ const events = eventsOf(bundle);
165
+ for (let order = 0; order < events.length; order++) {
166
+ const entry = events[order];
167
+ const eventId = str(entry?.envelope?.event_id);
168
+ const publicKey = str(entry?.envelope?.public_key);
169
+ // Without a producing key there is no chain identity (§3.2), and without an
170
+ // event id an adapter pointer has nothing to resolve against.
171
+ if (eventId === null || publicKey === null)
172
+ continue;
173
+ const decoded = decodeRecord(entry?.record_bytes);
174
+ if (decoded === null)
175
+ continue;
176
+ const { record, bytes } = decoded;
177
+ // 1. The record's own chain. `context.chain_key` is present IFF the record
178
+ // is chained (profile §5) — which is what keeps every unchained
179
+ // singleton, all of them carrying `previous_hash: "GENESIS"`, out of one
180
+ // enormous fictional chain that would report a second root per pair.
181
+ const recordChainKey = str(record.context?.chain_key);
182
+ let member;
183
+ let key;
184
+ let group;
185
+ if (recordChainKey !== null) {
186
+ const previous = str(record.previous_hash);
187
+ member = {
188
+ eventId,
189
+ selfId: await sha256Hex(bytes),
190
+ prev: previous === null || previous === "GENESIS" ? null : previous,
191
+ seq: null,
192
+ order,
193
+ };
194
+ group = { chainKey: recordChainKey, source: "record", publicKey };
195
+ key = `record ${publicKey} ${recordChainKey}`;
196
+ }
197
+ else {
198
+ // 2. Fallback only. A record that carries its own chain is never read
199
+ // through its adapter metadata: one record must not land in two
200
+ // chains, and the profile's own field is the one a third party can
201
+ // walk without knowing which SDK wrote it.
202
+ const linkage = adapterLinkage(record);
203
+ if (linkage === null)
204
+ continue;
205
+ member = { eventId, selfId: eventId, prev: linkage.prev, seq: linkage.seq, order };
206
+ group = {
207
+ chainKey: linkage.chainKey,
208
+ source: "adapter",
209
+ namespace: linkage.namespace,
210
+ publicKey,
211
+ };
212
+ key = `adapter ${linkage.namespace} ${publicKey} ${linkage.chainKey}`;
213
+ }
214
+ const existing = groups.get(key);
215
+ if (existing === undefined)
216
+ groups.set(key, { ...group, members: [member] });
217
+ else
218
+ existing.members.push(member);
219
+ }
220
+ return [...groups.values()];
221
+ }
222
+ // ---- The rule ----------------------------------------------------------------
223
+ /** By `seq` where both have one, else by position in the bundle. */
224
+ function byChainOrder(a, b) {
225
+ if (a.seq !== null && b.seq !== null && a.seq !== b.seq)
226
+ return a.seq - b.seq;
227
+ return a.order - b.order;
228
+ }
229
+ function findingsFor(group) {
230
+ const { members, chainKey, source, publicKey } = group;
231
+ const of = members.length;
232
+ const base = {
233
+ chainKey,
234
+ source,
235
+ publicKey,
236
+ of,
237
+ ...(group.namespace === undefined ? {} : { namespace: group.namespace }),
238
+ };
239
+ const findings = [];
240
+ const selfIds = new Set(members.map((m) => m.selfId));
241
+ const roots = members.filter((m) => m.prev === null).sort(byChainOrder);
242
+ const dangling = members
243
+ .filter((m) => m.prev !== null && !selfIds.has(m.prev))
244
+ .sort(byChainOrder);
245
+ // A SECOND BEGINNING. One chain has one head per (public_key, chain_key), so
246
+ // two records claiming GENESIS under one key is a fork — the case a link-walk
247
+ // alone reports as CLEAN, because both halves walk back to a legitimate
248
+ // beginning and neither dangles. It is what a crash looks like when the SDK
249
+ // lost its chain head and started over under the same name.
250
+ if (roots.length > 1) {
251
+ findings.push({
252
+ kind: "chain-second-root",
253
+ ...base,
254
+ count: roots.length,
255
+ eventIds: roots.map((m) => m.eventId),
256
+ });
257
+ }
258
+ // A POINTER THAT LEAVES THIS BUNDLE. Exactly one such pointer is expected and
259
+ // is NOT a finding: a bundle is a window onto a chain that may have begun
260
+ // before this export, so the window's earliest record necessarily points at a
261
+ // predecessor nobody handed us. The allowance is spent only when the chain
262
+ // shows no GENESIS root — a chain that already has its beginning here has no
263
+ // window edge left to explain the next one.
264
+ const unexplained = roots.length === 0 ? dangling.slice(1) : dangling;
265
+ if (unexplained.length > 0) {
266
+ const first = unexplained[0];
267
+ findings.push({
268
+ kind: "chain-link-dangling",
269
+ ...base,
270
+ count: unexplained.length,
271
+ eventIds: unexplained.map((m) => m.eventId),
272
+ unresolvedPointer: first.prev,
273
+ });
274
+ }
275
+ // A HOLE IN THE SEQUENCE, where an adapter numbers its events. Only INTERIOR
276
+ // holes count: a window that starts at seq 7 is a window, not a deletion, and
277
+ // reporting its first six absent slots would bury the one that matters. A
278
+ // repeated seq is a real defect but a different one, and the present-set below
279
+ // is deliberately duplicate-safe rather than count-based so it cannot be
280
+ // mistaken for a gap.
281
+ const sequenced = members.filter((m) => m.seq !== null);
282
+ if (sequenced.length === of && of > 0) {
283
+ const present = new Set(sequenced.map((m) => m.seq));
284
+ const low = Math.min(...present);
285
+ const high = Math.max(...present);
286
+ const missing = [];
287
+ for (let s = low; s <= high; s++)
288
+ if (!present.has(s))
289
+ missing.push(s);
290
+ if (missing.length > 0) {
291
+ const after = sequenced.filter((m) => m.seq > missing[0]).sort(byChainOrder)[0];
292
+ findings.push({
293
+ kind: "chain-gap",
294
+ ...base,
295
+ count: missing.length,
296
+ eventIds: after === undefined ? [] : [after.eventId],
297
+ firstMissingSeq: missing[0],
298
+ });
299
+ }
300
+ }
301
+ return findings;
302
+ }
303
+ /**
304
+ * Follow every chain the bundle's records describe, and report the links that
305
+ * do not resolve within it.
306
+ *
307
+ * Reports NOTHING for an intact chain, and nothing for a chain whose records
308
+ * are withheld — a link we could not read is unknown, never an accusation.
309
+ * `chains` is what makes that distinguishable from having read nothing at all.
310
+ */
311
+ export async function readChains(bundle) {
312
+ const groups = await groupChains(bundle);
313
+ const order = (g) => `${g.source} ${g.namespace ?? ""} ${g.chainKey}`;
314
+ groups.sort((a, b) => order(a).localeCompare(order(b)));
315
+ return {
316
+ chains: groups.map((g) => ({
317
+ chainKey: g.chainKey,
318
+ source: g.source,
319
+ ...(g.namespace === undefined ? {} : { namespace: g.namespace }),
320
+ publicKey: g.publicKey,
321
+ events: g.members.length,
322
+ })),
323
+ findings: groups.flatMap(findingsFor),
324
+ };
325
+ }
326
+ /**
327
+ * The house wording for one finding.
328
+ *
329
+ * Deliberately free of verdict vocabulary. Every sentence says what does not
330
+ * resolve IN THIS BUNDLE, because that is the whole of what was checked; none
331
+ * of them says failed, invalid, tampered or complete. A surface with its own
332
+ * voice should read the fields instead — but it must not upgrade the claim.
333
+ */
334
+ export function renderChainFinding(finding, subject = "this bundle") {
335
+ const where = finding.source === "adapter"
336
+ ? `chain ${finding.chainKey} (${finding.namespace} metadata)`
337
+ : `chain ${finding.chainKey}`;
338
+ switch (finding.kind) {
339
+ case "chain-link-dangling":
340
+ return (`${where}: ${finding.count} of ${finding.of} record(s) point at a predecessor ` +
341
+ `${subject} does not present (first: ${finding.eventIds[0]}, pointer ` +
342
+ `${finding.unresolvedPointer})`);
343
+ case "chain-gap":
344
+ return (`${where}: ${finding.count} sequence slot(s) between the records ${subject} presents ` +
345
+ `carry no record (first missing seq ${finding.firstMissingSeq}` +
346
+ `${finding.eventIds[0] === undefined ? "" : `, next present: ${finding.eventIds[0]}`})`);
347
+ case "chain-second-root":
348
+ return (`${where}: ${finding.count} records begin the chain where one chain has one beginning ` +
349
+ `(${finding.eventIds.join(", ")})`);
350
+ }
351
+ }