@sanning/proof 0.10.0 → 0.11.1

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,674 @@
1
+ // The declaration: what a pack SAYS it holds, checked against what it does.
2
+ //
3
+ // WHY THIS EXISTS.
4
+ // A pack is a selection out of an interval. Every per-record check in this
5
+ // kernel is a check on a record that is PRESENT: signatures, payload bindings,
6
+ // inclusion proofs, content hashes. Drop half the records and every survivor
7
+ // still verifies perfectly, so an auditor holding a pack cannot tell a full
8
+ // hand-over from a trimmed one. `completenessNotes` in `./grading.ts` closes
9
+ // one narrow case of that, where a checkpoint's own signed record commits to a
10
+ // leaf count. Nothing closes it for a hand-over pack built out of a stamp, and
11
+ // nothing at all says which records were withheld ON PURPOSE.
12
+ //
13
+ // The declaration is the pack's own signed statement of what it was meant to
14
+ // contain: which agents, which period, which case, the records expected, which
15
+ // of those are disclosed and which are withheld deliberately, and the hand-off
16
+ // pairs it claims. It rides INSIDE `body`, so the bundle's signature covers it
17
+ // and `body_hash` commits to it: the claim has an author, and re-declaring a
18
+ // different selection later means signing a different pack.
19
+ //
20
+ // ── the rule ────────────────────────────────────────────────────────────────
21
+ //
22
+ // verified the pack holds exactly what the declaration says it holds. A
23
+ // record declared `content_withheld` is in scope for its
24
+ // signature and its commitment only, and its absent bytes are
25
+ // not a deviation, because the declaration said so in advance.
26
+ // failed ANY deviation, and EVERY deviation is named. Declared and
27
+ // absent, present and undeclared, the pack carrying one
28
+ // `event_id` twice, withheld without being declared, disclosed
29
+ // after being declared withheld, or a claimed hand-off naming a
30
+ // record the declaration never declared.
31
+ // partial no declaration, or one the reader cannot interpret as this
32
+ // version. The pack makes no claim this reader can check, so
33
+ // what it was meant to contain is UNKNOWN. Never `verified`,
34
+ // because there is nothing to check against, and never `failed`,
35
+ // because nothing is wrong: absence is not failure.
36
+ //
37
+ // A deviation is a FAILURE and is NAMED. It must never surface as an absence
38
+ // a reader is left to interpret. That is the shape the hand-off record
39
+ // contract caught in review (anchor#189): a check made a condition of pairing
40
+ // turns a deviation into two records that did not pair for no stated reason,
41
+ // and a reader reads a missing thing as an innocent one.
42
+ //
43
+ // ── the bytes that did not travel ────────────────────────────────────
44
+ //
45
+ // One case inside rule 3 is not a selection and is not graded as one. When NO
46
+ // record declared `disclosed` supplied bytes, nothing was selected: either the
47
+ // bytes travelled separately or the reader was handed the JSON alone. A
48
+ // hostile holder gains nothing from that shape, because declaring those
49
+ // records `content_withheld` grades `verified` today and is cheaper, so the
50
+ // only party who reaches it is the honest one who forgot `--logs`. This check
51
+ // emits a NAMED note instead of a deviation, and the pack's missing bytes
52
+ // carry it to `incomplete` through the disclosure account that already counts
53
+ // them.
54
+ //
55
+ // PARTIAL disclosure is a selection and stays a deviation: nineteen of twenty
56
+ // declared-disclosed records supplying bytes and one not is a choice about
57
+ // which record, and it fails.
58
+ //
59
+ // ── the seam ────────────────────────────────────────────────────────────────
60
+ //
61
+ // PURE, like `./grading.ts`: data in, data out, no I/O and no key lifecycle.
62
+ // It reads the bundle it is handed and, optionally, the same side-content map
63
+ // `verifyEvidenceBundle` takes, because "the holder supplied bytes for this
64
+ // record" has exactly one definition in this kernel (`./side-content.ts`) and
65
+ // this module calls it rather than restating it.
66
+ //
67
+ // ── what this check does NOT claim ──────────────────────────────────────────
68
+ //
69
+ // It compares the declaration to the pack. It does not read a record's
70
+ // internals and cross-check `agents`, `period` or `case_id` against them, and
71
+ // it does not verify that a claimed hand-off PAIR is a real pair: the hashes
72
+ // match, the sequence holds, the two sides name each other's keys. Those are
73
+ // the CASE verdict, across packs and across organizations, and they are
74
+ // anchor#198. Doing half of that here would put two implementations of one
75
+ // rule in the stack, which is the defect `./grading.ts` was brought into the
76
+ // kernel to end.
77
+ //
78
+ // It does now check that each SIDE of a claimed hand-off is a conformant
79
+ // hand-off record (proof#136, ratified by Phil 2026-09-22), and it asks
80
+ // `./handoff.ts` rather than restating that rule. `handoffs[]` carried the
81
+ // claim and checked nothing about it, and that is what made an unreadable
82
+ // record useful: a receiving side respelled one code point of `event_type`
83
+ // and one of `event.counterparty_public_key`, which put the record outside
84
+ // envelope-spec.md 4.1 by both of its scope clauses, then declared it and
85
+ // claimed it as the received half. The record layer said nothing and the pack
86
+ // layer said `verified`. A record cannot be unreadable now and usable later.
87
+ //
88
+ // What that does NOT close (proof#137, ratified by Phil 2026-09-22). The
89
+ // sentence deleted from the spec beside this one said the other door was
90
+ // closed too: omit the record from `handoffs[]` and it is a
91
+ // present-and-undeclared record. It is not. `handoffs[]` MAY be empty, so a
92
+ // holder declares the record in `records[]` and claims it nowhere, and then
93
+ // rule 2 cannot reach it because it is declared and rule 7 cannot reach it
94
+ // because it is unclaimed: this check reads `verified`, exactly as it does
95
+ // over the honest pack. The corpus pins that pack as EXPECTED
96
+ // (`declaration/declaration-unclaimed-record-01`). A pack a party assembles
97
+ // about itself is not self-proving about what that party LEFT OUT, because
98
+ // the omission happens before any reader runs; the closure is the case
99
+ // verdict across BOTH parties' packs, anchor#198.
100
+ //
101
+ // ── the three defects the sixth hunt landed on this check (proof#137) ───────
102
+ //
103
+ // Three. The fourth door the hunt found, whether a pack a party assembles
104
+ // about itself can prove what it LEFT OUT, is a founder call and is not
105
+ // answered here.
106
+ //
107
+ // ONE PARSER, NOT TWO BUILT-INS. The two kernels read one `event_bytes`
108
+ // differently, three of the four ways verdict-level on identical bytes:
109
+ // Python's `json.loads` accepts `NaN`, `Infinity` and `-Infinity` and this
110
+ // kernel's `JSON.parse` rejects them, and Python's `.decode("utf-8")` keeps
111
+ // a leading byte-order mark where `TextDecoder` eats it. One pack read
112
+ // `verified` here and `failed[pair-record-not-a-handoff]` there. The rule is
113
+ // now stated rather than delegated: bytes that are not RFC 8785 JSON are not
114
+ // a conformant record, so a leading BOM and each of the three non-finite
115
+ // literals are rejected in BOTH kernels, spelled out in each, exactly as the
116
+ // hex charset already was.
117
+ //
118
+ // ONE `event_id`, TWO RECORDS. Both kernels built a map by id, which is
119
+ // last-write-wins in both languages, so a second record under a declared id
120
+ // was graded by neither rule 2 nor rule 7 and was never reported as surplus.
121
+ // FIRST in bundle order is now the record every other rule grades, and every
122
+ // record after it under the same id is a `duplicate-event-id` deviation.
123
+ //
124
+ // MINIMUM DISCLOSURE IS NOT A DEVIATION. A claimed side whose `event_bytes`
125
+ // the pack withholds was graded "not a conformant hand-off record", so an
126
+ // honest holder exercising the disclosure posture this product sells proved
127
+ // a deviation, and the cheapest escape from that was to stop claiming pairs
128
+ // at all. A withheld side now gets its OWN reading, distinct from both
129
+ // conformant and non-conformant, and is a NAMED NOTE.
130
+ // The kernel's one reader for bytes a producer signed: strict UTF-8, no leading
131
+ // byte-order mark, no non-finite literal. `jcs_loads` in
132
+ // `src/sanning_proof/canonicalize.py` is the twin (proof#140 gate 5).
133
+ import { hexToBytes } from "./crypto.js";
134
+ import { jcsParse } from "./verifier.js";
135
+ import { isConformantHandoffRecord } from "./handoff.js";
136
+ import { hasSideContent, inBodyContentOffered } from "./side-content.js";
137
+ /** The declaration schema this reader knows. */
138
+ export const DECLARATION_VERSION = "sanning.declaration/v1";
139
+ /**
140
+ * The record committed inside one `events[]` entry, or `null`.
141
+ *
142
+ * `null` is "this reader cannot read the record", which is not the same as
143
+ * "the record is fine": a claimed hand-off side whose bytes the pack supplied
144
+ * and this reader cannot read is a deviation, because the claim is that it IS
145
+ * a hand-off record and nothing here supports that. It is not the same as "the
146
+ * pack supplied no bytes" either: see `bytesSupplied`.
147
+ *
148
+ * ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. The bytes are
149
+ * `JCS(event record)` (RFC 8785) or they are not a conformant record. Three
150
+ * checks carry that: lowercase-or-uppercase hex in pairs, no leading byte-order
151
+ * mark, and no non-finite literal. The hex is `hexToBytes`, because it and
152
+ * `bytes.fromhex` disagree about embedded whitespace (proof#135); the rest is
153
+ * `jcsParse`, which every site in either kernel that reads signed bytes now
154
+ * shares (proof#140 gate 5). Both halves were a hand-rolled copy in this module
155
+ * until proof#146, and both are the shared reader now: the answer on every
156
+ * input is the same, and there is one fewer place for the rule to drift. The
157
+ * cross-kernel declaration leg and `declaration.test.ts` hold both kernels to
158
+ * the same answer on the same bytes rather than to either built-in.
159
+ */
160
+ function committedRecord(raw) {
161
+ let parsed;
162
+ try {
163
+ parsed = jcsParse(hexToBytes(raw));
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ return isObject(parsed) ? parsed : null;
169
+ }
170
+ // Which record type each side of a claimed hand-off has to be. A pack that
171
+ // names its receipt as the sent side has mislabeled a record, and the reading
172
+ // layer grades the two types differently.
173
+ const SIDE_EVENT_TYPES = { sent: "handoff.sent", received: "handoff.received" };
174
+ /**
175
+ * How a claimed side reads: conformant, withheld, or not a hand-off record.
176
+ *
177
+ * The definition of conformant is 4.1's own (`isConformantHandoffRecord`),
178
+ * read once and never restated here, plus the record type the side names.
179
+ *
180
+ * WITHHELD IS ITS OWN READING. The pack supplied no `event_bytes`, so this
181
+ * reader cannot see what the record is in either direction. Saying it is not a
182
+ * hand-off record is a claim the reader cannot support, and it punishes the
183
+ * disclosure posture the product sells.
184
+ */
185
+ function sideReading(p, side) {
186
+ if (!p.bytesSupplied)
187
+ return "withheld";
188
+ if (p.record === null)
189
+ return "not-a-handoff";
190
+ return isConformantHandoffRecord({ publicKey: p.publicKey, record: p.record, ref: p.eventId }) &&
191
+ p.record.event_type === SIDE_EVENT_TYPES[side]
192
+ ? "conformant"
193
+ : "not-a-handoff";
194
+ }
195
+ const bodyOf = (bundle) => bundle?.body;
196
+ /**
197
+ * The pack's own records, in bundle order. Exported so the CASE layer
198
+ * (`./case.ts`, anchor#198) reads a pack through the SAME parser that grades
199
+ * it here, rather than through a second one beside it. A validation copied
200
+ * next to the one that enforces it drifts, and a copy that is looser blesses
201
+ * a value the real one then rejects.
202
+ */
203
+ export function packRecords(bundle, content) {
204
+ const events = bodyOf(bundle)?.events;
205
+ if (!Array.isArray(events))
206
+ return [];
207
+ return events.map((ev, i) => {
208
+ const raw = ev?.envelope?.event_id;
209
+ const nameable = typeof raw === "string" && raw.length > 0;
210
+ const eventId = nameable ? raw : `#${i + 1}`;
211
+ // One definition of "the holder supplied bytes", in both of the two
212
+ // shapes a pack can supply them: in the body, or beside it. Both are
213
+ // `./side-content.ts`, never restated here.
214
+ const disclosedInBody = inBodyContentOffered(ev?.content);
215
+ const disclosed = disclosedInBody || (nameable && hasSideContent(content, eventId));
216
+ // The key comes from the ENVELOPE and never from inside the record, which
217
+ // is 4.1's whole point: a value that names no key makes the record
218
+ // `malformed` there, so a claimed side carrying one is a deviation here.
219
+ const key = ev?.envelope?.public_key;
220
+ // The family convention for "the holder supplied this hex field": a
221
+ // non-empty string. Anything else, an absent key, a `null` or a number, is
222
+ // the field not travelling, which is minimum disclosure and not a record
223
+ // this reader was handed and could not read.
224
+ const rawBytes = ev?.event_bytes;
225
+ return {
226
+ eventId,
227
+ nameable,
228
+ disclosed,
229
+ disclosedInBody,
230
+ publicKey: typeof key === "string" ? key : "",
231
+ record: committedRecord(rawBytes),
232
+ bytesSupplied: typeof rawBytes === "string" && rawBytes.length > 0,
233
+ };
234
+ });
235
+ }
236
+ // ---- Reading the declaration -------------------------------------------------
237
+ const DISCLOSURE_STATES = new Set(["disclosed", "content_withheld"]);
238
+ // RFC 3339 `date-time` with an uppercase `Z`, the family's convention for an
239
+ // instant. Format only: this is not an ordering check and not a calendar
240
+ // check, and it deliberately does not restate the `signed_at` rule of
241
+ // envelope-spec §2, which grades a different field on a different layer.
242
+ const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
243
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
244
+ const isId = (v) => typeof v === "string" && v.length > 0;
245
+ /**
246
+ * Read a declaration written to the version this reader knows, or say what is
247
+ * wrong with it.
248
+ *
249
+ * Every malformation is collected, not the first: a declaration is a claim,
250
+ * and a reader that names one defect in a claim with four sends its holder
251
+ * round the loop four times.
252
+ */
253
+ function readDeclaration(declaration) {
254
+ const reasons = [];
255
+ const note = (r) => reasons.push(r);
256
+ if (!isId(declaration["case_id"]))
257
+ note("case_id is not a non-empty string");
258
+ const agents = declaration["agents"];
259
+ if (!Array.isArray(agents) || agents.length === 0) {
260
+ note("agents is not a non-empty array");
261
+ }
262
+ else if (!agents.every(isId)) {
263
+ note("agents holds an entry that is not a non-empty string");
264
+ }
265
+ else if (new Set(agents).size !== agents.length) {
266
+ note("agents names the same agent twice");
267
+ }
268
+ const period = declaration["period"];
269
+ if (!isObject(period))
270
+ note("period is not an object");
271
+ else if (typeof period["start"] !== "string" || !INSTANT.test(period["start"]))
272
+ note("period.start is not an RFC 3339 instant ending in Z");
273
+ else if (typeof period["end"] !== "string" || !INSTANT.test(period["end"]))
274
+ note("period.end is not an RFC 3339 instant ending in Z");
275
+ const records = [];
276
+ const raw = declaration["records"];
277
+ if (!Array.isArray(raw))
278
+ note("records is not an array");
279
+ else {
280
+ const seen = new Set();
281
+ for (const [i, r] of raw.entries()) {
282
+ if (!isObject(r) || !isId(r["event_id"])) {
283
+ note(`records[${i}] carries no event_id`);
284
+ continue;
285
+ }
286
+ const eventId = r["event_id"];
287
+ const disclosure = r["disclosure"];
288
+ if (typeof disclosure !== "string" || !DISCLOSURE_STATES.has(disclosure)) {
289
+ note(`records[${i}] (${eventId}) has no disclosure of "disclosed" or "content_withheld"`);
290
+ continue;
291
+ }
292
+ if (seen.has(eventId)) {
293
+ note(`records[${i}] declares ${eventId} a second time`);
294
+ continue;
295
+ }
296
+ seen.add(eventId);
297
+ records.push({ eventId, withheld: disclosure === "content_withheld" });
298
+ }
299
+ }
300
+ const pairs = [];
301
+ const rawPairs = declaration["handoffs"];
302
+ if (!Array.isArray(rawPairs))
303
+ note("handoffs is not an array");
304
+ else {
305
+ for (const [i, p] of rawPairs.entries()) {
306
+ if (!isObject(p)) {
307
+ note(`handoffs[${i}] is not an object`);
308
+ continue;
309
+ }
310
+ const sent = p["sent_event_id"] ?? null;
311
+ const received = p["received_event_id"] ?? null;
312
+ const legal = (v) => v === null || isId(v);
313
+ if (!legal(sent) || !legal(received)) {
314
+ note(`handoffs[${i}] names a side that is neither null nor a non-empty string`);
315
+ continue;
316
+ }
317
+ if (sent === null && received === null) {
318
+ note(`handoffs[${i}] claims a hand-off with neither side`);
319
+ continue;
320
+ }
321
+ pairs.push({ sent: sent, received: received });
322
+ }
323
+ }
324
+ return reasons.length > 0 ? { ok: false, reasons } : { ok: true, read: { records, pairs } };
325
+ }
326
+ // ---- The check ---------------------------------------------------------------
327
+ const noDeclaration = () => ({
328
+ verdict: "partial",
329
+ deviations: [],
330
+ contentFindings: [],
331
+ notes: [],
332
+ present: false,
333
+ declared: [],
334
+ declaredWithheld: [],
335
+ });
336
+ /**
337
+ * Check a pack against the declaration inside its body.
338
+ *
339
+ * `content` is the same side-content map `verifyEvidenceBundle` takes, and is
340
+ * optional: a pack that discloses its bytes in the body needs no side input,
341
+ * and a pack that discloses them beside itself passes the map a consumer
342
+ * already built.
343
+ *
344
+ * The verdict is this check's own. It does not restate the bundle verdict and
345
+ * it does not read it: a declaration can match perfectly over a pack whose
346
+ * signatures are forged, and saying so is `gradePack`'s job, which takes both.
347
+ */
348
+ export function checkDeclaration(bundle, content) {
349
+ // `body.declaration` as WRITTEN, not as hoped. The key being absent and the
350
+ // key holding a string, an array or `null` are graded differently and must
351
+ // not collapse into one another: a body with no `declaration` key claimed
352
+ // nothing, which every pack built before §5.4 did, while a body whose
353
+ // `declaration` will not read DID claim something. Reading the second as the
354
+ // first is how a one-field edit to a re-sealed pack graded clean.
355
+ const body = bodyOf(bundle);
356
+ if (!isObject(body) || !("declaration" in body))
357
+ return noDeclaration();
358
+ const declaration = body["declaration"];
359
+ // A declaration this reader cannot interpret as `sanning.declaration/v1` is
360
+ // UNKNOWN and not an accusation: the same posture §4 step 1 takes for an
361
+ // unknown `body_type`, one layer down. `present` says a declaration was
362
+ // THERE, so a surface can tell "nothing was claimed" from "something was
363
+ // claimed in a language I do not speak", and `gradePack` carries the second
364
+ // to `incomplete`.
365
+ //
366
+ // Every shape that lands here is one field away from a valid declared pack
367
+ // the holder can re-seal with its own key: an unknown `declaration_version`,
368
+ // a deleted one, a null one, and a `declaration` that is a string, an array
369
+ // or null. Each of those hides the evidence of a trim inside a signed body,
370
+ // so none of them may reach a clean pack verdict.
371
+ if (!isObject(declaration) || declaration["declaration_version"] !== DECLARATION_VERSION) {
372
+ return { ...noDeclaration(), present: true };
373
+ }
374
+ const read = readDeclaration(declaration);
375
+ if (!read.ok) {
376
+ return {
377
+ verdict: "failed",
378
+ deviations: read.reasons.map((reason) => ({ kind: "declaration-malformed", reason })),
379
+ contentFindings: [],
380
+ notes: [],
381
+ present: true,
382
+ declared: [],
383
+ declaredWithheld: [],
384
+ };
385
+ }
386
+ const { records, pairs } = read.read;
387
+ const declared = records.map((r) => r.eventId);
388
+ const declaredSet = new Set(declared);
389
+ const pack = packRecords(bundle, content);
390
+ // FIRST WINS, and the second record under one id is named below. A map built
391
+ // by id is last-write-wins in both languages, so a pack carrying one
392
+ // `event_id` twice had its second record graded by rule 7 and its first by
393
+ // nothing, or the reverse once the order flipped, and neither record was
394
+ // ever reported as surplus (proof#137, defect 2). First in bundle order is
395
+ // the record every other rule grades, and it does not depend on how the
396
+ // holder ordered `events[]`.
397
+ const byId = new Map();
398
+ for (const p of pack) {
399
+ if (p.nameable && !byId.has(p.eventId))
400
+ byId.set(p.eventId, p);
401
+ }
402
+ const deviations = [];
403
+ // Declared and absent. The trimmed hand-over, which is the whole reason a
404
+ // declaration exists: nothing else in the pack can see it.
405
+ for (const r of records) {
406
+ if (!byId.has(r.eventId))
407
+ deviations.push({ kind: "declared-absent", eventId: r.eventId });
408
+ }
409
+ // Present and undeclared, and the pack carrying one id twice.
410
+ //
411
+ // An unnameable record is present-and-undeclared, because a record the
412
+ // declaration could not have named is surplus by construction.
413
+ //
414
+ // A record under an id the pack already carries is named SEPARATELY, in
415
+ // bundle order, and is not also reported as surplus: one defect, one
416
+ // finding. 5.4.1 says declaring an id twice is a malformed declaration and
417
+ // said nothing about the PACK carrying one twice, so a second record under a
418
+ // declared id was graded by no rule at all and was never reported
419
+ // (proof#137, defect 2). A declaration names records by id, so an id that
420
+ // names two records names neither.
421
+ const seenInPack = new Set();
422
+ for (const p of pack) {
423
+ if (p.nameable && seenInPack.has(p.eventId)) {
424
+ deviations.push({ kind: "duplicate-event-id", eventId: p.eventId });
425
+ continue;
426
+ }
427
+ if (p.nameable)
428
+ seenInPack.add(p.eventId);
429
+ if (!p.nameable || !declaredSet.has(p.eventId)) {
430
+ deviations.push({ kind: "undeclared-present", eventId: p.eventId });
431
+ }
432
+ }
433
+ // The bytes that did not travel. Over the records declared `disclosed` that
434
+ // the pack actually holds: if NONE of them supplied bytes, no record was
435
+ // selected over another and the honest reading is that the bytes travelled
436
+ // separately, so this is a note and not a deviation. One of them supplying
437
+ // bytes and another not IS a selection, and every shortfall is named.
438
+ const disclosedHeld = records
439
+ .filter((r) => !r.withheld)
440
+ .map((r) => byId.get(r.eventId))
441
+ .filter((p) => p !== undefined);
442
+ const nothingTravelled = disclosedHeld.length > 0 && !disclosedHeld.some((p) => p.disclosed);
443
+ const notes = nothingTravelled
444
+ ? [{ kind: "disclosure-did-not-travel", count: disclosedHeld.length }]
445
+ : [];
446
+ // The two disclosure rules, in declaration order, over the records the pack
447
+ // actually holds: an absent record is already named above, and naming it
448
+ // twice would report one defect as two.
449
+ //
450
+ // EACH OF THEM SPLITS ON WHICH CHANNEL THE BYTES CAME FROM (proof#147,
451
+ // ratified by Phil 2026-09-22). `events[].content` is inside the signed
452
+ // body, so what it says is the holder's own word and a mismatch with the
453
+ // holder's own declaration is a proven deviation. The side-content map is
454
+ // unsigned and is filled in by whoever assembled the case, so a reading
455
+ // that rests on it says what the READER established and not what the holder
456
+ // did. The weaker readings are named, and they carry the check to `partial`
457
+ // and the pack to `incomplete`.
458
+ const contentFindings = [];
459
+ for (const r of records) {
460
+ const p = byId.get(r.eventId);
461
+ if (p === undefined)
462
+ continue;
463
+ if (!r.withheld && !p.disclosed) {
464
+ if (nothingTravelled)
465
+ continue;
466
+ // ALWAYS content-dependent. 5.4.1 lets a record's bytes travel beside
467
+ // the pack, so no pack can prove it supplied them and no reader can
468
+ // prove it did not: an accuser holding a twenty-file drop reaches this
469
+ // reading by deleting one file.
470
+ contentFindings.push({ kind: "content-dependent-withholding", eventId: r.eventId });
471
+ }
472
+ else if (r.withheld && p.disclosed) {
473
+ if (p.disclosedInBody) {
474
+ deviations.push({ kind: "withheld-but-disclosed", eventId: r.eventId });
475
+ }
476
+ else {
477
+ contentFindings.push({ kind: "content-dependent-disclosure", eventId: r.eventId });
478
+ }
479
+ }
480
+ }
481
+ // A claimed hand-off that names a record the declaration does not declare,
482
+ // or names one that is not a conformant hand-off record of that side.
483
+ //
484
+ // The PAIR itself, that the hashes match and the sequence holds, is the case
485
+ // verdict (anchor#198) and is deliberately not checked here. Each SIDE is
486
+ // checked, and that is the belt for 4.1's own repair: a record respelled out
487
+ // of 4.1's scope was unreadable at the record layer and still usable here,
488
+ // because this array carried the claim and checked nothing about it
489
+ // (proof#136). A record declared and absent is already named above and is
490
+ // not named twice.
491
+ //
492
+ // A claimed side whose `event_bytes` the pack WITHHELD is a NAMED NOTE and
493
+ // not a deviation (proof#137, defect 3). `event_bytes` is OPTIONAL, and a
494
+ // holder that withholds it is exercising minimum disclosure, which this
495
+ // corpus calls a customer's right and not a defect. Grading it "not a
496
+ // conformant hand-off record" made an honest pack prove a deviation over a
497
+ // record the reader cannot see in either direction, and the cheapest escape
498
+ // from that was to stop claiming pairs, which is the door proof#137 is
499
+ // about. A withheld side is still not a clean claim: the pack's own
500
+ // disclosure account already carries those absent bytes to `incomplete`.
501
+ for (const [i, pair] of pairs.entries()) {
502
+ for (const side of ["sent", "received"]) {
503
+ const id = pair[side];
504
+ if (id === null)
505
+ continue;
506
+ if (!declaredSet.has(id)) {
507
+ deviations.push({ kind: "pair-record-undeclared", eventId: id, side, pairIndex: i });
508
+ continue;
509
+ }
510
+ const p = byId.get(id);
511
+ if (p === undefined)
512
+ continue;
513
+ const reading = sideReading(p, side);
514
+ if (reading === "withheld") {
515
+ notes.push({ kind: "pair-record-withheld", eventId: id, side, pairIndex: i });
516
+ }
517
+ else if (reading === "not-a-handoff") {
518
+ deviations.push({ kind: "pair-record-not-a-handoff", eventId: id, side, pairIndex: i });
519
+ }
520
+ }
521
+ }
522
+ // A proven deviation wins: it is a mismatch between what the pack claimed
523
+ // and what its own signed body carries, and a failing check always wins.
524
+ // With no deviation and a content-dependent finding, the pack lines up with
525
+ // its claim everywhere a signature reaches and does not line up somewhere
526
+ // only unsigned bytes could settle, which is UNDETERMINED: `partial` is
527
+ // 3's word for that, and it adds none.
528
+ const verdict = deviations.length > 0 ? "failed" : contentFindings.length > 0 ? "partial" : "verified";
529
+ return {
530
+ verdict,
531
+ deviations,
532
+ contentFindings,
533
+ notes,
534
+ present: true,
535
+ declared,
536
+ declaredWithheld: records.filter((r) => r.withheld).map((r) => r.eventId),
537
+ };
538
+ }
539
+ /**
540
+ * Read the claim a pack's declaration makes, without grading the pack.
541
+ *
542
+ * Deliberately total: a bundle this reader cannot read comes back with
543
+ * `readable: false` rather than throwing, because a case reader grades what it
544
+ * was handed and a reader that throws on a hostile pack cannot report that the
545
+ * pack is hostile.
546
+ */
547
+ export function declarationClaims(bundle) {
548
+ const none = {
549
+ present: false,
550
+ readable: false,
551
+ caseId: null,
552
+ claimedSides: [],
553
+ };
554
+ const body = bodyOf(bundle);
555
+ if (!isObject(body) || !("declaration" in body))
556
+ return none;
557
+ const declaration = body["declaration"];
558
+ if (!isObject(declaration) || declaration["declaration_version"] !== DECLARATION_VERSION) {
559
+ return { ...none, present: true };
560
+ }
561
+ const read = readDeclaration(declaration);
562
+ if (!read.ok)
563
+ return { ...none, present: true };
564
+ const claimedSides = [];
565
+ for (const pair of read.read.pairs) {
566
+ for (const eventId of [pair.sent, pair.received]) {
567
+ if (eventId !== null && !claimedSides.includes(eventId))
568
+ claimedSides.push(eventId);
569
+ }
570
+ }
571
+ const caseId = declaration["case_id"];
572
+ return {
573
+ present: true,
574
+ readable: true,
575
+ caseId: typeof caseId === "string" ? caseId : null,
576
+ claimedSides,
577
+ };
578
+ }
579
+ export function renderDeclarationSummary(check) {
580
+ if (!check.present) {
581
+ return "this pack does not say what it holds, so whether it is complete is UNKNOWN";
582
+ }
583
+ if (check.verdict === "failed") {
584
+ // READ BEFORE THE CONTENT-FINDINGS BRANCH BELOW. A failing check always
585
+ // wins (section 5.4.3: a pack with one deviation and one content-dependent
586
+ // finding is `failed`, and the finding is still named), so a pack that
587
+ // deviates gets the deviating sentence even when it also carries findings.
588
+ // Ordered the other way round, a pack holding both printed "this pack
589
+ // agrees with its own declaration everywhere its signature reaches" with
590
+ // its own named deviations listed underneath, which is the reading this
591
+ // section exists to prevent: a deviation surfacing as something a reader
592
+ // is left to interpret.
593
+ if (check.contentFindings.length > 0) {
594
+ // BOTH counts, because the two facts are about different records and a
595
+ // reader shown one of them would look in the wrong place.
596
+ return (`this pack DEVIATES from its own declaration, in ${check.deviations.length} way(s), ` +
597
+ `and in ${check.contentFindings.length} further way(s) it does not line up with ` +
598
+ "the unsigned content handed in beside it, which anyone could have supplied");
599
+ }
600
+ return `this pack DEVIATES from its own declaration, in ${check.deviations.length} way(s)`;
601
+ }
602
+ if (check.contentFindings.length > 0) {
603
+ // Read before the `partial` branch below, because both reach this verdict
604
+ // and they are two different sentences. This one says what the reader
605
+ // could not establish; the one below says the reader could not read the
606
+ // claim at all.
607
+ return ("this pack agrees with its own declaration everywhere its signature reaches, " +
608
+ `and in ${check.contentFindings.length} way(s) it does not line up with the ` +
609
+ "unsigned content handed in beside it, which anyone could have supplied");
610
+ }
611
+ if (check.verdict === "partial") {
612
+ return ("this pack says what it holds in a declaration this verifier cannot read, " +
613
+ "so whether it is complete is UNKNOWN");
614
+ }
615
+ return (`this pack holds exactly what it declares: ${check.declared.length} record(s), ` +
616
+ `${check.declaredWithheld.length} withheld on purpose`);
617
+ }
618
+ /**
619
+ * The house wording for one note. Offered, not imposed, as `renderDeviation`
620
+ * is.
621
+ */
622
+ export function renderNote(note) {
623
+ switch (note.kind) {
624
+ case "disclosure-did-not-travel":
625
+ return (`this pack declares ${note.count} record(s) disclosed and supplies no bytes for ` +
626
+ "any of them: the bytes did not travel with the pack, so their content is UNKNOWN");
627
+ case "pair-record-withheld":
628
+ return (`the claimed hand-off at position ${note.pairIndex} names ${note.eventId} on its ` +
629
+ `${note.side} side, whose record bytes this pack withholds, so what that record ` +
630
+ "IS is UNKNOWN");
631
+ }
632
+ }
633
+ /**
634
+ * The house wording for one content-dependent finding.
635
+ *
636
+ * EVERY SENTENCE HERE NAMES THE CONTENT AND NOT THE HOLDER. That is the whole
637
+ * point of the kind: the bytes are unsigned and whoever assembled the case
638
+ * chose them, so a sentence that reads as an accusation would put the weaker
639
+ * word beside the stronger claim and an auditor would read the claim.
640
+ */
641
+ export function renderContentFinding(finding) {
642
+ switch (finding.kind) {
643
+ case "content-dependent-withholding":
644
+ return (`${finding.eventId} is declared disclosed and no bytes for it reached this ` +
645
+ "verifier, from the pack or from beside it, so whether the holder supplied " +
646
+ "them is UNKNOWN");
647
+ case "content-dependent-disclosure":
648
+ return (`${finding.eventId} is declared withheld and bytes for it were handed in ` +
649
+ "beside this pack, which the pack does not carry and nobody signed, so " +
650
+ "whether the holder disclosed them is UNKNOWN");
651
+ }
652
+ }
653
+ export function renderDeviation(deviation) {
654
+ switch (deviation.kind) {
655
+ case "declaration-malformed":
656
+ return `the declaration is not readable as ${DECLARATION_VERSION}: ${deviation.reason}`;
657
+ case "declared-absent":
658
+ return `${deviation.eventId} is declared and is NOT in this pack`;
659
+ case "undeclared-present":
660
+ return `${deviation.eventId} is in this pack and is NOT declared`;
661
+ case "withheld-but-disclosed":
662
+ return (`${deviation.eventId} is declared withheld and this pack carries its bytes ` +
663
+ "in its own signed body");
664
+ case "duplicate-event-id":
665
+ return `this pack carries ${deviation.eventId} more than once`;
666
+ case "pair-record-undeclared":
667
+ return (`the claimed hand-off at position ${deviation.pairIndex} names ` +
668
+ `${deviation.eventId} on its ${deviation.side} side, which the declaration does not declare`);
669
+ case "pair-record-not-a-handoff":
670
+ return (`the claimed hand-off at position ${deviation.pairIndex} names ` +
671
+ `${deviation.eventId} on its ${deviation.side} side, which is not a conformant ` +
672
+ `${SIDE_EVENT_TYPES[deviation.side ?? "sent"]} record`);
673
+ }
674
+ }