@sanning/proof 0.10.0 → 0.11.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,1005 @@
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
+
131
+ // The kernel's one reader for bytes a producer signed: strict UTF-8, no leading
132
+ // byte-order mark, no non-finite literal. `jcs_loads` in
133
+ // `src/sanning_proof/canonicalize.py` is the twin (proof#140 gate 5).
134
+ import { hexToBytes } from "./crypto.js";
135
+ import { jcsParse } from "./verifier.js";
136
+ import { isConformantHandoffRecord, type HandoffRecord } from "./handoff.js";
137
+ import { hasSideContent, inBodyContentOffered, type SideContentMap } from "./side-content.js";
138
+
139
+ /** The declaration schema this reader knows. */
140
+ export const DECLARATION_VERSION = "sanning.declaration/v1";
141
+
142
+ /** The three states a declaration check can reach, from the §3 vocabulary. */
143
+ export type DeclarationVerdict = "verified" | "failed" | "partial";
144
+
145
+ export type DeclarationDeviationKind =
146
+ /** The declaration is written to this version and is not readable as one. */
147
+ | "declaration-malformed"
148
+ /** A record the declaration expects is not in the pack. */
149
+ | "declared-absent"
150
+ /** A record in the pack the declaration never expected. */
151
+ | "undeclared-present"
152
+ /**
153
+ * A record declared `content_withheld` whose bytes the pack supplies INSIDE
154
+ * its own signed body, in `events[].content`.
155
+ */
156
+ | "withheld-but-disclosed"
157
+ /** The pack carries one `event_id` on two or more records. */
158
+ | "duplicate-event-id"
159
+ /** A claimed hand-off names a record the declaration does not declare. */
160
+ | "pair-record-undeclared"
161
+ /**
162
+ * A claimed hand-off names a record that is not a conformant hand-off
163
+ * record of the side it is claimed on (envelope-spec.md 4.1).
164
+ */
165
+ | "pair-record-not-a-handoff";
166
+
167
+ /**
168
+ * Every way a pack and the UNSIGNED content handed in beside it fail to line
169
+ * up, as a closed set. Neither of these is a deviation, and neither of them
170
+ * fails (proof#147, ratified by Phil 2026-09-22).
171
+ *
172
+ * WHY THEY ARE NOT DEVIATIONS. 5.4 grades a pack's declaration partly against
173
+ * content the reader was HANDED, and that content is not signed by anybody.
174
+ * `events[].content` rides inside `body`, so the wrapper signature and
175
+ * `body_hash` cover it and what it says about the holder is the holder's own
176
+ * word. The side-content map is the other channel, and whoever assembles a
177
+ * case fills it in: an accuser could put an intact pack into
178
+ * `pack_declaration_deviates`, which contributed `failed`, WITH NO KEY AT
179
+ * ALL, by dropping the bytes of a record the pack declared `disclosed` or by
180
+ * adding bytes for one it declared `content_withheld`. The pack was intact,
181
+ * its wrapper verified, and its holder had done nothing.
182
+ *
183
+ * This is the mirror of proof#143. There a counterparty makes itself look
184
+ * better by choosing what it hands over; here an accuser makes a counterparty
185
+ * look worse the same way. Both now read as what the READER established, so
186
+ * `failed` stays for what a signature covers.
187
+ *
188
+ * WHAT A HOSTILE HOLDER GAINS FROM THE WEAKER WORD: nothing, because a
189
+ * cheaper move already dominates it. Declaring a record `content_withheld` is
190
+ * legal and grades `verified`, which is strictly better than the `incomplete`
191
+ * these carry.
192
+ */
193
+ export type DeclarationContentKind =
194
+ /**
195
+ * A record declared `disclosed` whose bytes reached this reader from
196
+ * neither channel. ALWAYS content-dependent: 5.4.1 lets those bytes travel
197
+ * beside the pack as `logs/<event_id>.json`, so "no bytes arrived" is a
198
+ * fact about what the READER was handed and never a fact about what the
199
+ * holder supplied. No signature covers the absence of a file nobody signed.
200
+ */
201
+ | "content-dependent-withholding"
202
+ /**
203
+ * A record declared `content_withheld` whose bytes reached this reader ONLY
204
+ * from the unsigned side-content map. The pack's own body supplies none, so
205
+ * the holder committed to no such disclosure. The in-body half of the same
206
+ * rule is the `withheld-but-disclosed` DEVIATION and still fails.
207
+ */
208
+ | "content-dependent-disclosure";
209
+
210
+ /**
211
+ * One way the pack and the unsigned content beside it do not line up. Named
212
+ * like a deviation and weighed like an absence.
213
+ */
214
+ export interface DeclarationContentFinding {
215
+ kind: DeclarationContentKind;
216
+ /** The record this is about. Always one: both kinds are per-record. */
217
+ eventId?: string;
218
+ }
219
+
220
+ /** Everything this check reports that is NOT a deviation, as a closed set. */
221
+ export type DeclarationNoteKind =
222
+ /**
223
+ * Every record declared `disclosed` supplied no bytes, so nothing was
224
+ * selected: the bytes did not travel with the pack.
225
+ */
226
+ | "disclosure-did-not-travel"
227
+ /**
228
+ * A claimed hand-off side whose `event_bytes` the pack withholds. The reader
229
+ * cannot see what the record is either way, so it is neither a conformant
230
+ * hand-off record nor a non-conformant one.
231
+ */
232
+ | "pair-record-withheld";
233
+
234
+ /**
235
+ * One named non-deviation finding, as data.
236
+ *
237
+ * A note never moves the declaration verdict. It exists so the one shape rule
238
+ * 3 deliberately does not fail is still SAID, rather than leaving a reader to
239
+ * notice nothing was reported.
240
+ */
241
+ export interface DeclarationNote {
242
+ kind: DeclarationNoteKind;
243
+ /** How many records the note is about, where it is about a count. */
244
+ count?: number;
245
+ /** The record this is about, where it is about one. */
246
+ eventId?: string;
247
+ /** Which side of a claimed hand-off named it, for `pair-record-withheld`. */
248
+ side?: "sent" | "received";
249
+ /** Which claimed hand-off, by position, for `pair-record-withheld`. */
250
+ pairIndex?: number;
251
+ }
252
+
253
+ /** One deviation, as data. A surface renders its own prose or calls `renderDeviation`. */
254
+ export interface DeclarationDeviation {
255
+ kind: DeclarationDeviationKind;
256
+ /** The record this is about, where it is about one. */
257
+ eventId?: string;
258
+ /** Which side of a claimed hand-off named it, for the two `pair-record-` deviations. */
259
+ side?: "sent" | "received";
260
+ /** Which claimed hand-off, by position, for the two `pair-record-` deviations. */
261
+ pairIndex?: number;
262
+ /** What is wrong with the declaration itself, for `declaration-malformed`. */
263
+ reason?: string;
264
+ }
265
+
266
+ export interface DeclarationCheck {
267
+ verdict: DeclarationVerdict;
268
+ /** Every deviation, in a fixed order. Empty is only a pass when the verdict says so. */
269
+ deviations: DeclarationDeviation[];
270
+ /**
271
+ * Every way the pack and the unsigned content beside it do not line up, in
272
+ * a fixed order. These are NOT deviations: they carry the check to
273
+ * `partial` and the pack to `incomplete`, never to `failed`.
274
+ */
275
+ contentFindings: DeclarationContentFinding[];
276
+ /** Named findings that are NOT deviations, in a fixed order. */
277
+ notes: DeclarationNote[];
278
+ /**
279
+ * Whether the body carried a `declaration` member at all, WHATEVER its
280
+ * shape. A string, an array or a `null` under that key is a claim the holder
281
+ * made and this reader cannot read, which is not the same thing as a pack
282
+ * that claimed nothing: `gradePack` grades the two differently.
283
+ */
284
+ present: boolean;
285
+ /** `event_id`s the declaration expects, in declaration order. */
286
+ declared: string[];
287
+ /** Of those, the ones declared withheld on purpose. */
288
+ declaredWithheld: string[];
289
+ }
290
+
291
+ // ---- Reading the pack --------------------------------------------------------
292
+
293
+ interface DeclarationBundleLike {
294
+ body?: {
295
+ declaration?: unknown;
296
+ events?: {
297
+ envelope?: { event_id?: unknown; public_key?: unknown };
298
+ content?: unknown;
299
+ event_bytes?: unknown;
300
+ }[];
301
+ };
302
+ }
303
+
304
+ /**
305
+ * The record committed inside one `events[]` entry, or `null`.
306
+ *
307
+ * `null` is "this reader cannot read the record", which is not the same as
308
+ * "the record is fine": a claimed hand-off side whose bytes the pack supplied
309
+ * and this reader cannot read is a deviation, because the claim is that it IS
310
+ * a hand-off record and nothing here supports that. It is not the same as "the
311
+ * pack supplied no bytes" either: see `bytesSupplied`.
312
+ *
313
+ * ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. The bytes are
314
+ * `JCS(event record)` (RFC 8785) or they are not a conformant record. Three
315
+ * checks carry that: lowercase-or-uppercase hex in pairs, no leading byte-order
316
+ * mark, and no non-finite literal. The hex is `hexToBytes`, because it and
317
+ * `bytes.fromhex` disagree about embedded whitespace (proof#135); the rest is
318
+ * `jcsParse`, which every site in either kernel that reads signed bytes now
319
+ * shares (proof#140 gate 5). Both halves were a hand-rolled copy in this module
320
+ * until proof#146, and both are the shared reader now: the answer on every
321
+ * input is the same, and there is one fewer place for the rule to drift. The
322
+ * cross-kernel declaration leg and `declaration.test.ts` hold both kernels to
323
+ * the same answer on the same bytes rather than to either built-in.
324
+ */
325
+ function committedRecord(raw: unknown): HandoffRecord | null {
326
+ let parsed: unknown;
327
+ try {
328
+ parsed = jcsParse(hexToBytes(raw as string));
329
+ } catch {
330
+ return null;
331
+ }
332
+ return isObject(parsed) ? (parsed as HandoffRecord) : null;
333
+ }
334
+
335
+ // Which record type each side of a claimed hand-off has to be. A pack that
336
+ // names its receipt as the sent side has mislabeled a record, and the reading
337
+ // layer grades the two types differently.
338
+ const SIDE_EVENT_TYPES = { sent: "handoff.sent", received: "handoff.received" } as const;
339
+
340
+ /**
341
+ * The three readings a claimed hand-off side can get. THREE, not two
342
+ * (proof#137, defect 3). A reader that has only "conformant" and "not
343
+ * conformant" has to put a record whose `event_bytes` the pack withholds into
344
+ * one of them, and both are wrong: `event_bytes` is OPTIONAL and withholding
345
+ * it is minimum disclosure, which the corpus calls a customer's right and not
346
+ * a defect. Grading it "not a conformant hand-off record" made an honest
347
+ * minimum-disclosure pack prove a deviation, and the cheapest way out of that
348
+ * was to stop claiming pairs at all.
349
+ */
350
+ type SideReading = "conformant" | "withheld" | "not-a-handoff";
351
+
352
+ /**
353
+ * How a claimed side reads: conformant, withheld, or not a hand-off record.
354
+ *
355
+ * The definition of conformant is 4.1's own (`isConformantHandoffRecord`),
356
+ * read once and never restated here, plus the record type the side names.
357
+ *
358
+ * WITHHELD IS ITS OWN READING. The pack supplied no `event_bytes`, so this
359
+ * reader cannot see what the record is in either direction. Saying it is not a
360
+ * hand-off record is a claim the reader cannot support, and it punishes the
361
+ * disclosure posture the product sells.
362
+ */
363
+ function sideReading(
364
+ p: {
365
+ eventId: string;
366
+ publicKey: string;
367
+ record: HandoffRecord | null;
368
+ bytesSupplied: boolean;
369
+ },
370
+ side: "sent" | "received",
371
+ ): SideReading {
372
+ if (!p.bytesSupplied) return "withheld";
373
+ if (p.record === null) return "not-a-handoff";
374
+ return isConformantHandoffRecord({ publicKey: p.publicKey, record: p.record, ref: p.eventId }) &&
375
+ p.record.event_type === SIDE_EVENT_TYPES[side]
376
+ ? "conformant"
377
+ : "not-a-handoff";
378
+ }
379
+
380
+ const bodyOf = (bundle: unknown) => (bundle as DeclarationBundleLike | null)?.body;
381
+
382
+ /**
383
+ * One of the pack's own records: the id, and whether the pack supplied its
384
+ * bytes.
385
+ *
386
+ * An event with no readable `event_id` is given a positional name, the same
387
+ * `#n` convention `accountDisclosure` uses, so it is still reported rather
388
+ * than dropped. An unnameable record cannot be declared, so it can only ever
389
+ * be surplus, which is a deviation and is named as one.
390
+ */
391
+ export interface PackRecord {
392
+ eventId: string;
393
+ nameable: boolean;
394
+ /**
395
+ * Whether bytes for this record reached the reader at ALL, from either
396
+ * channel. This is what the disclosure account counts.
397
+ */
398
+ disclosed: boolean;
399
+ /**
400
+ * Whether the bytes came from `events[].content`, INSIDE the body the
401
+ * wrapper signed and `body_hash` commits to. The two channels are kept
402
+ * apart because only this one is the holder's own word: the side-content
403
+ * map is filled in by whoever assembled the case (proof#147).
404
+ */
405
+ disclosedInBody: boolean;
406
+ publicKey: string;
407
+ record: HandoffRecord | null;
408
+ /**
409
+ * Whether the pack SUPPLIED `event_bytes` for this record at all.
410
+ * `event_bytes` is OPTIONAL (evidence-bundle.md 5.1 and 5.3), and
411
+ * withholding it is minimum disclosure rather than a defect, so "the pack
412
+ * supplied bytes this reader cannot read" and "the pack supplied none" are
413
+ * two different readings and never one.
414
+ */
415
+ bytesSupplied: boolean;
416
+ }
417
+
418
+ /**
419
+ * The pack's own records, in bundle order. Exported so the CASE layer
420
+ * (`./case.ts`, anchor#198) reads a pack through the SAME parser that grades
421
+ * it here, rather than through a second one beside it. A validation copied
422
+ * next to the one that enforces it drifts, and a copy that is looser blesses
423
+ * a value the real one then rejects.
424
+ */
425
+ export function packRecords(
426
+ bundle: unknown,
427
+ content: SideContentMap | null | undefined,
428
+ ): PackRecord[] {
429
+ const events = bodyOf(bundle)?.events;
430
+ if (!Array.isArray(events)) return [];
431
+ return events.map((ev, i) => {
432
+ const raw = ev?.envelope?.event_id;
433
+ const nameable = typeof raw === "string" && raw.length > 0;
434
+ const eventId = nameable ? (raw as string) : `#${i + 1}`;
435
+ // One definition of "the holder supplied bytes", in both of the two
436
+ // shapes a pack can supply them: in the body, or beside it. Both are
437
+ // `./side-content.ts`, never restated here.
438
+ const disclosedInBody = inBodyContentOffered(ev?.content);
439
+ const disclosed = disclosedInBody || (nameable && hasSideContent(content, eventId));
440
+ // The key comes from the ENVELOPE and never from inside the record, which
441
+ // is 4.1's whole point: a value that names no key makes the record
442
+ // `malformed` there, so a claimed side carrying one is a deviation here.
443
+ const key = ev?.envelope?.public_key;
444
+ // The family convention for "the holder supplied this hex field": a
445
+ // non-empty string. Anything else, an absent key, a `null` or a number, is
446
+ // the field not travelling, which is minimum disclosure and not a record
447
+ // this reader was handed and could not read.
448
+ const rawBytes = ev?.event_bytes;
449
+ return {
450
+ eventId,
451
+ nameable,
452
+ disclosed,
453
+ disclosedInBody,
454
+ publicKey: typeof key === "string" ? key : "",
455
+ record: committedRecord(rawBytes),
456
+ bytesSupplied: typeof rawBytes === "string" && rawBytes.length > 0,
457
+ };
458
+ });
459
+ }
460
+
461
+ // ---- Reading the declaration -------------------------------------------------
462
+
463
+ const DISCLOSURE_STATES = new Set(["disclosed", "content_withheld"]);
464
+
465
+ // RFC 3339 `date-time` with an uppercase `Z`, the family's convention for an
466
+ // instant. Format only: this is not an ordering check and not a calendar
467
+ // check, and it deliberately does not restate the `signed_at` rule of
468
+ // envelope-spec §2, which grades a different field on a different layer.
469
+ const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
470
+
471
+ const isObject = (v: unknown): v is Record<string, unknown> =>
472
+ typeof v === "object" && v !== null && !Array.isArray(v);
473
+
474
+ const isId = (v: unknown): v is string => typeof v === "string" && v.length > 0;
475
+
476
+ interface ReadDeclaration {
477
+ records: { eventId: string; withheld: boolean }[];
478
+ pairs: { sent: string | null; received: string | null }[];
479
+ }
480
+
481
+ /**
482
+ * Read a declaration written to the version this reader knows, or say what is
483
+ * wrong with it.
484
+ *
485
+ * Every malformation is collected, not the first: a declaration is a claim,
486
+ * and a reader that names one defect in a claim with four sends its holder
487
+ * round the loop four times.
488
+ */
489
+ function readDeclaration(
490
+ declaration: Record<string, unknown>,
491
+ ): { ok: true; read: ReadDeclaration } | { ok: false; reasons: string[] } {
492
+ const reasons: string[] = [];
493
+ const note = (r: string) => reasons.push(r);
494
+
495
+ if (!isId(declaration["case_id"])) note("case_id is not a non-empty string");
496
+
497
+ const agents = declaration["agents"];
498
+ if (!Array.isArray(agents) || agents.length === 0) {
499
+ note("agents is not a non-empty array");
500
+ } else if (!agents.every(isId)) {
501
+ note("agents holds an entry that is not a non-empty string");
502
+ } else if (new Set(agents as string[]).size !== agents.length) {
503
+ note("agents names the same agent twice");
504
+ }
505
+
506
+ const period = declaration["period"];
507
+ if (!isObject(period)) note("period is not an object");
508
+ else if (typeof period["start"] !== "string" || !INSTANT.test(period["start"]))
509
+ note("period.start is not an RFC 3339 instant ending in Z");
510
+ else if (typeof period["end"] !== "string" || !INSTANT.test(period["end"]))
511
+ note("period.end is not an RFC 3339 instant ending in Z");
512
+
513
+ const records: { eventId: string; withheld: boolean }[] = [];
514
+ const raw = declaration["records"];
515
+ if (!Array.isArray(raw)) note("records is not an array");
516
+ else {
517
+ const seen = new Set<string>();
518
+ for (const [i, r] of raw.entries()) {
519
+ if (!isObject(r) || !isId(r["event_id"])) {
520
+ note(`records[${i}] carries no event_id`);
521
+ continue;
522
+ }
523
+ const eventId = r["event_id"] as string;
524
+ const disclosure = r["disclosure"];
525
+ if (typeof disclosure !== "string" || !DISCLOSURE_STATES.has(disclosure)) {
526
+ note(`records[${i}] (${eventId}) has no disclosure of "disclosed" or "content_withheld"`);
527
+ continue;
528
+ }
529
+ if (seen.has(eventId)) {
530
+ note(`records[${i}] declares ${eventId} a second time`);
531
+ continue;
532
+ }
533
+ seen.add(eventId);
534
+ records.push({ eventId, withheld: disclosure === "content_withheld" });
535
+ }
536
+ }
537
+
538
+ const pairs: { sent: string | null; received: string | null }[] = [];
539
+ const rawPairs = declaration["handoffs"];
540
+ if (!Array.isArray(rawPairs)) note("handoffs is not an array");
541
+ else {
542
+ for (const [i, p] of rawPairs.entries()) {
543
+ if (!isObject(p)) {
544
+ note(`handoffs[${i}] is not an object`);
545
+ continue;
546
+ }
547
+ const sent = p["sent_event_id"] ?? null;
548
+ const received = p["received_event_id"] ?? null;
549
+ const legal = (v: unknown) => v === null || isId(v);
550
+ if (!legal(sent) || !legal(received)) {
551
+ note(`handoffs[${i}] names a side that is neither null nor a non-empty string`);
552
+ continue;
553
+ }
554
+ if (sent === null && received === null) {
555
+ note(`handoffs[${i}] claims a hand-off with neither side`);
556
+ continue;
557
+ }
558
+ pairs.push({ sent: sent as string | null, received: received as string | null });
559
+ }
560
+ }
561
+
562
+ return reasons.length > 0 ? { ok: false, reasons } : { ok: true, read: { records, pairs } };
563
+ }
564
+
565
+ // ---- The check ---------------------------------------------------------------
566
+
567
+ const noDeclaration = (): DeclarationCheck => ({
568
+ verdict: "partial",
569
+ deviations: [],
570
+ contentFindings: [],
571
+ notes: [],
572
+ present: false,
573
+ declared: [],
574
+ declaredWithheld: [],
575
+ });
576
+
577
+ /**
578
+ * Check a pack against the declaration inside its body.
579
+ *
580
+ * `content` is the same side-content map `verifyEvidenceBundle` takes, and is
581
+ * optional: a pack that discloses its bytes in the body needs no side input,
582
+ * and a pack that discloses them beside itself passes the map a consumer
583
+ * already built.
584
+ *
585
+ * The verdict is this check's own. It does not restate the bundle verdict and
586
+ * it does not read it: a declaration can match perfectly over a pack whose
587
+ * signatures are forged, and saying so is `gradePack`'s job, which takes both.
588
+ */
589
+ export function checkDeclaration(
590
+ bundle: unknown,
591
+ content?: SideContentMap | null,
592
+ ): DeclarationCheck {
593
+ // `body.declaration` as WRITTEN, not as hoped. The key being absent and the
594
+ // key holding a string, an array or `null` are graded differently and must
595
+ // not collapse into one another: a body with no `declaration` key claimed
596
+ // nothing, which every pack built before §5.4 did, while a body whose
597
+ // `declaration` will not read DID claim something. Reading the second as the
598
+ // first is how a one-field edit to a re-sealed pack graded clean.
599
+ const body = bodyOf(bundle);
600
+ if (!isObject(body) || !("declaration" in body)) return noDeclaration();
601
+ const declaration = (body as Record<string, unknown>)["declaration"];
602
+
603
+ // A declaration this reader cannot interpret as `sanning.declaration/v1` is
604
+ // UNKNOWN and not an accusation: the same posture §4 step 1 takes for an
605
+ // unknown `body_type`, one layer down. `present` says a declaration was
606
+ // THERE, so a surface can tell "nothing was claimed" from "something was
607
+ // claimed in a language I do not speak", and `gradePack` carries the second
608
+ // to `incomplete`.
609
+ //
610
+ // Every shape that lands here is one field away from a valid declared pack
611
+ // the holder can re-seal with its own key: an unknown `declaration_version`,
612
+ // a deleted one, a null one, and a `declaration` that is a string, an array
613
+ // or null. Each of those hides the evidence of a trim inside a signed body,
614
+ // so none of them may reach a clean pack verdict.
615
+ if (!isObject(declaration) || declaration["declaration_version"] !== DECLARATION_VERSION) {
616
+ return { ...noDeclaration(), present: true };
617
+ }
618
+
619
+ const read = readDeclaration(declaration);
620
+ if (!read.ok) {
621
+ return {
622
+ verdict: "failed",
623
+ deviations: read.reasons.map((reason) => ({ kind: "declaration-malformed", reason })),
624
+ contentFindings: [],
625
+ notes: [],
626
+ present: true,
627
+ declared: [],
628
+ declaredWithheld: [],
629
+ };
630
+ }
631
+
632
+ const { records, pairs } = read.read;
633
+ const declared = records.map((r) => r.eventId);
634
+ const declaredSet = new Set(declared);
635
+ const pack = packRecords(bundle, content);
636
+ // FIRST WINS, and the second record under one id is named below. A map built
637
+ // by id is last-write-wins in both languages, so a pack carrying one
638
+ // `event_id` twice had its second record graded by rule 7 and its first by
639
+ // nothing, or the reverse once the order flipped, and neither record was
640
+ // ever reported as surplus (proof#137, defect 2). First in bundle order is
641
+ // the record every other rule grades, and it does not depend on how the
642
+ // holder ordered `events[]`.
643
+ const byId = new Map<string, (typeof pack)[number]>();
644
+ for (const p of pack) {
645
+ if (p.nameable && !byId.has(p.eventId)) byId.set(p.eventId, p);
646
+ }
647
+
648
+ const deviations: DeclarationDeviation[] = [];
649
+
650
+ // Declared and absent. The trimmed hand-over, which is the whole reason a
651
+ // declaration exists: nothing else in the pack can see it.
652
+ for (const r of records) {
653
+ if (!byId.has(r.eventId)) deviations.push({ kind: "declared-absent", eventId: r.eventId });
654
+ }
655
+
656
+ // Present and undeclared, and the pack carrying one id twice.
657
+ //
658
+ // An unnameable record is present-and-undeclared, because a record the
659
+ // declaration could not have named is surplus by construction.
660
+ //
661
+ // A record under an id the pack already carries is named SEPARATELY, in
662
+ // bundle order, and is not also reported as surplus: one defect, one
663
+ // finding. 5.4.1 says declaring an id twice is a malformed declaration and
664
+ // said nothing about the PACK carrying one twice, so a second record under a
665
+ // declared id was graded by no rule at all and was never reported
666
+ // (proof#137, defect 2). A declaration names records by id, so an id that
667
+ // names two records names neither.
668
+ const seenInPack = new Set<string>();
669
+ for (const p of pack) {
670
+ if (p.nameable && seenInPack.has(p.eventId)) {
671
+ deviations.push({ kind: "duplicate-event-id", eventId: p.eventId });
672
+ continue;
673
+ }
674
+ if (p.nameable) seenInPack.add(p.eventId);
675
+ if (!p.nameable || !declaredSet.has(p.eventId)) {
676
+ deviations.push({ kind: "undeclared-present", eventId: p.eventId });
677
+ }
678
+ }
679
+
680
+ // The bytes that did not travel. Over the records declared `disclosed` that
681
+ // the pack actually holds: if NONE of them supplied bytes, no record was
682
+ // selected over another and the honest reading is that the bytes travelled
683
+ // separately, so this is a note and not a deviation. One of them supplying
684
+ // bytes and another not IS a selection, and every shortfall is named.
685
+ const disclosedHeld = records
686
+ .filter((r) => !r.withheld)
687
+ .map((r) => byId.get(r.eventId))
688
+ .filter((p): p is NonNullable<typeof p> => p !== undefined);
689
+ const nothingTravelled = disclosedHeld.length > 0 && !disclosedHeld.some((p) => p.disclosed);
690
+ const notes: DeclarationNote[] = nothingTravelled
691
+ ? [{ kind: "disclosure-did-not-travel", count: disclosedHeld.length }]
692
+ : [];
693
+
694
+ // The two disclosure rules, in declaration order, over the records the pack
695
+ // actually holds: an absent record is already named above, and naming it
696
+ // twice would report one defect as two.
697
+ //
698
+ // EACH OF THEM SPLITS ON WHICH CHANNEL THE BYTES CAME FROM (proof#147,
699
+ // ratified by Phil 2026-09-22). `events[].content` is inside the signed
700
+ // body, so what it says is the holder's own word and a mismatch with the
701
+ // holder's own declaration is a proven deviation. The side-content map is
702
+ // unsigned and is filled in by whoever assembled the case, so a reading
703
+ // that rests on it says what the READER established and not what the holder
704
+ // did. The weaker readings are named, and they carry the check to `partial`
705
+ // and the pack to `incomplete`.
706
+ const contentFindings: DeclarationContentFinding[] = [];
707
+ for (const r of records) {
708
+ const p = byId.get(r.eventId);
709
+ if (p === undefined) continue;
710
+ if (!r.withheld && !p.disclosed) {
711
+ if (nothingTravelled) continue;
712
+ // ALWAYS content-dependent. 5.4.1 lets a record's bytes travel beside
713
+ // the pack, so no pack can prove it supplied them and no reader can
714
+ // prove it did not: an accuser holding a twenty-file drop reaches this
715
+ // reading by deleting one file.
716
+ contentFindings.push({ kind: "content-dependent-withholding", eventId: r.eventId });
717
+ } else if (r.withheld && p.disclosed) {
718
+ if (p.disclosedInBody) {
719
+ deviations.push({ kind: "withheld-but-disclosed", eventId: r.eventId });
720
+ } else {
721
+ contentFindings.push({ kind: "content-dependent-disclosure", eventId: r.eventId });
722
+ }
723
+ }
724
+ }
725
+
726
+ // A claimed hand-off that names a record the declaration does not declare,
727
+ // or names one that is not a conformant hand-off record of that side.
728
+ //
729
+ // The PAIR itself, that the hashes match and the sequence holds, is the case
730
+ // verdict (anchor#198) and is deliberately not checked here. Each SIDE is
731
+ // checked, and that is the belt for 4.1's own repair: a record respelled out
732
+ // of 4.1's scope was unreadable at the record layer and still usable here,
733
+ // because this array carried the claim and checked nothing about it
734
+ // (proof#136). A record declared and absent is already named above and is
735
+ // not named twice.
736
+ //
737
+ // A claimed side whose `event_bytes` the pack WITHHELD is a NAMED NOTE and
738
+ // not a deviation (proof#137, defect 3). `event_bytes` is OPTIONAL, and a
739
+ // holder that withholds it is exercising minimum disclosure, which this
740
+ // corpus calls a customer's right and not a defect. Grading it "not a
741
+ // conformant hand-off record" made an honest pack prove a deviation over a
742
+ // record the reader cannot see in either direction, and the cheapest escape
743
+ // from that was to stop claiming pairs, which is the door proof#137 is
744
+ // about. A withheld side is still not a clean claim: the pack's own
745
+ // disclosure account already carries those absent bytes to `incomplete`.
746
+ for (const [i, pair] of pairs.entries()) {
747
+ for (const side of ["sent", "received"] as const) {
748
+ const id = pair[side];
749
+ if (id === null) continue;
750
+ if (!declaredSet.has(id)) {
751
+ deviations.push({ kind: "pair-record-undeclared", eventId: id, side, pairIndex: i });
752
+ continue;
753
+ }
754
+ const p = byId.get(id);
755
+ if (p === undefined) continue;
756
+ const reading = sideReading(p, side);
757
+ if (reading === "withheld") {
758
+ notes.push({ kind: "pair-record-withheld", eventId: id, side, pairIndex: i });
759
+ } else if (reading === "not-a-handoff") {
760
+ deviations.push({ kind: "pair-record-not-a-handoff", eventId: id, side, pairIndex: i });
761
+ }
762
+ }
763
+ }
764
+
765
+ // A proven deviation wins: it is a mismatch between what the pack claimed
766
+ // and what its own signed body carries, and a failing check always wins.
767
+ // With no deviation and a content-dependent finding, the pack lines up with
768
+ // its claim everywhere a signature reaches and does not line up somewhere
769
+ // only unsigned bytes could settle, which is UNDETERMINED: `partial` is
770
+ // 3's word for that, and it adds none.
771
+ const verdict: DeclarationVerdict =
772
+ deviations.length > 0 ? "failed" : contentFindings.length > 0 ? "partial" : "verified";
773
+
774
+ return {
775
+ verdict,
776
+ deviations,
777
+ contentFindings,
778
+ notes,
779
+ present: true,
780
+ declared,
781
+ declaredWithheld: records.filter((r) => r.withheld).map((r) => r.eventId),
782
+ };
783
+ }
784
+
785
+ // ---- Presentation (optional) -------------------------------------------------
786
+
787
+ /**
788
+ * The house wording for one deviation.
789
+ *
790
+ * Offered, not imposed, exactly as `renderCause` is: what must not vary
791
+ * between surfaces is the verdict and the set of deviations, and those come
792
+ * from `checkDeclaration`. Both kernels return this same text for the same
793
+ * deviation, and `cross-kernel/run_cli.sh` holds the two commands to it.
794
+ */
795
+ /**
796
+ * One line a surface can print beside the pack verdict: what the pack claimed
797
+ * to hold, and whether it does.
798
+ *
799
+ * Both kernels' commands print this sentence, so a reader who runs
800
+ * `sanning-proof verify` and `npx @sanning/proof verify` over one pack reads
801
+ * one sentence rather than two that differ by a word.
802
+ *
803
+ * `partial` is spelled out rather than printed as a word, because "partial" on
804
+ * its own beside a green pack reads as a hedge. What it means is that nobody
805
+ * said what the pack was meant to contain, and a reader deciding whether this
806
+ * is a full hand-over needs that in words.
807
+ */
808
+ /**
809
+ * What a pack's declaration CLAIMS, for a reader one layer out.
810
+ *
811
+ * `checkDeclaration` answers "does this pack agree with its own claim". A case
812
+ * reader (`./case.ts`, anchor#198) needs a different thing from the same
813
+ * object: which records the holder claimed as a hand-off side at all, so that
814
+ * a conformant hand-off record the holder claimed NOWHERE is a named finding
815
+ * rather than a silence. That is the door proof#137 ratified open at this
816
+ * layer: `handoffs[]` MAY be empty, so rule 2 cannot reach a declared record
817
+ * and rule 7 cannot reach an unclaimed one.
818
+ *
819
+ * It is read HERE, from the same two functions `checkDeclaration` reads,
820
+ * rather than restated in the case layer. A second reader of one object drifts
821
+ * from the first, and a looser copy blesses a claim the real one rejects.
822
+ */
823
+ export interface DeclarationClaims {
824
+ /**
825
+ * Whether the body carried a `declaration` member at all, whatever its
826
+ * shape. The same field `DeclarationCheck` carries, and the same meaning.
827
+ */
828
+ present: boolean;
829
+ /**
830
+ * Whether this reader could read the claim as `DECLARATION_VERSION` AND the
831
+ * object satisfies 5.4.1. `false` covers an absent declaration, an unknown
832
+ * version and a malformed object, and a case reader must not read any of the
833
+ * three as "the holder claimed nothing to check".
834
+ */
835
+ readable: boolean;
836
+ /** The case the declaration names, where it names one. */
837
+ caseId: string | null;
838
+ /**
839
+ * Every `event_id` a `handoffs[]` entry names on either side, in declaration
840
+ * order, with no duplicates. Empty where the holder claimed no pair, which
841
+ * is legal and is exactly the shape a case reader has to name.
842
+ */
843
+ claimedSides: string[];
844
+ }
845
+
846
+ /**
847
+ * Read the claim a pack's declaration makes, without grading the pack.
848
+ *
849
+ * Deliberately total: a bundle this reader cannot read comes back with
850
+ * `readable: false` rather than throwing, because a case reader grades what it
851
+ * was handed and a reader that throws on a hostile pack cannot report that the
852
+ * pack is hostile.
853
+ */
854
+ export function declarationClaims(bundle: unknown): DeclarationClaims {
855
+ const none: DeclarationClaims = {
856
+ present: false,
857
+ readable: false,
858
+ caseId: null,
859
+ claimedSides: [],
860
+ };
861
+ const body = bodyOf(bundle);
862
+ if (!isObject(body) || !("declaration" in body)) return none;
863
+ const declaration = (body as Record<string, unknown>)["declaration"];
864
+ if (!isObject(declaration) || declaration["declaration_version"] !== DECLARATION_VERSION) {
865
+ return { ...none, present: true };
866
+ }
867
+ const read = readDeclaration(declaration);
868
+ if (!read.ok) return { ...none, present: true };
869
+
870
+ const claimedSides: string[] = [];
871
+ for (const pair of read.read.pairs) {
872
+ for (const eventId of [pair.sent, pair.received]) {
873
+ if (eventId !== null && !claimedSides.includes(eventId)) claimedSides.push(eventId);
874
+ }
875
+ }
876
+ const caseId = declaration["case_id"];
877
+ return {
878
+ present: true,
879
+ readable: true,
880
+ caseId: typeof caseId === "string" ? caseId : null,
881
+ claimedSides,
882
+ };
883
+ }
884
+
885
+ export function renderDeclarationSummary(check: DeclarationCheck): string {
886
+ if (!check.present) {
887
+ return "this pack does not say what it holds, so whether it is complete is UNKNOWN";
888
+ }
889
+ if (check.verdict === "failed") {
890
+ // READ BEFORE THE CONTENT-FINDINGS BRANCH BELOW. A failing check always
891
+ // wins (section 5.4.3: a pack with one deviation and one content-dependent
892
+ // finding is `failed`, and the finding is still named), so a pack that
893
+ // deviates gets the deviating sentence even when it also carries findings.
894
+ // Ordered the other way round, a pack holding both printed "this pack
895
+ // agrees with its own declaration everywhere its signature reaches" with
896
+ // its own named deviations listed underneath, which is the reading this
897
+ // section exists to prevent: a deviation surfacing as something a reader
898
+ // is left to interpret.
899
+ if (check.contentFindings.length > 0) {
900
+ // BOTH counts, because the two facts are about different records and a
901
+ // reader shown one of them would look in the wrong place.
902
+ return (
903
+ `this pack DEVIATES from its own declaration, in ${check.deviations.length} way(s), ` +
904
+ `and in ${check.contentFindings.length} further way(s) it does not line up with ` +
905
+ "the unsigned content handed in beside it, which anyone could have supplied"
906
+ );
907
+ }
908
+ return `this pack DEVIATES from its own declaration, in ${check.deviations.length} way(s)`;
909
+ }
910
+ if (check.contentFindings.length > 0) {
911
+ // Read before the `partial` branch below, because both reach this verdict
912
+ // and they are two different sentences. This one says what the reader
913
+ // could not establish; the one below says the reader could not read the
914
+ // claim at all.
915
+ return (
916
+ "this pack agrees with its own declaration everywhere its signature reaches, " +
917
+ `and in ${check.contentFindings.length} way(s) it does not line up with the ` +
918
+ "unsigned content handed in beside it, which anyone could have supplied"
919
+ );
920
+ }
921
+ if (check.verdict === "partial") {
922
+ return (
923
+ "this pack says what it holds in a declaration this verifier cannot read, " +
924
+ "so whether it is complete is UNKNOWN"
925
+ );
926
+ }
927
+ return (
928
+ `this pack holds exactly what it declares: ${check.declared.length} record(s), ` +
929
+ `${check.declaredWithheld.length} withheld on purpose`
930
+ );
931
+ }
932
+
933
+ /**
934
+ * The house wording for one note. Offered, not imposed, as `renderDeviation`
935
+ * is.
936
+ */
937
+ export function renderNote(note: DeclarationNote): string {
938
+ switch (note.kind) {
939
+ case "disclosure-did-not-travel":
940
+ return (
941
+ `this pack declares ${note.count} record(s) disclosed and supplies no bytes for ` +
942
+ "any of them: the bytes did not travel with the pack, so their content is UNKNOWN"
943
+ );
944
+ case "pair-record-withheld":
945
+ return (
946
+ `the claimed hand-off at position ${note.pairIndex} names ${note.eventId} on its ` +
947
+ `${note.side} side, whose record bytes this pack withholds, so what that record ` +
948
+ "IS is UNKNOWN"
949
+ );
950
+ }
951
+ }
952
+
953
+ /**
954
+ * The house wording for one content-dependent finding.
955
+ *
956
+ * EVERY SENTENCE HERE NAMES THE CONTENT AND NOT THE HOLDER. That is the whole
957
+ * point of the kind: the bytes are unsigned and whoever assembled the case
958
+ * chose them, so a sentence that reads as an accusation would put the weaker
959
+ * word beside the stronger claim and an auditor would read the claim.
960
+ */
961
+ export function renderContentFinding(finding: DeclarationContentFinding): string {
962
+ switch (finding.kind) {
963
+ case "content-dependent-withholding":
964
+ return (
965
+ `${finding.eventId} is declared disclosed and no bytes for it reached this ` +
966
+ "verifier, from the pack or from beside it, so whether the holder supplied " +
967
+ "them is UNKNOWN"
968
+ );
969
+ case "content-dependent-disclosure":
970
+ return (
971
+ `${finding.eventId} is declared withheld and bytes for it were handed in ` +
972
+ "beside this pack, which the pack does not carry and nobody signed, so " +
973
+ "whether the holder disclosed them is UNKNOWN"
974
+ );
975
+ }
976
+ }
977
+
978
+ export function renderDeviation(deviation: DeclarationDeviation): string {
979
+ switch (deviation.kind) {
980
+ case "declaration-malformed":
981
+ return `the declaration is not readable as ${DECLARATION_VERSION}: ${deviation.reason}`;
982
+ case "declared-absent":
983
+ return `${deviation.eventId} is declared and is NOT in this pack`;
984
+ case "undeclared-present":
985
+ return `${deviation.eventId} is in this pack and is NOT declared`;
986
+ case "withheld-but-disclosed":
987
+ return (
988
+ `${deviation.eventId} is declared withheld and this pack carries its bytes ` +
989
+ "in its own signed body"
990
+ );
991
+ case "duplicate-event-id":
992
+ return `this pack carries ${deviation.eventId} more than once`;
993
+ case "pair-record-undeclared":
994
+ return (
995
+ `the claimed hand-off at position ${deviation.pairIndex} names ` +
996
+ `${deviation.eventId} on its ${deviation.side} side, which the declaration does not declare`
997
+ );
998
+ case "pair-record-not-a-handoff":
999
+ return (
1000
+ `the claimed hand-off at position ${deviation.pairIndex} names ` +
1001
+ `${deviation.eventId} on its ${deviation.side} side, which is not a conformant ` +
1002
+ `${SIDE_EVENT_TYPES[deviation.side ?? "sent"]} record`
1003
+ );
1004
+ }
1005
+ }