@sanning/proof 0.9.2 → 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
@@ -17,7 +17,7 @@ import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from ".
17
17
  // major ("sanning.agent/v1.<minor>") are additive and tolerated — matching the Go
18
18
  // reference kernel's semantics (pkg/proof isSupportedSpec) so the kernels do
19
19
  // not diverge on minors. Accepting a new profile is a deliberate one-entry
20
- // addition HERE and only here. sanning.events/v1 (external commitment + Minimal
20
+ // addition HERE and only here. sanning.events/v2 (external commitment + Minimal
21
21
  // disclosure; envelope-spec §4) is verified through the SAME primitives — the
22
22
  // signed scope is still envelope-minus-signature-minus-co_signatures, with no
23
23
  // profile-specific dialect (no underscore strip, that is mlflow-only).
@@ -39,9 +39,13 @@ import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from ".
39
39
  // always strict mode) and the `readonly string[]` type rejects it at compile
40
40
  // time. The elements are strings, so a shallow freeze is a deep one. Pinned by
41
41
  // test/accept-set.test.ts "the exported registry cannot be widened at runtime".
42
+ //
43
+ // `sanning.events/v2` replaced `sanning.events/v1` in the producer-to-agent cut
44
+ // (envelope-spec A10, governance.md §8 W-3). The old major is rejected like any
45
+ // other unknown one: DL-73 keeps no reader for it.
42
46
  export const ACCEPTED_SPEC_MAJORS: readonly string[] = Object.freeze([
43
47
  "sanning.agent/v1",
44
- "sanning.events/v1",
48
+ "sanning.events/v2",
45
49
  ]);
46
50
 
47
51
  // Maximum JSON container-nesting depth a verifier will canonicalize. Inputs
@@ -187,6 +191,82 @@ export function jcs(value: unknown): string {
187
191
  return canonical;
188
192
  }
189
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
+
190
270
  // Walk every string in the value (keys and values). The check must run on
191
271
  // the INPUT: `canonicalize` escapes a lone surrogate as `\udXXX` text in its
192
272
  // output, so the malformed code unit is invisible after serialization.
