@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.
@@ -0,0 +1,160 @@
1
+ export type PackVerdict = "verified" | "partial" | "pending" | "not_found" | "mixed" | "failed" | "malformed" | "incomplete";
2
+ /**
3
+ * The minimum this module reads out of a verification result.
4
+ *
5
+ * `EvidenceBundleResult` satisfies it structurally, so a consumer normally
6
+ * passes the kernel's own result straight through. It is stated as a minimum
7
+ * rather than an import so a surface running an OLDER pinned kernel — or one
8
+ * grading a result it adapted from another verifier — can still be held to
9
+ * this rule.
10
+ */
11
+ export interface GradeInput {
12
+ status: string | null | undefined;
13
+ events: {
14
+ ok: boolean;
15
+ contentOk: boolean | null;
16
+ }[];
17
+ }
18
+ /**
19
+ * The account of which records disclosed bytes, and what could not be used.
20
+ *
21
+ * An empty account is NOT a clean bill of health: callers gate on this, never
22
+ * on the absence of mismatches. The old readers wrapped the disclosure read in
23
+ * `try {} catch {}` and fell back to an empty map, so deleting every log turned
24
+ * every content check undetermined, left every event `ok`, and printed a
25
+ * verified verdict over zero hashed bytes.
26
+ */
27
+ export interface DisclosureAccount {
28
+ /** `event_id`s the bundle presents, in bundle order. */
29
+ expected: string[];
30
+ /** `event_id`s whose bytes were supplied. */
31
+ present: string[];
32
+ /** expected, but no bytes were supplied. */
33
+ missing: string[];
34
+ /** supplied, but not usable as evidence for the record it names. */
35
+ unreadable: {
36
+ eventId: string;
37
+ reason: string;
38
+ }[];
39
+ /** supplied bytes no record in the bundle accounts for. */
40
+ extra: string[];
41
+ }
42
+ /**
43
+ * A completeness note: the pack presents a different number of records for a
44
+ * checkpoint than that checkpoint's SIGNED record commits to.
45
+ */
46
+ export type CompletenessNote = {
47
+ checkpointRef: string;
48
+ signed: number;
49
+ presented: number;
50
+ kind: "omitted" | "surplus";
51
+ } | {
52
+ checkpointRef: string;
53
+ signed: null;
54
+ presented: null;
55
+ kind: "unreadable";
56
+ };
57
+ export type PackGradeCauseKind = "records-omitted" | "records-surplus" | "leaf-count-unreadable" | "no-disclosure" | "missing-disclosure" | "unreadable-disclosure" | "unaccounted-disclosure";
58
+ /**
59
+ * One reason a pack is not `verified`, as data.
60
+ *
61
+ * Structured because the two surfaces that used to derive this rule also
62
+ * derived its prose, and prose is the part that drifts hardest and matters
63
+ * least. `renderCause` produces the house wording; a surface with its own
64
+ * voice reads the fields.
65
+ */
66
+ export interface PackGradeCause {
67
+ kind: PackGradeCauseKind;
68
+ /** The checkpoint this is about, for the completeness kinds. */
69
+ checkpointRef?: string;
70
+ /** The count a checkpoint's signed record commits to. */
71
+ signed?: number;
72
+ /** The count the pack actually presents for that checkpoint. */
73
+ presented?: number;
74
+ /** How many records the cause is about (missing, unaccounted, expected). */
75
+ count?: number;
76
+ /** How many records the bundle presents in total, where that frames `count`. */
77
+ of?: number;
78
+ /** The first affected `event_id`, so a reader has somewhere to look. */
79
+ firstEventId?: string;
80
+ /** Why one supplied disclosure could not be used. */
81
+ reason?: string;
82
+ }
83
+ export interface PackGrade {
84
+ verdict: PackVerdict;
85
+ /** Records whose OWN bytes bound to their OWN commitment. Nothing else. */
86
+ recordsVerified: number;
87
+ recordsFailed: number;
88
+ recordsUndetermined: number;
89
+ causes: PackGradeCause[];
90
+ }
91
+ /**
92
+ * Account for the disclosed bytes a pack handed us against the records its
93
+ * bundle presents.
94
+ *
95
+ * `content` is the same map `verifyEvidenceBundle` takes — a plain object or a
96
+ * `Map` — so a caller builds it once and uses it for both. Prefer the `Map`:
97
+ * an `event_id` is producer-chosen, and on a plain object `__proto__` is not a
98
+ * key but a prototype setter, so bytes written under that id never land. A
99
+ * ZERO-BYTE entry is present, not missing: bytes were supplied, the check ran,
100
+ * and the mismatch the verifier reports stands. A truncated log must not be
101
+ * able to launder itself into "unknown".
102
+ *
103
+ * A CONVENIENCE, NOT THE ONLY WAY TO BUILD AN ACCOUNT — and the difference
104
+ * matters for one field. `extra` here can only ever name keys the content map
105
+ * carries, so a transport whose surplus is not representable as a map entry
106
+ * cannot report it through this function: a pack directory holding
107
+ * `logs/notes.txt`, or a second file for a record already accounted for, is
108
+ * invisible to a map built by iterating the bundle's own events. A consumer
109
+ * reading a DIRECTORY should build `DisclosureAccount` itself from the real
110
+ * listing and pass that to `gradePack` — the rule takes an account, never a
111
+ * bundle, precisely so the transport can say things the map cannot.
112
+ */
113
+ export declare function accountDisclosure(bundle: unknown, content: Readonly<Record<string, Uint8Array>> | ReadonlyMap<string, Uint8Array>): DisclosureAccount;
114
+ /** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
115
+ export declare function signedLeafCount(checkpoint: {
116
+ record_bytes?: unknown;
117
+ } | null | undefined): number | null;
118
+ /**
119
+ * Omission — the failure a per-record check structurally cannot see.
120
+ *
121
+ * Drop records from a pack and every SURVIVOR still verifies perfectly:
122
+ * signatures, inclusion proofs, content hashes, all green. The number that
123
+ * exposes it is already in the pack and already signed — each checkpoint's
124
+ * record commits to the `leaf_count` of the tree it anchored. This compares
125
+ * that against how many records the pack presents for that checkpoint.
126
+ *
127
+ * A checkpoint whose signed count cannot be read is `unreadable`: a
128
+ * completeness check we could not run is UNKNOWN, never a pass.
129
+ */
130
+ export declare function completenessNotes(bundle: unknown): CompletenessNote[];
131
+ /**
132
+ * Every reason this pack is not `verified`, in the order a reader should hear
133
+ * them: what is missing from the whole, then what is missing from the parts.
134
+ */
135
+ export declare function gradeCauses(account: DisclosureAccount, notes: readonly CompletenessNote[]): PackGradeCause[];
136
+ /**
137
+ * The verdict a surface may print, and the counts it may print beside it.
138
+ *
139
+ * The invariant, stated so a mutation test can kill it: `verified` is reachable
140
+ * ONLY when every record's supplied bytes bound to its own commitment and
141
+ * nothing is missing, unreadable, surplus or omitted.
142
+ *
143
+ * Precedence: malformed > failed > incomplete > the kernel's own headline.
144
+ * A hard verdict wins — "the bytes are absent" is never reported as failure
145
+ * (§5.1: undetermined, not failed), and a proven mismatch is never softened
146
+ * into absence. The last branch passes the kernel's headline through unchanged:
147
+ * this rule DOWNGRADES a verdict, it never invents one.
148
+ */
149
+ export declare function gradePack(result: GradeInput, account: DisclosureAccount, notes: readonly CompletenessNote[]): PackGrade;
150
+ /**
151
+ * The house wording for one cause.
152
+ *
153
+ * Offered, not imposed: a surface with its own voice reads the fields on
154
+ * `PackGradeCause` instead. What must not vary between surfaces is the verdict
155
+ * and the counts, and those come from `gradePack`.
156
+ *
157
+ * The `subject` names what the pack is from the reader's position — "this pack"
158
+ * on a page someone dropped a file onto, "the pack" in a CLI report.
159
+ */
160
+ export declare function renderCause(cause: PackGradeCause, subject?: string): string;
@@ -0,0 +1,469 @@
1
+ // Pack grading — what a verification SURFACE may claim about a pack it was
2
+ // handed, as distinct from what the wrapper verdict says about a bundle.
3
+ //
4
+ // WHY THIS LIVES IN THE KERNEL.
5
+ // This rule was, until now, derived twice: once in a CLI trust close and once
6
+ // in a browser verify page, in two repos and two languages, held together by a
7
+ // conformance table copied byte-for-byte into both. The table caught an
8
+ // implementation drifting from the rule. Nothing caught the table itself being
9
+ // edited on one side — and the two surfaces had already diverged once, one
10
+ // binding disclosed bytes per record and the other believed to bind by set
11
+ // membership. Two implementations of one verification rule is the defect; a
12
+ // shared table is a mitigation of it.
13
+ //
14
+ // Grading is kernel semantics that leaked into consumers. The rule it encodes
15
+ // is already normative in `specs/evidence-bundle.md`: absent disclosure — and
16
+ // an absent committed `content_hash` — is **undetermined, not failed** (§5.1),
17
+ // a genuine mismatch fails the event and the rollup (§5.1), and no status ever
18
+ // means "safe" (§6). What the surfaces added was a sharper word for the
19
+ // undetermined case and an account of what the pack failed to disclose. Both
20
+ // belong next to the verifier that produced the result.
21
+ //
22
+ // ── the seam ────────────────────────────────────────────────────────────────
23
+ //
24
+ // Everything here is PURE: data in, data out. No `fs`, no `fetch`, no key
25
+ // lifecycle — the kernel's standing boundary (`specs/architecture.md` §3) is
26
+ // unchanged. How disclosed bytes were transported is a consumer concern; a
27
+ // directory read and a browser drop both arrive here as a content map and MUST
28
+ // produce the same verdict. `causes` come back STRUCTURED, so a surface can
29
+ // render its own prose (or call `renderCause` and get the house wording)
30
+ // without the kernel taking on presentation.
31
+ //
32
+ // ── the rule ────────────────────────────────────────────────────────────────
33
+ //
34
+ // Three outcomes where the old code had two:
35
+ //
36
+ // verified every record's own bytes hashed to its OWN committed
37
+ // content_hash, and nothing is missing, unreadable, surplus or
38
+ // omitted
39
+ // failed some record's bytes DISAGREE with what was committed — tamper
40
+ // incomplete some record's bytes are absent or unreadable, or the pack
41
+ // presents a different number of records than a checkpoint
42
+ // signed for — UNKNOWN
43
+ //
44
+ // `incomplete` is the surface's word for undetermined, and it is deliberately
45
+ // NOT `verdict.status` from `evidence-bundle.md` §3. That enum grades a signed
46
+ // bundle; this one grades a PACK — a strictly larger object, the bundle plus
47
+ // whatever bytes travelled beside it. A pack can be incomplete while the bundle
48
+ // it carries is impeccable, which is exactly the case that used to render green
49
+ // over zero hashed bytes. Widening §3 to carry `incomplete` would be a spec
50
+ // change; keeping the two enums distinct is not.
51
+ //
52
+ // `recordsVerified` counts CONTENT-BOUND records only. The kernel's own
53
+ // `event.ok` tolerates undisclosed content by design — a bundle may legitimately
54
+ // withhold a record (minimal disclosure, §5.1) — which is right for the kernel
55
+ // and wrong for a surface whose headline is that the logs were checked.
56
+ // Counting `ok` events is how "26 records verified" got printed over zero
57
+ // hashed bytes.
58
+ //
59
+ // Binding is PER RECORD, never per set. The bytes disclosed for `event_id` are
60
+ // hashed against the commitment inside THAT event's signed record; the verifier
61
+ // does this and this module never re-implements it. A set-membership check
62
+ // ("is this digest anywhere in the bundle?") passes a swap of two records'
63
+ // bytes, and that is the shape of the divergence this module exists to end.
64
+ //
65
+ // The conformance statement of all of the above is `../vectors/pack-grading.json`,
66
+ // which ships in the published tarball so a consumer can diff its own vendored
67
+ // copy against it. If an implementation disagrees with the table, the
68
+ // implementation is wrong.
69
+ import { hexToBytes } from "./crypto.js";
70
+ import { hasSideContent, sideContentKeys } from "./side-content.js";
71
+ /**
72
+ * The checkpoint's join key, in whichever vocabulary the bundle speaks.
73
+ *
74
+ * ── Why this is read from the entry and not from the body_type ──
75
+ *
76
+ * Grading runs over a PACK, which a consumer may hand us in pieces, so the
77
+ * wrapper is not always in scope here. Reading the key off the entry keeps
78
+ * grading honest for both forms with no wrapper lookup. It is deliberately
79
+ * NOT a fallback chain that would accept either spelling on the same entry:
80
+ * an entry carrying both is a mixed bundle, and `verifyEvidenceBundle`
81
+ * rejects it — grading must not quietly grade what the verifier refuses.
82
+ *
83
+ * Returning `null` (rather than a "(no tx_id)" placeholder that compares
84
+ * equal to itself) matters: before this was dual-form, a v2 pack read every
85
+ * checkpoint key as absent, every `presented` count as zero, and reported
86
+ * EVERY checkpoint as "omitted" — a completeness accusation manufactured by
87
+ * a field rename. A key we cannot read is unreadable, never zero.
88
+ */
89
+ function joinKeyOf(o) {
90
+ if (!o)
91
+ return null;
92
+ const hasV2 = typeof o.checkpoint_ref === "string";
93
+ const hasV1 = typeof o.tx_id === "string";
94
+ if (hasV2 === hasV1)
95
+ return null; // neither, or both (a mixed entry)
96
+ return (hasV2 ? o.checkpoint_ref : o.tx_id);
97
+ }
98
+ function inclusionKeyOf(incl) {
99
+ if (!incl)
100
+ return null;
101
+ const hasV2 = typeof incl.checkpoint_ref === "string";
102
+ const hasV1 = typeof incl.checkpoint_tx_id === "string";
103
+ if (hasV2 === hasV1)
104
+ return null;
105
+ return (hasV2 ? incl.checkpoint_ref : incl.checkpoint_tx_id);
106
+ }
107
+ const eventsOf = (bundle) => {
108
+ const e = bundle?.body?.events;
109
+ return Array.isArray(e) ? e : [];
110
+ };
111
+ const checkpointsOf = (bundle) => {
112
+ const c = bundle?.body?.checkpoints;
113
+ return Array.isArray(c) ? c : [];
114
+ };
115
+ // Presence in a caller-built content map — DEFERRED to `side-content.ts`, which
116
+ // is the same rule the VERIFIER applies, because two answers to "is there an
117
+ // entry" inside one kernel is two implementations of one verification rule, and
118
+ // ending that is why this module was brought into the kernel at all.
119
+ //
120
+ // This module used to answer it laxly: own + enumerable, nothing more. The
121
+ // verifier additionally requires a DATA property (an accessor can throw, and a
122
+ // verifier must never raise on adversarial input) holding something other than
123
+ // null/undefined (`{ [id]: undefined }` and `new Map([[id, undefined]])` are
124
+ // how "no entry" is spelled in the two shapes). So on one map this module said
125
+ // PRESENT and the verifier said nothing was offered — and `gradePack` returned
126
+ // `incomplete` with ZERO causes, the very outcome the comment in
127
+ // `accountDisclosure` below says was removed. Now there is one definition and
128
+ // two call sites.
129
+ /**
130
+ * `event_id`s name files on every transport this rule has met. An id that
131
+ * cannot safely name one cannot be bound to disclosed bytes, so it is refused
132
+ * rather than joined into a path.
133
+ *
134
+ * ── WHAT THIS IS AND IS NOT ─────────────────────────────────────────────────
135
+ *
136
+ * **Not a traversal fix, and it must not be described as one.** Nothing in
137
+ * this kernel or in the `proof` CLI joins an `event_id` into a path: the CLI's
138
+ * only join is `join(dirPath, name)` where `name` came from `readdir`, so the
139
+ * id is DERIVED from a real directory entry rather than used to reach one.
140
+ * Claiming otherwise would misrepresent a hardening as a vulnerability fix.
141
+ *
142
+ * It is an **honesty guard**, and it points one way only. The documented pack
143
+ * layout is `logs/<event_id>.json`, so a downstream consumer WILL join these,
144
+ * and an id that cannot name a file on the holder's filesystem is an id whose
145
+ * bytes will never arrive. Saying so — `unreadable`, and therefore
146
+ * `incomplete` — is the truth about that record. Refusing an id can only move
147
+ * a verdict toward UNKNOWN, never toward verified, which is what makes
148
+ * tightening this safe: the failure mode of a rule that is too strict is a
149
+ * conservative verdict, and the failure mode of one that is too lax is a
150
+ * record silently graded on bytes that were never there.
151
+ *
152
+ * ── WHAT IS REFUSED, AND WHY EACH ONE ───────────────────────────────────────
153
+ *
154
+ * charset — anything outside letters, digits, dot, underscore, dash.
155
+ * No separator of any platform survives it.
156
+ * `.` and `..` — not names but path entries. Under the documented `.json`
157
+ * convention they are harmless (`logs/..json` is a file);
158
+ * under a consumer that writes `logs/<event_id>` or uses
159
+ * the id as a directory, `..` IS the parent. The kernel
160
+ * does not know the convention, so it refuses the two ids
161
+ * that are hazardous under any of them.
162
+ * trailing dot — Windows strips it, so `a.` and `a` are one file. Two
163
+ * distinct records collapsing onto one path means one
164
+ * record's bytes overwrite another's, and the verifier then
165
+ * reports a MISMATCH — an accusation manufactured by a
166
+ * filesystem rather than by a tamper.
167
+ * device names — `CON`, `PRN`, `AUX`, `NUL`, `COM0-9`, `LPT0-9`, with or
168
+ * without an extension. On Windows `CON.json` is still the
169
+ * console device: the write appears to succeed and nothing
170
+ * is on disk.
171
+ * length — a POSIX path component is 255 bytes. The charset is
172
+ * ASCII, so characters are bytes; 200 leaves the documented
173
+ * `.json` and room for a prefix a consumer adds, and sits
174
+ * far above any real id (a UUID is 36).
175
+ *
176
+ * ── WHAT IS DELIBERATELY *NOT* REFUSED ──────────────────────────────────────
177
+ *
178
+ * **Case collisions.** `A` and `a` are distinct ids that are one file on
179
+ * macOS and Windows. They stay legal, because banning uppercase would refuse
180
+ * ordinary ids for a hazard the kernel cannot see: this module takes a content
181
+ * MAP, which is case-sensitive and has already resolved the question. The
182
+ * collision happens in the transport, and `accountDisclosure` is explicitly a
183
+ * convenience rather than the only way to build an account — a consumer
184
+ * reading a DIRECTORY builds its own and is the one that can detect it.
185
+ * Pinned as a decision, not left to be rediscovered.
186
+ */
187
+ const SAFE_EVENT_ID = /^[A-Za-z0-9._-]+$/;
188
+ const MAX_EVENT_ID_LENGTH = 200;
189
+ const WINDOWS_DEVICE_NAME = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i;
190
+ /** Why this id cannot name a file, or null if it can. Prose, for a surface. */
191
+ function unsafeEventIdReason(eventId) {
192
+ if (!SAFE_EVENT_ID.test(eventId)) {
193
+ return "only letters, digits, dot, underscore and dash are admitted";
194
+ }
195
+ if (eventId === "." || eventId === "..") {
196
+ return "a relative path entry, not a name";
197
+ }
198
+ if (eventId.endsWith(".")) {
199
+ return "a trailing dot is stripped on Windows, so two ids collide on one file";
200
+ }
201
+ if (WINDOWS_DEVICE_NAME.test(eventId)) {
202
+ return "a reserved Windows device name, which swallows a write with no file on disk";
203
+ }
204
+ if (eventId.length > MAX_EVENT_ID_LENGTH) {
205
+ return `longer than ${MAX_EVENT_ID_LENGTH} characters, which overruns a path component`;
206
+ }
207
+ return null;
208
+ }
209
+ /**
210
+ * Account for the disclosed bytes a pack handed us against the records its
211
+ * bundle presents.
212
+ *
213
+ * `content` is the same map `verifyEvidenceBundle` takes — a plain object or a
214
+ * `Map` — so a caller builds it once and uses it for both. Prefer the `Map`:
215
+ * an `event_id` is producer-chosen, and on a plain object `__proto__` is not a
216
+ * key but a prototype setter, so bytes written under that id never land. A
217
+ * ZERO-BYTE entry is present, not missing: bytes were supplied, the check ran,
218
+ * and the mismatch the verifier reports stands. A truncated log must not be
219
+ * able to launder itself into "unknown".
220
+ *
221
+ * A CONVENIENCE, NOT THE ONLY WAY TO BUILD AN ACCOUNT — and the difference
222
+ * matters for one field. `extra` here can only ever name keys the content map
223
+ * carries, so a transport whose surplus is not representable as a map entry
224
+ * cannot report it through this function: a pack directory holding
225
+ * `logs/notes.txt`, or a second file for a record already accounted for, is
226
+ * invisible to a map built by iterating the bundle's own events. A consumer
227
+ * reading a DIRECTORY should build `DisclosureAccount` itself from the real
228
+ * listing and pass that to `gradePack` — the rule takes an account, never a
229
+ * bundle, precisely so the transport can say things the map cannot.
230
+ */
231
+ export function accountDisclosure(bundle, content) {
232
+ const expected = [];
233
+ const present = [];
234
+ const missing = [];
235
+ const unreadable = [];
236
+ for (const [i, ev] of eventsOf(bundle).entries()) {
237
+ const eventId = ev?.envelope?.event_id;
238
+ if (typeof eventId !== "string" || eventId.length === 0) {
239
+ unreadable.push({ eventId: `#${i + 1}`, reason: "the bundle event carries no event_id" });
240
+ continue;
241
+ }
242
+ expected.push(eventId);
243
+ const unsafe = unsafeEventIdReason(eventId);
244
+ if (unsafe !== null) {
245
+ // The prefix is stable so a surface can group these; the suffix says
246
+ // which rule refused it, because "not a safe file name" alone leaves a
247
+ // holder with no idea what to change.
248
+ unreadable.push({ eventId, reason: `event_id is not a safe file name: ${unsafe}` });
249
+ continue;
250
+ }
251
+ // An OWN entry, NOT `content[eventId] === undefined`. Every key on
252
+ // Object.prototype — `constructor`, `toString`, `valueOf`, `__proto__` —
253
+ // passes SAFE_EVENT_ID (see the regex above: it admits letters, digits,
254
+ // dot, underscore and dash, so every one of them is a legal id here) and
255
+ // resolves to an inherited value, so an indexed lookup reported a record as
256
+ // PRESENT when nothing at all was disclosed for it. That is the module's
257
+ // own founding defect (absent evidence read as good evidence) reappearing
258
+ // one layer down: the verdict stayed `incomplete` because the verifier
259
+ // still had no bytes to bind, but with ZERO causes — a surface saying
260
+ // "incomplete" and unable to say why. `__proto__` is worse still: assigning
261
+ // it on a plain object either silently drops the write or replaces the
262
+ // object's prototype outright, so bytes a holder genuinely supplied never
263
+ // reach the map, and the indexed read then claimed they had.
264
+ if (!hasSideContent(content, eventId))
265
+ missing.push(eventId);
266
+ else
267
+ present.push(eventId);
268
+ }
269
+ const accounted = new Set(expected);
270
+ const extra = sideContentKeys(content).filter((id) => !accounted.has(id));
271
+ return { expected, present, missing, unreadable, extra };
272
+ }
273
+ /** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
274
+ export function signedLeafCount(checkpoint) {
275
+ const hex = checkpoint?.record_bytes;
276
+ if (typeof hex !== "string" || hex.length === 0)
277
+ return null;
278
+ try {
279
+ const record = JSON.parse(new TextDecoder().decode(hexToBytes(hex)));
280
+ const n = record?.event?.leaf_count;
281
+ return Number.isInteger(n) ? n : null;
282
+ }
283
+ catch {
284
+ return null;
285
+ }
286
+ }
287
+ /**
288
+ * Omission — the failure a per-record check structurally cannot see.
289
+ *
290
+ * Drop records from a pack and every SURVIVOR still verifies perfectly:
291
+ * signatures, inclusion proofs, content hashes, all green. The number that
292
+ * exposes it is already in the pack and already signed — each checkpoint's
293
+ * record commits to the `leaf_count` of the tree it anchored. This compares
294
+ * that against how many records the pack presents for that checkpoint.
295
+ *
296
+ * A checkpoint whose signed count cannot be read is `unreadable`: a
297
+ * completeness check we could not run is UNKNOWN, never a pass.
298
+ */
299
+ export function completenessNotes(bundle) {
300
+ const events = eventsOf(bundle);
301
+ const notes = [];
302
+ for (const cp of checkpointsOf(bundle)) {
303
+ const key = joinKeyOf(cp);
304
+ const checkpointRef = key ?? "(no checkpoint_ref)";
305
+ const signed = signedLeafCount(cp);
306
+ // No readable key means we cannot count what was presented against it, so
307
+ // completeness is UNKNOWN — the same answer as an unreadable leaf_count,
308
+ // and never a shortfall.
309
+ if (signed === null || key === null) {
310
+ notes.push({ checkpointRef, signed: null, presented: null, kind: "unreadable" });
311
+ continue;
312
+ }
313
+ const presented = events.filter((e) => inclusionKeyOf(e?.inclusion) === key).length;
314
+ if (presented < signed)
315
+ notes.push({ checkpointRef, signed, presented, kind: "omitted" });
316
+ else if (presented > signed)
317
+ notes.push({ checkpointRef, signed, presented, kind: "surplus" });
318
+ }
319
+ return notes;
320
+ }
321
+ // ---- The rule ----------------------------------------------------------------
322
+ /**
323
+ * Every reason this pack is not `verified`, in the order a reader should hear
324
+ * them: what is missing from the whole, then what is missing from the parts.
325
+ */
326
+ export function gradeCauses(account, notes) {
327
+ const causes = [];
328
+ for (const note of notes) {
329
+ if (note.kind === "omitted") {
330
+ causes.push({
331
+ kind: "records-omitted",
332
+ checkpointRef: note.checkpointRef,
333
+ signed: note.signed,
334
+ presented: note.presented,
335
+ count: note.signed - note.presented,
336
+ });
337
+ }
338
+ else if (note.kind === "surplus") {
339
+ causes.push({
340
+ kind: "records-surplus",
341
+ checkpointRef: note.checkpointRef,
342
+ signed: note.signed,
343
+ presented: note.presented,
344
+ count: note.presented - note.signed,
345
+ });
346
+ }
347
+ else {
348
+ causes.push({ kind: "leaf-count-unreadable", checkpointRef: note.checkpointRef });
349
+ }
350
+ }
351
+ // "Nothing at all was disclosed" is reported once, as itself. It is the
352
+ // regression that rendered a green banner over zero hashed bytes, and a
353
+ // reader deserves to be told the pack disclosed nothing rather than counting
354
+ // N absences.
355
+ if (account.expected.length > 0 && account.present.length === 0 && account.unreadable.length === 0) {
356
+ causes.push({ kind: "no-disclosure", count: account.expected.length, of: account.expected.length });
357
+ }
358
+ else if (account.missing.length > 0) {
359
+ causes.push({
360
+ kind: "missing-disclosure",
361
+ count: account.missing.length,
362
+ of: account.expected.length,
363
+ ...(account.missing[0] === undefined ? {} : { firstEventId: account.missing[0] }),
364
+ });
365
+ }
366
+ for (const u of account.unreadable) {
367
+ causes.push({ kind: "unreadable-disclosure", firstEventId: u.eventId, reason: u.reason });
368
+ }
369
+ if (account.extra.length > 0) {
370
+ causes.push({
371
+ kind: "unaccounted-disclosure",
372
+ count: account.extra.length,
373
+ ...(account.extra[0] === undefined ? {} : { firstEventId: account.extra[0] }),
374
+ });
375
+ }
376
+ return causes;
377
+ }
378
+ /**
379
+ * The kernel headline this rule is willing to repeat, as an ALLOW-LIST.
380
+ *
381
+ * `GradeInput.status` is deliberately `string` so a surface can grade a result
382
+ * from a verifier this kernel did not write. The cost of that was a hole: the
383
+ * status used to be cast straight to `PackVerdict`, so `gradePack` would hand
384
+ * back whatever it was given — `"mixed"` and `"not_found"` (legal, if
385
+ * unexpected), but equally `"banana"`, `""` and `"SAFE"`. A surface that trusts
386
+ * the type and renders the verdict verbatim would then print the one word
387
+ * `evidence-bundle.md` §6 forbids, over a pack this rule never graded.
388
+ *
389
+ * So: the six states of the §3 vocabulary pass through, and ANYTHING ELSE is
390
+ * `incomplete`. A verdict we cannot interpret is a check we could not run, and
391
+ * the answer to that is UNKNOWN — never a pass, and never an accusation.
392
+ * (`failed` and `malformed` are handled before this is reached.)
393
+ */
394
+ const PASS_THROUGH = new Set([
395
+ "verified",
396
+ "partial",
397
+ "pending",
398
+ "not_found",
399
+ "mixed",
400
+ ]);
401
+ function passThrough(status) {
402
+ return typeof status === "string" && PASS_THROUGH.has(status)
403
+ ? status
404
+ : "incomplete";
405
+ }
406
+ /**
407
+ * The verdict a surface may print, and the counts it may print beside it.
408
+ *
409
+ * The invariant, stated so a mutation test can kill it: `verified` is reachable
410
+ * ONLY when every record's supplied bytes bound to its own commitment and
411
+ * nothing is missing, unreadable, surplus or omitted.
412
+ *
413
+ * Precedence: malformed > failed > incomplete > the kernel's own headline.
414
+ * A hard verdict wins — "the bytes are absent" is never reported as failure
415
+ * (§5.1: undetermined, not failed), and a proven mismatch is never softened
416
+ * into absence. The last branch passes the kernel's headline through unchanged:
417
+ * this rule DOWNGRADES a verdict, it never invents one.
418
+ */
419
+ export function gradePack(result, account, notes) {
420
+ const events = Array.isArray(result?.events) ? result.events : [];
421
+ const recordsVerified = events.filter((e) => e.ok && e.contentOk === true).length;
422
+ const recordsFailed = events.filter((e) => e.ok === false || e.contentOk === false).length;
423
+ const recordsUndetermined = events.length - recordsVerified - recordsFailed;
424
+ const causes = gradeCauses(account, notes);
425
+ let verdict;
426
+ if (result?.status === "malformed")
427
+ verdict = "malformed";
428
+ else if (result?.status === "failed" || recordsFailed > 0)
429
+ verdict = "failed";
430
+ else if (recordsUndetermined > 0 || causes.length > 0)
431
+ verdict = "incomplete";
432
+ else
433
+ verdict = passThrough(result?.status);
434
+ return { verdict, recordsVerified, recordsFailed, recordsUndetermined, causes };
435
+ }
436
+ // ---- Presentation (optional) -------------------------------------------------
437
+ /**
438
+ * The house wording for one cause.
439
+ *
440
+ * Offered, not imposed: a surface with its own voice reads the fields on
441
+ * `PackGradeCause` instead. What must not vary between surfaces is the verdict
442
+ * and the counts, and those come from `gradePack`.
443
+ *
444
+ * The `subject` names what the pack is from the reader's position — "this pack"
445
+ * on a page someone dropped a file onto, "the pack" in a CLI report.
446
+ */
447
+ export function renderCause(cause, subject = "this pack") {
448
+ switch (cause.kind) {
449
+ case "records-omitted":
450
+ return (`checkpoint ${cause.checkpointRef} commits to ${cause.signed} record(s); ${subject} presents ` +
451
+ `${cause.presented} — ${cause.count} record(s) OMITTED`);
452
+ case "records-surplus":
453
+ return (`checkpoint ${cause.checkpointRef} commits to ${cause.signed} record(s); ${subject} presents ` +
454
+ `${cause.presented} — more records than the signed tree accounts for`);
455
+ case "leaf-count-unreadable":
456
+ return `checkpoint ${cause.checkpointRef} has no readable signed leaf_count — completeness is UNKNOWN`;
457
+ case "no-disclosure":
458
+ return (`no record bytes were disclosed — content binding is UNKNOWN for all ${cause.count} ` +
459
+ `record(s), not verified`);
460
+ case "missing-disclosure":
461
+ return (`${cause.count} of ${cause.of} record(s) disclose no bytes (first: ${cause.firstEventId}) — ` +
462
+ `content binding is UNKNOWN, not verified`);
463
+ case "unreadable-disclosure":
464
+ return `the disclosure for ${cause.firstEventId} is unreadable: ${cause.reason}`;
465
+ case "unaccounted-disclosure":
466
+ return (`${cause.count} disclosed file(s) are not accounted for by any record in this bundle ` +
467
+ `(first: ${cause.firstEventId})`);
468
+ }
469
+ }
package/dist/index.d.ts CHANGED
@@ -2,8 +2,12 @@ export { ACCEPTED_SPEC_MAJORS, contentHashes, jcs, specVersionSupported, verifyE
2
2
  export { bytesToHex, deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
3
3
  export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle.js";
4
4
  export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions } from "./types.js";
5
- export { ANCHOR_TRACE_BODY_TYPE, EXPORT_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
6
- export type { AnchorTraceBody, AttestationPayload, AttestationRecord, AttestationResult, CheckpointResult, ComposeVerdictResult, DisclosedContentResult, DisclosedEventContent, DisclosureStatus, EvidenceBundle, EvidenceBundleResult, EvidenceIssuer, EvidenceStatus, EvidenceVerdict, EventResult, ExportBody, ExportResult, OnChainOutcome, OnChainResult, PerGatewayOutcome, TraceCheckpoint, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
5
+ export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
6
+ export type { AnchorTraceBody, AttestationPayload, AttestationRecord, AttestationResult, CheckpointResult, ComposeVerdictResult, DisclosedContentResult, DisclosedEventContent, DisclosureStatus, EvidenceBundle, EvidenceBundleResult, EvidenceIssuer, EvidenceStatus, EvidenceVerdict, EventResult, ExportBody, ExportResult, OnChainOutcome, OnChainResult, PerGatewayOutcome, AnchorRef, StampBody, StampEntry, StampResult, StampSubtree, StampSubtreeResult, StampTraceBody, TraceCheckpoint, TraceCheckpointV1, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
7
+ export { accountDisclosure, completenessNotes, gradeCauses, gradePack, renderCause, signedLeafCount, } from "./grading.js";
8
+ export type { CompletenessNote, DisclosureAccount, GradeInput, PackGrade, PackGradeCause, PackGradeCauseKind, PackVerdict, } from "./grading.js";
9
+ export { ADAPTER_CHAIN_NAMESPACES, CHAIN_READING_SCOPE, readChains, renderChainFinding, } from "./chain.js";
10
+ export type { ChainFinding, ChainFindingKind, ChainReading, ChainSource, ChainSummary, } from "./chain.js";
7
11
  export { attestationSignatureToHex, composeExport } from "./compose.js";
8
12
  export type { ComposeExportOptions, ComposeExportResult, ExporterKey } from "./compose.js";
9
13
  export { AGENT_PROOF_SPEC_PREFIX, isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";