@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.
package/src/verifier.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import canonicalize from "canonicalize";
11
11
 
12
- import { ed25519Verify, sha256Hex, utf8 } from "./crypto.js";
12
+ import { ed25519Verify, isHexField, sha256Hex, utf8 } from "./crypto.js";
13
13
  import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from "./types.js";
14
14
 
15
15
  // Fail-closed accepted-profile registry (envelope-spec §2): exactly the
@@ -191,6 +191,82 @@ export function jcs(value: unknown): string {
191
191
  return canonical;
192
192
  }
193
193
 
194
+ // The byte-order mark, as a CHARACTER. RFC 8785 output never begins with
195
+ // U+FEFF, so bytes that do are not conformant whatever a JSON reader makes of
196
+ // them.
197
+ const JCS_BOM = "";
198
+
199
+ /**
200
+ * Read bytes that claim to be `jcs(value)` (RFC 8785), strictly. The Python
201
+ * twin is `jcs_loads` in `src/sanning_proof/canonicalize.py`, and every site in
202
+ * either kernel that reads bytes a producer signed goes through one of the two.
203
+ *
204
+ * ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. Four checks
205
+ * carry it, none of them left to a decoder's defaults: strict UTF-8, no leading
206
+ * byte-order mark, no non-finite literal (this kernel's `JSON.parse` rejects
207
+ * `NaN`, `Infinity` and `-Infinity` natively, and Python names them in a
208
+ * `parse_constant` hook), and no unpaired surrogate in the parsed value.
209
+ *
210
+ * THE FOURTH CHECK IS NOT THE FIRST ONE TWICE. The strict decode refuses a
211
+ * surrogate written as raw bytes; it cannot see one written as the ASCII
212
+ * escape `\ud800`, which `JSON.parse` turns into a lone code unit AFTER the
213
+ * decode has passed. `"c\ud800x"` is pure ASCII on the wire, so this reader
214
+ * accepted it while its own text above said it does not, and one poisoned
215
+ * record then deleted the Python kernel's whole chain reading (the gate on
216
+ * proof#154). Both spellings are the same malformed record, and both are
217
+ * refused here.
218
+ *
219
+ * `new TextDecoder()` alone is none of that. It replaces every invalid sequence
220
+ * with U+FFFD and strips a BOM. Python's `json.loads(<bytes>)` is not it
221
+ * either, in the opposite direction: CPython sniffs the encoding and decodes
222
+ * with `errors="surrogatepass"`, so it strips a UTF-8 BOM, accepts a UTF-16 or
223
+ * UTF-32 body, and passes a lone surrogate and a CESU-8 pair through. Both
224
+ * defaults have shipped a split verdict on one pack: a raw `0x80` inside a
225
+ * stamp body read `verified` here and `failed` there (gate 4), and then a
226
+ * `BOM + JCS(record)` checkpoint read `verified` there and `mixed` here (gate
227
+ * 5), both on proof#140. JCS is defined over UTF-8 and I-JSON (RFC 7493)
228
+ * forbids a lone surrogate, so each of those inputs is MALFORMED rather than
229
+ * another spelling of the same record.
230
+ *
231
+ * Throws for anything that is not conformant; every call site treats a throw as
232
+ * "this reader cannot read these bytes" and names the check that failed.
233
+ */
234
+ export function jcsParse(bytes: Uint8Array): unknown {
235
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
236
+ if (text.startsWith(JCS_BOM)) throw new Error("jcsParse: leading byte-order mark");
237
+ const value: unknown = JSON.parse(text);
238
+ rejectUnpairedSurrogates(value);
239
+ return value;
240
+ }
241
+
242
+ /**
243
+ * Refuse a parsed value carrying an unpaired surrogate, in a key or a value, at
244
+ * any depth. The Python twin is `_reject_unpaired_surrogates`, and the two name
245
+ * the failure with the same words.
246
+ *
247
+ * Deliberately ITERATIVE. `rejectLoneSurrogates` below walks a value this
248
+ * kernel is about to serialize, which `boundedInput` has already depth-bounded.
249
+ * This one walks whatever `JSON.parse` returned for a producer's bytes, with no
250
+ * bound in front of it, so a recursive walk would turn a deeply-nested record
251
+ * into a `RangeError` out of the reader itself.
252
+ */
253
+ function rejectUnpairedSurrogates(value: unknown): void {
254
+ const stack: unknown[] = [value];
255
+ while (stack.length > 0) {
256
+ const node = stack.pop();
257
+ if (typeof node === "string") {
258
+ if (hasLoneSurrogate(node)) throw new Error("jcsParse: unpaired surrogate");
259
+ } else if (Array.isArray(node)) {
260
+ for (const child of node) stack.push(child);
261
+ } else if (node !== null && typeof node === "object") {
262
+ for (const [key, child] of Object.entries(node)) {
263
+ if (hasLoneSurrogate(key)) throw new Error("jcsParse: unpaired surrogate");
264
+ stack.push(child);
265
+ }
266
+ }
267
+ }
268
+ }
269
+
194
270
  // Walk every string in the value (keys and values). The check must run on