@@ -276,11 +356,34 @@ export function isRfc3339UtcInstant(value: unknown): boolean {
276
356
  // present: §2 flags an absent `signed_at` as non-conformant rather than
277
357
  // rejecting it, and `environment` is optional in the skeleton.
278
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
+ //
279
373
  // Own properties only, because those are the members `jcs` signs over.
280
374
  function skeletonFieldErrors(env: Envelope): string[] {
281
375
  const fields = env as unknown as Record<string, unknown>;
282
376
  const own = (k: string): unknown => (Object.prototype.hasOwnProperty.call(fields, k) ? fields[k] : undefined);
283
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
+ }
284
387
  const signedAt = own("signed_at");
285
388
  if (signedAt !== undefined && !isRfc3339UtcInstant(signedAt)) {
286
389
  out.push("malformed signed_at: not an RFC 3339 UTC date-time with Z naming a real date and time (envelope-spec §2)");
@@ -377,6 +480,44 @@ export async function verifyEnvelope(
377
480
  "Option keys are camelCase in the TypeScript kernel; the Python kernel uses snake_case.",
378
481
  );
379
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> {
380
521
  const expectedContentHash = opts.expectedContentHash;
381
522
  const errors: string[] = [];
382
523
 
@@ -433,6 +574,16 @@ export async function verifyEnvelope(
433
574
  // inline payload, supplied committed bytes, or both. With neither, the
434
575
  // binding is undetermined (null): "signature-valid, semantics-undetermined"
435
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.
436
587
  let payloadHashOk: boolean | null;
437
588
  if (typeof env.payload_hash !== "string") {
438
589
  payloadHashOk = false;
@@ -445,7 +596,7 @@ export async function verifyEnvelope(
445
596
  checks.push(recomputed === env.payload_hash);
446
597
  if (recomputed !== env.payload_hash) {
447
598
  errors.push(
448
- `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}`,
449
600
  );
450
601
  }
451
602
  } catch (e) {
@@ -458,7 +609,7 @@ export async function verifyEnvelope(
458
609
  checks.push(external === env.payload_hash);
459
610
  if (external !== env.payload_hash) {
460
611
  errors.push(
461
- `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}`,
462
613
  );
463
614
  }
464
615
  }
@@ -488,7 +639,7 @@ export async function verifyEnvelope(
488
639
  const committed = contentHashes(env);
489
640
  if (committed.length === 0) {
490
641
  // UNDETERMINED, not failed. An external-commitment envelope
491
- // (`sanning.events/v1`) has no inline payload, so it commits to no
642
+ // (`sanning.events/v2`) has no inline payload, so it commits to no
492
643
  // envelope-readable content hash and there is nothing here to compare
493
644
  // against. Reporting that as a failure would render "we cannot check
494
645
  // this" as "it did not match" — what P-10 and I-5 exist to prevent — and
package/vectors/README.md CHANGED
@@ -12,7 +12,7 @@ correctly.
12
12
 
13
13
  The rule that decides what re-fetching a **stamp's witness** from a gateway does
14
14
  and does not settle — the optional, online half of `sanning.stamp.trace/v1` and
15
- `sanning.stamp.evidence/v1` (`evidence-bundle.md` §5.2.4 / §5.3.4). Implemented
15
+ `sanning.stamp.evidence/v2` (`evidence-bundle.md` §5.2.4 / §5.3.4). Implemented
16
16
  by [`../src/evidence.ts`](../src/evidence.ts) and asserted against this table by
17
17
  [`../test/stamp-on-chain-vectors.test.ts`](../test/stamp-on-chain-vectors.test.ts)
18
18
  — **and by the Python kernel against the same bytes**, in
@@ -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. |
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "rule": "chain-reading",
3
3
  "ruleVersion": 1,
4
- "statement": "What following an event chain DOES and DOES NOT show. Every sanning.events/v1 record points at the record before it; reading those pointers over the records a bundle presents yields deletion- and reorder-EVIDENCE for those records, and nothing else. It is not a completeness proof and no finding here is a verdict: a chain whose last events were never exported dangles nowhere, and a record that is withheld or unreadable takes no part in a chain rather than becoming an accusation. Findings are the specific links that do not resolve inside the bundle in front of the reader: chain-link-dangling, chain-gap, chain-second-root.",
4
+ "statement": "What following an event chain DOES and DOES NOT show. Every sanning.events/v2 record points at the record before it; reading those pointers over the records a bundle presents yields deletion- and reorder-EVIDENCE for those records, and nothing else. It is not a completeness proof and no finding here is a verdict: a chain whose last events were never exported dangles nowhere, and a record that is withheld or unreadable takes no part in a chain rather than becoming an accusation. Findings are the specific links that do not resolve inside the bundle in front of the reader: chain-link-dangling, chain-gap, chain-second-root.",
5
5
  "home": "@sanning/proof — ts/vectors/chain-reading.json. This file is the RULE, not a test fixture of one implementation. The kernel implements it in ts/src/chain.ts and is held to it by ts/test/chain.test.ts.",
6
6
  "consumers": "A surface that follows event chains SHOULD call the kernel's readChains 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/chain-reading.json — and that fails when it cannot find a copy to compare against. A vendored copy that is not byte-identical to this one is a defect, not a variant.",
7
- "howToUse": "Each case is a chain as it was WRITTEN, in order. Build one event record per entry from `recordScaffold`, overriding `previous_hash`, `context` and `metadata` from the entry and setting `event.content_length` to the entry's index so no two records share bytes. Canonicalize each record (RFC 8785), hex-encode the bytes as `record_bytes`, and place it in a bundle body as `{ envelope: { event_id, public_key }, record_bytes }` — in array order, SKIPPING every entry marked `dropped`. A dropped entry is still built and still hashed, because that is what deletion is: the record existed, its successor still points at it, and the pointer now leads nowhere. Then call readChains and assert `expect`.",
7
+ "howToUse": "Each case is a chain as it was WRITTEN, in order. Build one event record per entry from `recordScaffold`, overriding `previous_hash`, `context` and `metadata` from the entry and setting `event.content_length` to the entry's index so no two records share bytes. Canonicalize each record (RFC 8785), hex-encode the bytes as `event_bytes`, and place it in a bundle body as `{ envelope: { event_id, public_key }, event_bytes }`, in array order, SKIPPING every entry marked `dropped`. A dropped entry is still built and still hashed, because that is what deletion is: the record existed, its successor still points at it, and the pointer now leads nowhere. Then call readChains and assert `expect`.",
8
8
  "placeholders": {
9
9
  "@<id>": "The LINK IDENTITY of the entry with that id — for a record chain the SHA-256 of its canonical record bytes, for an adapter chain its event_id. Resolvable for dropped entries too; that is the point of them.",
10
10
  "GENESIS": "In previous_hash: this record claims to begin its chain.",
@@ -14,9 +14,9 @@
14
14
  "adapterNamespaces": "Verified against sanning-io/anchor @ main, 2026-09-07: `run` (LangChain callbacks, TS and Python — anchor#68 promoted this out of the old `langchain` namespace), `vercel_ai` (Vercel AI SDK middleware), `interchange` (Interchange adapter). All three carry `prev_event_id` and `seq`; their chain key is `root_run_id`, `chain_key` and `session_id` respectively. These are read ONLY where the record carries no chain of its own, and they are transitional: once every adapter sets the record's own previous_hash, a reader needs no adapter knowledge at all.",
15
15
  "recordScaffold": {
16
16
  "payload_version": 1,
17
- "spec_version": "sanning.events/v1",
17
+ "spec_version": "sanning.events/v2",
18
18
  "event_type": "langchain.tool_start",
19
- "subject": { "type": "producer", "producer_id": "vector" },
19
+ "subject": { "type": "agent", "agent_id": "vector" },
20
20
  "event": {
21
21
  "content_hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
22
22
  "content_length": 0
@@ -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
+ }