195
271
  // the INPUT: `canonicalize` escapes a lone surrogate as `\udXXX` text in its
196
272
  // output, so the malformed code unit is invisible after serialization.
@@ -280,11 +356,34 @@ export function isRfc3339UtcInstant(value: unknown): boolean {
280
356
  // present: §2 flags an absent `signed_at` as non-conformant rather than
281
357
  // rejecting it, and `environment` is optional in the skeleton.
282
358
  //
359
+ // `public_key` AND `signature` ARE CHECKED HERE TOO (proof#146, under
360
+ // proof#135 option B). The strict `hexToBytes` inside `ed25519Verify` is what
361
+ // makes the three kernels agree on the VERDICT; naming the spelling is what
362
+ // makes them agree on the REASON. On its own a respelled key reads `Ed25519
363
+ // signature verification failed`, which sends an auditor looking for a forged
364
+ // signature that is not there. proof#135 was opened against this function's
365
+ // Python twin, for covering `signed_at` and `environment` and not `public_key`.
366
+ //
367
+ // ONLY A STRING IS CHECKED, the same rule `envelopeHexError` follows one layer
368
+ // up: an absent or non-string `public_key` is already a failed signature in all
369
+ // three kernels (Go names it "missing signature or public_key"), and naming it
370
+ // here as a spelling would state one fact twice and in the wrong words. CASE IS
371
+ // NOT CHECKED: proof#135 pinned the charset and left the case open.
372
+ //
283
373
  // Own properties only, because those are the members `jcs` signs over.
284
374
  function skeletonFieldErrors(env: Envelope): string[] {
285
375
  const fields = env as unknown as Record<string, unknown>;
286
376
  const own = (k: string): unknown => (Object.prototype.hasOwnProperty.call(fields, k) ? fields[k] : undefined);
287
377
  const out: string[] = [];
378
+ for (const [name, nibbles] of [
379
+ ["public_key", 64],
380
+ ["signature", 128],
381
+ ] as const) {
382
+ const value = own(name);
383
+ if (typeof value === "string" && !isHexField(value, nibbles)) {
384
+ out.push(`malformed ${name}: not ${nibbles} hex characters (envelope-spec §2)`);
385
+ }
386
+ }
288
387
  const signedAt = own("signed_at");
289
388
  if (signedAt !== undefined && !isRfc3339UtcInstant(signedAt)) {
290
389
  out.push("malformed signed_at: not an RFC 3339 UTC date-time with Z naming a real date and time (envelope-spec §2)");
@@ -381,6 +480,44 @@ export async function verifyEnvelope(
381
480
  "Option keys are camelCase in the TypeScript kernel; the Python kernel uses snake_case.",
382
481
  );
383
482
  }
483
+ // ── A verifier never raises on an input (proof#85 item 3) ──
484
+ //
485
+ // The option check above is the one exception and stays OUTSIDE this try: an
486
+ // unknown option key is the CALLER's mistake, not the envelope's, and turning
487
+ // a typo into a verdict is how a check silently never runs.
488
+ //
489
+ // Below, every field read is a read of attacker-shaped data. `env` is typed
490
+ // as an `Envelope`, but nothing enforces that at runtime, and an object whose
491
+ // property reads throw (a `get`-trap `Proxy`, a getter that raises, an
492
+ // exotic object from another realm) passed the null/non-object guard and then
493
+ // escaped as a raised exception. An auditor cannot tell a raised exception
494
+ // apart from a broken tool, which is the one thing this kernel exists not to
495
+ // hand them. CLAUDE.md states the rule flatly: malformed envelopes return a
496
+ // failed `VerificationResult`, they do not throw. This closes the class, not
497
+ // the one `Proxy` case: #77 hardened the anchor-trace path for the same
498
+ // reason, and a per-input patch would have to be written again for the next
499
+ // exotic object.
500
+ try {
501
+ return await verifyEnvelopeChecked(env, opts);
502
+ } catch (e) {
503
+ return {
504
+ ok: false,
505
+ specVersionOk: false,
506
+ // `null`, never `false`: nothing was read, so nothing was determined.
507
+ // The same rule the not-a-JSON-object branch below follows.
508
+ payloadHashOk: null,
509
+ signatureOk: false,
510
+ contentHashOk: opts.expectedContentHash === undefined ? null : false,
511
+ contentRole: null,
512
+ errors: [`envelope could not be read: ${stringifyErr(e)}`],
513
+ };
514
+ }
515
+ }
516
+
517
+ async function verifyEnvelopeChecked(
518
+ env: Envelope,
519
+ opts: VerifyOptions,
520
+ ): Promise<VerificationResult> {
384
521
  const expectedContentHash = opts.expectedContentHash;
385
522
  const errors: string[] = [];
386
523
 
@@ -437,6 +574,16 @@ export async function verifyEnvelope(
437
574
  // inline payload, supplied committed bytes, or both. With neither, the
438
575
  // binding is undetermined (null): "signature-valid, semantics-undetermined"
439
576
  // (§3.1/§6.2). null does not fail the verdict; absence does.
577
+ //
578
+ // THE ENVELOPE'S OWN `payload_hash` IS NOT ECHOED INTO THESE REASONS, and
579
+ // that is a change (proof#154 F4). It is producer-controlled text of any
580
+ // length, and it went into an auditor-facing error list verbatim: a producer
581
+ // could put a newline and a forged `payload_hash: OK envelope verified`
582
+ // line, or an ANSI escape that paints `VERIFIED` green, into what the
583
+ // verifier prints. No other field in this file echoes its value, the Python
584
+ // twin names the same failure without one, and the value is in the envelope
585
+ // the auditor already holds. The RECOMPUTED digest stays: this kernel
586
+ // produced it, so it is 64 characters of lowercase hex by construction.
440
587
  let payloadHashOk: boolean | null;
441
588
  if (typeof env.payload_hash !== "string") {
442
589
  payloadHashOk = false;
@@ -449,7 +596,7 @@ export async function verifyEnvelope(
449
596
  checks.push(recomputed === env.payload_hash);
450
597
  if (recomputed !== env.payload_hash) {
451
598
  errors.push(
452
- `payload_hash mismatch: envelope=${env.payload_hash} recomputed=${recomputed}`,
599
+ `payload_hash mismatch: the envelope's payload_hash is not SHA-256(JCS(payload)), recomputed=${recomputed}`,
453
600
  );
454
601
  }
455
602
  } catch (e) {
@@ -462,7 +609,7 @@ export async function verifyEnvelope(
462
609
  checks.push(external === env.payload_hash);
463
610
  if (external !== env.payload_hash) {
464
611
  errors.push(
465
- `payload_hash does not match the committed bytes: envelope=${env.payload_hash} bytes=${external}`,
612
+ `payload_hash does not match the committed bytes: bytes=${external}`,
466
613
  );
467
614
  }
468
615
  }
package/vectors/README.md CHANGED
@@ -26,6 +26,41 @@ on a run that contacted none — a false accusation manufactured by a missing
26
26
  state, and the defect this table was written after. Vendoring rules are the same
27
27
  three steps as below, against `vectors/stamp-on-chain.json`.
28
28
 
29
+ ## `jcs-reader.json`
30
+
31
+ The rule that decides how a kernel reads bytes that claim to be `JCS(value)`
32
+ (RFC 8785). Four checks, none of them left to a built-in decoder's defaults:
33
+ **strict UTF-8**, **no leading byte-order mark**, **no non-finite literal**, and
34
+ **no unpaired surrogate in the parsed value**. The fourth is not the first one
35
+ twice: a surrogate written as the ASCII escape `\ud800` survives every
36
+ byte-level check, because the bytes are ASCII, and the parser produces the
37
+ malformed code unit afterwards (proof#154).
38
+ Implemented by [`../src/verifier.ts`](../src/verifier.ts) as `jcsParse` and by
39
+ `src/sanning_proof/canonicalize.py` as `jcs_loads`, and asserted against this
40
+ table by [`../test/host-wording.test.ts`](../test/host-wording.test.ts) **and by
41
+ the Python kernel against the same bytes**, in `tests/test_host_wording.py`, so
42
+ neither kernel can pass by agreeing with the other.
43
+
44
+ **The one thing an implementation must not do with it: reach for its language's
45
+ default.** `new TextDecoder()` replaces invalid UTF-8 with U+FFFD and strips a
46
+ BOM. Python's `json.loads(<bytes>)` is not `bytes.decode("utf-8")` then
47
+ `json.loads`: CPython sniffs the encoding and decodes with
48
+ `errors="surrogatepass"`, so it strips a BOM too, accepts a UTF-16 or UTF-32
49
+ body, and passes a lone surrogate and a CESU-8 pair through. Both defaults have
50
+ shipped a split verdict on one pack that verifies (proof#140, gates 4 and 5).
51
+
52
+ **The accepted cases matter as much as the rejected ones.** An honest body
53
+ carrying astral characters, a noncharacter, a U+FEFF *inside a string value* or
54
+ combining marks is conformant, and a reader that rejects any of them has broken
55
+ evidence someone already anchored. Seven of the 26 cases are there for that,
56
+ including an astral character spelled as an escaped surrogate PAIR, which is the
57
+ honest input the fourth check must not catch.
58
+
59
+ Vendoring rules are the same three steps as below, against
60
+ `vectors/jcs-reader.json`. Hand the reader the **bytes**, never a string: half
61
+ the cases are about the decode, and a reader tested through a string has not
62
+ been tested.
63
+
29
64
  ## `pack-grading.json`
30
65
 
31
66
  The rule that decides whether a **pack** — a signed bundle plus whatever
@@ -72,7 +107,7 @@ happened, and the reason this file moved here.
72
107
 
73
108
  | Key | Meaning |
74
109
  |---|---|
75
- | `ruleVersion` | Bumped when a case's expectation changes **or a case is added**. An implementation asserts the version it was written against, so a table it does not understand fails loudly rather than passing vacuously. Currently **2**. |
110
+ | `ruleVersion` | Bumped when a case's expectation changes **or a case is added**. An implementation asserts the version it was written against, so a table it does not understand fails loudly rather than passing vacuously. Currently **3**. |
76
111
  | `kernelEventClasses` | The four per-event outcome classes a verification result can carry, named so a case can be written as counts rather than as a hand-built bundle. |
77
112
  | `cases[].kernel` | The verification result to grade, as class counts. |
78
113
  | `cases[].logs` | The disclosure account: how many records the bundle presents, and how many disclosed usable bytes. |
@@ -0,0 +1,166 @@
1
+ {
2
+ "rule": "jcs-reader",
3
+ "ruleVersion": 2,
4
+ "statement": "How a kernel reads bytes that claim to be JCS(value) (RFC 8785). Four checks, none of them left to a built-in decoder's defaults: strict UTF-8, no LEADING byte-order mark, no non-finite literal, and no UNPAIRED surrogate in the parsed value. The fourth is not the first one twice: a surrogate written as the ASCII escape `\\ud800` survives every byte-level check, because the bytes are ASCII, and the parser produces the malformed code unit afterwards. Everything else a JSON parser rejects is rejected. A reader that accepts any of the rejected cases here is reading a set of bytes some other reader will read differently, and a producer chooses those bytes: that is a split verdict on one pack, not a spelling preference. The accepted cases matter as much as the rejected ones -- an honest body carrying astral characters, noncharacters, a U+FEFF inside a string value or combining marks is conformant and must keep verifying.",
5
+ "home": "@sanning/proof -- ts/vectors/jcs-reader.json. This file is the RULE, not a test fixture of one implementation. TypeScript implements it in ts/src/verifier.ts as jcsParse and Python in src/sanning_proof/canonicalize.py as jcs_loads; ts/test/host-wording.test.ts and tests/test_host_wording.py hold BOTH kernels to this file, so neither can pass by agreeing with the other.",
6
+ "howToUse": "Base64-decode `bytesB64`, hand the bytes to your kernel's JCS reader, and assert that it returns a value where `accept` is true and throws or raises where it is false. Never decode to a string first: half of these cases are ABOUT the decode, and a reader tested through a string has not been tested.",
7
+ "whyItExists": "Round four of proof#140 put a strict decoder on the TypeScript side of three sites and left json.loads(<bytes>) on the Python side of two. CPython hands bytes to detect_encoding and decodes with errors='surrogatepass', so Python became the LENIENT kernel: a sanning.anchor.trace/v3 pack whose checkpoint record bytes were BOM + JCS(record), re-sealed so the pack verifies, read `verified` with zero exceptions in Python and `mixed` in TypeScript. The full ladder, the case leg and CI were green over it. Both guards were source assertions over different module sets, and the TypeScript one stated the false premise in its own comment. One table both kernels read is the fix for that.",
8
+ "cases": [
9
+ {
10
+ "id": "plain",
11
+ "bytesB64": "eyJldmVudCI6eyJtZXJrbGVfcm9vdCI6ImFhIn0sInByZXZpb3VzX2hhc2giOiJHRU5FU0lTIn0=",
12
+ "accept": true,
13
+ "why": "the control: RFC 8785 output, read as an object"
14
+ },
15
+ {
16
+ "id": "bom-leading",
17
+ "bytesB64": "77u/eyJldmVudCI6eyJtZXJrbGVfcm9vdCI6ImFhIn0sInByZXZpb3VzX2hhc2giOiJHRU5FU0lTIn0=",
18
+ "accept": false,
19
+ "why": "a UTF-8 byte-order mark before the object. RFC 8785 output never begins with U+FEFF. `json.loads(<bytes>)` STRIPS it and `new TextDecoder()` STRIPS it; both strict readers keep it and reject"
20
+ },
21
+ {
22
+ "id": "utf16le-bom",
23
+ "bytesB64": "//57ACIAZQB2AGUAbgB0ACIAOgB7ACIAbQBlAHIAawBsAGUAXwByAG8AbwB0ACIAOgAiAGEAYQAiAH0ALAAiAHAAcgBlAHYAaQBvAHUAcwBfAGgAYQBzAGgAIgA6ACIARwBFAE4ARQBTAEkAUwAiAH0A",
24
+ "accept": false,
25
+ "why": "the same object encoded UTF-16LE with a BOM. CPython's `detect_encoding` sniffs it and decodes happily; JCS is defined over UTF-8 and this is not it"
26
+ },
27
+ {
28
+ "id": "utf16le-no-bom",
29
+ "bytesB64": "ewAiAGUAdgBlAG4AdAAiADoAewAiAG0AZQByAGsAbABlAF8AcgBvAG8AdAAiADoAIgBhAGEAIgB9ACwAIgBwAHIAZQB2AGkAbwB1AHMAXwBoAGEAcwBoACIAOgAiAEcARQBOAEUAUwBJAFMAIgB9AA==",
30
+ "accept": false,
31
+ "why": "UTF-16LE with no BOM. CPython falls back to a NUL heuristic on the first four bytes and still decodes it"
32
+ },
33
+ {
34
+ "id": "utf32le-bom",
35
+ "bytesB64": "//4AAHsAAAAiAAAAZQAAAHYAAABlAAAAbgAAAHQAAAAiAAAAOgAAAHsAAAAiAAAAbQAAAGUAAAByAAAAawAAAGwAAABlAAAAXwAAAHIAAABvAAAAbwAAAHQAAAAiAAAAOgAAACIAAABhAAAAYQAAACIAAAB9AAAALAAAACIAAABwAAAAcgAAAGUAAAB2AAAAaQAAAG8AAAB1AAAAcwAAAF8AAABoAAAAYQAAAHMAAABoAAAAIgAAADoAAAAiAAAARwAAAEUAAABOAAAARQAAAFMAAABJAAAAUwAAACIAAAB9AAAA",
36
+ "accept": false,
37
+ "why": "the same object encoded UTF-32LE with a BOM. `detect_encoding` sniffs this one too"
38
+ },
39
+ {
40
+ "id": "lone-surrogate-high",
41
+ "bytesB64": "eyJhIjoieO2ggHkifQ==",
42
+ "accept": false,
43
+ "why": "a high surrogate alone, encoded as three UTF-8 bytes. CPython decodes bytes with `errors=\"surrogatepass\"` and passes it through as a character; I-JSON (RFC 7493) forbids it and it is not representable as UTF-8"
44
+ },
45
+ {
46
+ "id": "lone-surrogate-low",
47
+ "bytesB64": "eyJhIjoieO2wgHkifQ==",
48
+ "accept": false,
49
+ "why": "a low surrogate alone, the mirror of the case above"
50
+ },
51
+ {
52
+ "id": "escaped-lone-surrogate-high",
53
+ "bytesB64": "eyJrIjoiY1x1ZDgwMHgifQ==",
54
+ "accept": false,
55
+ "why": "a high surrogate written as the ASCII escape `\\ud800`, with no low surrogate after it. PURE ASCII ON THE WIRE, which is the whole point: the strict UTF-8 decode above cannot see it, and both kernels accepted it while their own text said they refuse a lone surrogate. I-JSON (RFC 7493 2.1) forbids an unpaired surrogate and RFC 8785 cannot emit one, since it serializes to UTF-8 and UTF-8 has no encoding for a surrogate code point. Accepted, it is not a lenience anyone can live with: the Python kernel could not then encode the value for a sort key, `read_chains` raised, and the CLI reported `no event chain was found to follow in this bundle` for a bundle whose chains TypeScript read and named a finding on (proof#154)"
56
+ },
57
+ {
58
+ "id": "escaped-lone-surrogate-low",
59
+ "bytesB64": "eyJrIjoiY1x1ZGMwMHgifQ==",
60
+ "accept": false,
61
+ "why": "a low surrogate written as an ASCII escape with no high surrogate before it: the mirror of the case above, and the one a reader that only looks ahead for a pair would miss"
62
+ },
63
+ {
64
+ "id": "escaped-lone-surrogate-in-key",
65
+ "bytesB64": "eyJjXHVkODAweCI6MX0=",
66
+ "accept": false,
67
+ "why": "the same escape in a MEMBER NAME rather than a value. RFC 8785 sorts on member names, so a reader that walks values only accepts a record whose keys no other kernel can represent"
68
+ },
69
+ {
70
+ "id": "escaped-surrogate-pair-astral",
71
+ "bytesB64": "eyJrIjoiY1x1ZDgzZFx1ZGUwMHgifQ==",
72
+ "accept": true,
73
+ "why": "U+1F600 written as the escaped surrogate PAIR `\\ud83d\\ude00`. LEGITIMATE and it must keep verifying: both hosts join the pair into the one astral character before any check sees it, so this is the honest input the three rejections above must not catch. A reader that refuses every `\\udXXX` escape rather than every UNPAIRED one refuses this, and what it refuses is the honest spelling"
74
+ },
75
+ {
76
+ "id": "cesu8-pair",
77
+ "bytesB64": "eyJhIjoieO2gve24gHkifQ==",
78
+ "accept": false,
79
+ "why": "CESU-8: one astral code point spelled as a surrogate PAIR in three-byte form. Passes through `surrogatepass` and recombines; UTF-8 spells U+1F600 in four bytes and nothing else"
80
+ },
81
+ {
82
+ "id": "raw-0x80",
83
+ "bytesB64": "eyJhIjoieIB5In0=",
84
+ "accept": false,
85
+ "why": "a continuation byte with no lead byte: not UTF-8 at all. The case gate 4 found, where `new TextDecoder()` substituted U+FFFD and read `verified`"
86
+ },
87
+ {
88
+ "id": "overlong-solidus",
89
+ "bytesB64": "eyJhIjoieMCveSJ9",
90
+ "accept": false,
91
+ "why": "an overlong encoding of `/`. A second spelling of one code point is a second set of bytes hashing to a second digest"
92
+ },
93
+ {
94
+ "id": "truncated-sequence",
95
+ "bytesB64": "eyJhIjoieOKCeSJ9",
96
+ "accept": false,
97
+ "why": "a three-byte sequence cut after two"
98
+ },
99
+ {
100
+ "id": "nan",
101
+ "bytesB64": "eyJhIjpOYU59",
102
+ "accept": false,
103
+ "why": "`NaN`. RFC 8259 does not define it and RFC 8785 cannot emit it; `json.loads` returns a float and `JSON.parse` throws"
104
+ },
105
+ {
106
+ "id": "infinity",
107
+ "bytesB64": "eyJhIjpJbmZpbml0eX0=",
108
+ "accept": false,
109
+ "why": "`Infinity`, the same class as `NaN`"
110
+ },
111
+ {
112
+ "id": "negative-infinity",
113
+ "bytesB64": "eyJhIjotSW5maW5pdHl9",
114
+ "accept": false,
115
+ "why": "`-Infinity`, the same class as `NaN`"
116
+ },
117
+ {
118
+ "id": "not-json",
119
+ "bytesB64": "aGVsbG8=",
120
+ "accept": false,
121
+ "why": "not JSON in any encoding"
122
+ },
123
+ {
124
+ "id": "empty",
125
+ "bytesB64": "",
126
+ "accept": false,
127
+ "why": "no bytes at all"
128
+ },
129
+ {
130
+ "id": "astral-emoji",
131
+ "bytesB64": "eyJhIjoiePCfmIB5In0=",
132
+ "accept": true,
133
+ "why": "U+1F600 spelled the one way UTF-8 spells it, four bytes. LEGITIMATE: an honest body may carry any astral character and must keep verifying"
134
+ },
135
+ {
136
+ "id": "u-fffe",
137
+ "bytesB64": "eyJhIjoieO+/vnkifQ==",
138
+ "accept": true,
139
+ "why": "U+FFFE, a noncharacter, inside a string. LEGITIMATE: RFC 8785 emits it and neither kernel may reject it"
140
+ },
141
+ {
142
+ "id": "bom-mid-string",
143
+ "bytesB64": "eyJhIjoieO+7v3kifQ==",
144
+ "accept": true,
145
+ "why": "U+FEFF inside a string value rather than before the object. LEGITIMATE: it is an ordinary code point there, and only a LEADING one is the malformed case"
146
+ },
147
+ {
148
+ "id": "combining-marks",
149
+ "bytesB64": "eyJhIjoiZcyBzKcifQ==",
150
+ "accept": true,
151
+ "why": "a base character with two combining marks. LEGITIMATE: RFC 8785 does not normalize, so the reader must not either"
152
+ },
153
+ {
154
+ "id": "nul-in-string",
155
+ "bytesB64": "eyJhIjoieAB5In0=",
156
+ "accept": false,
157
+ "why": "a raw NUL inside a string. Valid UTF-8 and invalid JSON: RFC 8259 requires the escape, and both kernels reject at the parse rather than at the decode"
158
+ },
159
+ {
160
+ "id": "array-root",
161
+ "bytesB64": "WzEsMiwzXQ==",
162
+ "accept": true,
163
+ "why": "a JSON array at the root. The reader returns it; whether a given site accepts a non-object is that site's check, not this one's"
164
+ }
165
+ ]
166
+ }
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "rule": "pack-grading",
3
- "ruleVersion": 2,
4
- "statement": "What a verification surface may CLAIM about a pack it was handed. verified is reachable ONLY when every record's own bytes bound to its own commitment and nothing is missing, unreadable, surplus or omitted. Absent or unreadable evidence is incomplete (UNKNOWN), never failed and never verified. A hard kernel verdict wins: malformed > failed > incomplete > the kernel's own headline.",
3
+ "ruleVersion": 5,
4
+ "statement": "What a verification surface may CLAIM about a pack it was handed. verified is reachable ONLY when every record's own bytes bound to its own commitment and nothing is missing, unreadable, surplus or omitted. Absent or unreadable evidence is incomplete (UNKNOWN), never failed and never verified. A hard kernel verdict wins: malformed > failed > incomplete > the kernel's own headline. A pack that DEVIATES from its own signed declaration (evidence-bundle.md \u00a75.4) is failed, and every deviation is a cause: a declared record that is absent, a present record nobody declared, or a record the pack declared withheld whose bytes its own SIGNED BODY supplies. A reading that rests instead on the UNSIGNED content handed in beside a pack is not a deviation and never reaches failed (proof#147): each content-dependent finding is its own cause and carries the pack to incomplete, because whoever assembles a case fills that map in, so an accuser holding an intact pack could otherwise reach failed with no key at all. A deviation and a content-dependent finding together are still failed, because the deviation reads off the signed body and a failing check wins. A declaration check that came back partial therefore splits in THREE, and only two of the three turn on the declaration's readability: no declaration member in the body at all moves nothing, because every pack built before \u00a75.4 is that case; a declaration the reader CANNOT READ makes the pack incomplete, because declaration_version is chosen by the party that assembles the pack and has no accept-set, so an unreadable claim must never reach a clean verdict; and a declaration the reader read perfectly well whose disclosure only unsigned bytes could settle makes the pack incomplete as well, named as what the reader could not establish rather than as what the holder did. The grade carries the declaration check verdict in its own output field, so a consumer that keeps only the grade can still tell a declared pack from an undeclared one.",
5
5
  "home": "@sanning/proof — ts/vectors/pack-grading.json. This file is the RULE, not a test fixture of one implementation. The kernel implements it in ts/src/grading.ts and is held to it by ts/test/grading.test.ts.",
6
6
  "consumers": "A surface that grades packs SHOULD call the kernel's gradePack rather than derive the rule again. A surface that cannot yet (an older pinned kernel, another language) vendors this file byte-for-byte, asserts its own derivation against it, and runs a drift check that compares its copy against the copy shipped in the @sanning/proof tarball at vectors/pack-grading.json. A vendored copy that is not byte-identical to this one is a defect, not a variant.",
7
- "howToUse": "Adapt a kernel verification result and a disclosure account into the shapes below, call gradePack, and assert the expectations. `kernel` gives the per-event outcome classes the kernel returns; `logs` gives the account of which records disclosed bytes; `omissions` gives the signed-leaf_count completeness notes. Nothing here depends on how the bytes were transported — a directory read and a browser drop produce the same account and must produce the same verdict.",
7
+ "howToUse": "Adapt a kernel verification result and a disclosure account into the shapes below, call gradePack, and assert the expectations. `kernel` gives the per-event outcome classes the kernel returns; `logs` gives the account of which records disclosed bytes; `omissions` gives the signed-leaf_count completeness notes. Nothing here depends on how the bytes were transported: a directory read and a browser drop produce the same account and must produce the same verdict. `declaration`, where a case carries one, gives the declaration check's verdict, whether the body carried a declaration member at all, and how many deviations it found; pass a check of that shape as gradePack's optional fourth argument. A case with no `declaration` key calls gradePack with three arguments, which is the pre-§5.4 behavior and must not move, and its `expect.declaration` is absent.",
8
8
  "kernelEventClasses": {
9
9
  "okBound": "{ ok: true, contentOk: true } — the record's own bytes hashed to its own commitment",
10
10
  "okUnknown": "{ ok: true, contentOk: null } — structurally fine, but no bytes were disclosed",
@@ -52,6 +52,14 @@
52
52
  "omissions": [],
53
53
  "expect": { "verdict": "incomplete", "recordsVerified": 0, "recordsFailed": 0, "recordsUndetermined": 26, "causeCount": 1 }
54
54
  },
55
+ {
56
+ "id": "withheld-record-clean-account",
57
+ "why": "the case the terminal got wrong (proof#110). A bundle the kernel grades `verified` under evidence-bundle.md §3, holding one record the producer WITHHELD. The disclosure account is clean, and correctly so: no bytes were ever expected for a record that was never offered, so nothing is missing, unreadable, surplus or omitted and there is no cause to report. The record is still unhashed. The PACK is `incomplete` while the BUNDLE is `verified`: the two enums are distinct, and an undetermined record ON ITS OWN is enough to downgrade, with no cause beside it. Every other incomplete case here carries a cause, so a surface could satisfy all of them by keying off `causes` alone and still print a green verdict over a record nobody checked, which is exactly what `proof verify` did.",
58
+ "kernel": { "status": "verified", "okBound": 2, "okUnknown": 1, "mismatched": 0, "brokenNoContent": 0 },
59
+ "logs": { "expected": 2, "present": 2, "missing": 0, "unreadable": 0, "extra": 0 },
60
+ "omissions": [],
61
+ "expect": { "verdict": "incomplete", "recordsVerified": 2, "recordsFailed": 0, "recordsUndetermined": 1, "causeCount": 0 }
62
+ },
55
63
  {
56
64
  "id": "one-log-unreadable",
57
65
  "why": "a file we could not read is our fault, not the record's — UNKNOWN",
@@ -131,6 +139,126 @@
131
139
  "logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
132
140
  "omissions": [],
133
141
  "expect": { "verdict": "incomplete", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 0 }
142
+ },
143
+ {
144
+ "id": "declaration-matches",
145
+ "why": "a pack that holds exactly what its declaration says it holds: the declaration adds no cause and moves no verdict",
146
+ "kernel": { "status": "verified", "okBound": 26, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
147
+ "logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
148
+ "omissions": [],
149
+ "declaration": { "verdict": "verified", "present": true, "deviations": 0 },
150
+ "expect": { "verdict": "verified", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 0, "declaration": "verified" }
151
+ },
152
+ {
153
+ "id": "declaration-deviates",
154
+ "why": "the trimmed hand-over. Every record present verifies and nothing is missing from the disclosure account, so every other check is green; the pack deviates from its own declaration and that is a FAILURE, never an absence",
155
+ "kernel": { "status": "verified", "okBound": 26, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
156
+ "logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
157
+ "omissions": [],
158
+ "declaration": { "verdict": "failed", "present": true, "deviations": 1 },
159
+ "expect": { "verdict": "failed", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 1, "declaration": "failed" }
160
+ },
161
+ {
162
+ "id": "declaration-content-dependent",
163
+ "why": "the pack agrees with its own claim everywhere its signature reaches, and does not line up with the UNSIGNED content handed in beside it. A NAMED cause and incomplete, never failed: an accuser holding an intact pack reaches this by dropping one file or adding one, with no key at all (proof#147)",
164
+ "kernel": {
165
+ "status": "verified",
166
+ "okBound": 26,
167
+ "okUnknown": 0,
168
+ "mismatched": 0,
169
+ "brokenNoContent": 0
170
+ },
171
+ "logs": {
172
+ "expected": 26,
173
+ "present": 26,
174
+ "missing": 0,
175
+ "unreadable": 0,
176
+ "extra": 0
177
+ },
178
+ "omissions": [],
179
+ "declaration": {
180
+ "verdict": "partial",
181
+ "present": true,
182
+ "deviations": 0,
183
+ "contentFindings": 1
184
+ },
185
+ "expect": {
186
+ "verdict": "incomplete",
187
+ "recordsVerified": 26,
188
+ "recordsFailed": 0,
189
+ "recordsUndetermined": 0,
190
+ "causeCount": 1,
191
+ "declaration": "partial"
192
+ }
193
+ },
194
+ {
195
+ "id": "declaration-deviation-beats-a-content-finding",
196
+ "why": "one of each, over different records. The deviation reads off the pack's own signed body so the pack FAILS, and the content-dependent finding is still its own cause: two classes, both reported, neither folded into the other",
197
+ "kernel": {
198
+ "status": "verified",
199
+ "okBound": 26,
200
+ "okUnknown": 0,
201
+ "mismatched": 0,
202
+ "brokenNoContent": 0
203
+ },
204
+ "logs": {
205
+ "expected": 26,
206
+ "present": 26,
207
+ "missing": 0,
208
+ "unreadable": 0,
209
+ "extra": 0
210
+ },
211
+ "omissions": [],
212
+ "declaration": {
213
+ "verdict": "failed",
214
+ "present": true,
215
+ "deviations": 1,
216
+ "contentFindings": 1
217
+ },
218
+ "expect": {
219
+ "verdict": "failed",
220
+ "recordsVerified": 26,
221
+ "recordsFailed": 0,
222
+ "recordsUndetermined": 0,
223
+ "causeCount": 2,
224
+ "declaration": "failed"
225
+ }
226
+ },
227
+ {
228
+ "id": "declaration-names-every-deviation",
229
+ "why": "three deviations in one pack are three causes. A rule that reported one shortfall would pass the case above and lose the other two findings",
230
+ "kernel": { "status": "verified", "okBound": 26, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
231
+ "logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
232
+ "omissions": [],
233
+ "declaration": { "verdict": "failed", "present": true, "deviations": 3 },
234
+ "expect": { "verdict": "failed", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 3, "declaration": "failed" }
235
+ },
236
+ {
237
+ "id": "declaration-deviation-beats-incomplete",
238
+ "why": "a proven mismatch between what a pack claimed and what it carries is never softened into absence, even when the pack is also missing disclosed bytes",
239
+ "kernel": { "status": "verified", "okBound": 20, "okUnknown": 6, "mismatched": 0, "brokenNoContent": 0 },
240
+ "logs": { "expected": 26, "present": 20, "missing": 6, "unreadable": 0, "extra": 0 },
241
+ "omissions": [],
242
+ "declaration": { "verdict": "failed", "present": true, "deviations": 1 },
243
+ "expect": { "verdict": "failed", "recordsVerified": 20, "recordsFailed": 0, "recordsUndetermined": 6, "causeCount": 2, "declaration": "failed" }
244
+ },
245
+ {
246
+ "id": "declaration-absent-moves-nothing",
247
+ "why": "a pack with no declaration: partial is the declaration check's own answer, and pack grading keeps its existing verdict. Every pack built before \u00a75.4 is this case, and re-grading them would be a spec change wearing an optional argument. `present` is false, which is what separates this case from the one below it",
248
+ "kernel": { "status": "verified", "okBound": 26, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
249
+ "logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
250
+ "omissions": [],
251
+ "declaration": { "verdict": "partial", "present": false, "deviations": 0 },
252
+ "expect": { "verdict": "verified", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 0, "declaration": "partial" }
253
+ },
254
+ {
255
+ "id": "declaration-unreadable-is-incomplete",
256
+ "why": "a pack that DOES carry a declaration and wrote it in a shape this reader cannot read. `declaration_version` is chosen by the same party that assembles the pack and has no accept-set, so a holder can trim a record, write one unreadable field and re-seal the whole body with its own key: every other check stays green and the evidence of the trim sits inside the signed body. `partial` keeps its meaning on the CHECK, and the PACK is incomplete, because a claim the reader cannot read is not a claim the reader may ignore. The case above and this one differ only in `present`, and a rule that reads the verdict alone passes both while grading one of them clean",
257
+ "kernel": { "status": "verified", "okBound": 26, "okUnknown": 0, "mismatched": 0, "brokenNoContent": 0 },
258
+ "logs": { "expected": 26, "present": 26, "missing": 0, "unreadable": 0, "extra": 0 },
259
+ "omissions": [],
260
+ "declaration": { "verdict": "partial", "present": true, "deviations": 0 },
261
+ "expect": { "verdict": "incomplete", "recordsVerified": 26, "recordsFailed": 0, "recordsUndetermined": 0, "causeCount": 0, "declaration": "partial" }
134
262
  }
135
263
  ]
136
264
  }