consiliency-spec 0.1.1__py3-none-any.whl

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.
Files changed (57) hide show
  1. consiliency_spec/__init__.py +78 -0
  2. consiliency_spec/_data/canon/README.md +99 -0
  3. consiliency_spec/_data/canon/SPEC.md +380 -0
  4. consiliency_spec/_data/canon/bindings/pyo3/README.md +14 -0
  5. consiliency_spec/_data/canon/bindings/wasm/README.md +14 -0
  6. consiliency_spec/_data/canon/conformance/check.sh +78 -0
  7. consiliency_spec/_data/canon/conformance/check_dabi_cabi.sh +15 -0
  8. consiliency_spec/_data/canon/conformance/check_published_canon_core.sh +198 -0
  9. consiliency_spec/_data/canon/conformance/check_xg4_canon_core.sh +146 -0
  10. consiliency_spec/_data/canon/conformance/emit_published_npm.mjs +118 -0
  11. consiliency_spec/_data/canon/conformance/emit_published_py.py +116 -0
  12. consiliency_spec/_data/canon/conformance/emit_pyo3.py +55 -0
  13. consiliency_spec/_data/canon/conformance/emit_wasm.mjs +54 -0
  14. consiliency_spec/_data/canon/conformance/engine_boundary_vectors.json +71 -0
  15. consiliency_spec/_data/canon/conformance/test_ingest_nfc.py +120 -0
  16. consiliency_spec/_data/canon/conformance/test_unicode_skew.py +164 -0
  17. consiliency_spec/_data/canon/conformance/wasm_surrogate_finding.mjs +107 -0
  18. consiliency_spec/_data/canon/core/Cargo.lock +408 -0
  19. consiliency_spec/_data/canon/core/Cargo.toml +25 -0
  20. consiliency_spec/_data/canon/core/cbindgen.toml +26 -0
  21. consiliency_spec/_data/canon/core/include/canon_core.h +84 -0
  22. consiliency_spec/_data/canon/core/scripts/check_cabi_smoke.sh +49 -0
  23. consiliency_spec/_data/canon/core/src/bin/canon_core_emit.rs +36 -0
  24. consiliency_spec/_data/canon/core/src/c_abi.rs +226 -0
  25. consiliency_spec/_data/canon/core/src/lib.rs +278 -0
  26. consiliency_spec/_data/canon/core/tests/c_abi_smoke.c +85 -0
  27. consiliency_spec/_data/canon/core/tests/parity.rs +39 -0
  28. consiliency_spec/_data/canon/py/canon.py +221 -0
  29. consiliency_spec/_data/canon/py/canon_ingest.py +103 -0
  30. consiliency_spec/_data/canon/py/requirements.txt +9 -0
  31. consiliency_spec/_data/canon/py/test_canon.py +91 -0
  32. consiliency_spec/_data/canon/ts/canon.test.ts +110 -0
  33. consiliency_spec/_data/canon/ts/canon.ts +294 -0
  34. consiliency_spec/_data/canon/vectors/canon-vectors.json +464 -0
  35. consiliency_spec/_data/canon/vectors/gov-cross-repo-canon-vectors.PROVENANCE.md +29 -0
  36. consiliency_spec/_data/canon/vectors/gov-cross-repo-canon-vectors.json +467 -0
  37. consiliency_spec/_data/consiliency-spec.public-manifest.json +211 -0
  38. consiliency_spec/_data/idmodel/correspondence/schema.json +98 -0
  39. consiliency_spec/_data/spec-engine/authority/authority-event.schema.json +183 -0
  40. consiliency_spec/_data/spec-graph/schema/spec-graph.schema.json +236 -0
  41. consiliency_spec/_data/spec-parity/SEMANTICS.md +716 -0
  42. consiliency_spec/_data/spec-parity/conformance/check.sh +81 -0
  43. consiliency_spec/_data/spec-parity/conformance/check_xg0_envelope.sh +113 -0
  44. consiliency_spec/_data/spec-parity/kind-alignment.json +316 -0
  45. consiliency_spec/_data/spec-parity/permitted-freedom-vocab.json +36 -0
  46. consiliency_spec/_data/spec-parity/schemas/certificate.schema.json +106 -0
  47. consiliency_spec/_data/spec-parity/schemas/kind-alignment.schema.json +107 -0
  48. consiliency_spec/_data/spec-parity/schemas/permitted-freedom-vocab.schema.json +62 -0
  49. consiliency_spec/_data/spec-parity/schemas/portal-payload.schema.json +145 -0
  50. consiliency_spec/_data/spec-parity/schemas/result-state.schema.json +105 -0
  51. consiliency_spec/_data/spec-parity/schemas/waiver.schema.json +72 -0
  52. consiliency_spec/py.typed +1 -0
  53. consiliency_spec-0.1.1.dist-info/METADATA +97 -0
  54. consiliency_spec-0.1.1.dist-info/RECORD +57 -0
  55. consiliency_spec-0.1.1.dist-info/WHEEL +4 -0
  56. consiliency_spec-0.1.1.dist-info/licenses/LICENSE +202 -0
  57. consiliency_spec-0.1.1.dist-info/licenses/NOTICE +34 -0
@@ -0,0 +1,78 @@
1
+ """Thin Python reader for the public Consiliency canon package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from importlib import resources
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ SPEC_PACKAGE = "consiliency-spec"
11
+ SPEC_NPM_PACKAGE = "@consiliency/spec"
12
+ SPEC_VERSION = "0.1.0"
13
+ __version__ = SPEC_VERSION
14
+
15
+ _MANIFEST = "consiliency-spec.public-manifest.json"
16
+
17
+
18
+ def _source_root() -> Path:
19
+ return Path(__file__).resolve().parent.parent
20
+
21
+
22
+ def _read_bytes(relative_path: str) -> bytes:
23
+ source_path = _source_root() / relative_path
24
+ if source_path.exists():
25
+ return source_path.read_bytes()
26
+ data_path = resources.files(__package__).joinpath("_data", relative_path)
27
+ return data_path.read_bytes()
28
+
29
+
30
+ def _read_text(relative_path: str) -> str:
31
+ return _read_bytes(relative_path).decode("utf-8")
32
+
33
+
34
+ def load_manifest() -> dict[str, Any]:
35
+ return json.loads(_read_text(_MANIFEST))
36
+
37
+
38
+ def list_public_files() -> list[str]:
39
+ return [item["path"] for item in load_manifest()["public_files"]]
40
+
41
+
42
+ def read_public_bytes(relative_path: str) -> bytes:
43
+ if relative_path not in set(list_public_files()):
44
+ raise ValueError(f"Unknown public canon file: {relative_path}")
45
+ return _read_bytes(relative_path)
46
+
47
+
48
+ def read_public_text(relative_path: str) -> str:
49
+ return read_public_bytes(relative_path).decode("utf-8")
50
+
51
+
52
+ def load_json(relative_path: str) -> dict[str, Any]:
53
+ return json.loads(read_public_text(relative_path))
54
+
55
+
56
+ def load_schema(name: str) -> dict[str, Any]:
57
+ if name.endswith(".schema.json"):
58
+ filename = name
59
+ else:
60
+ filename = f"{name}.schema.json"
61
+ for path in list_public_files():
62
+ if path.endswith(f"/{filename}"):
63
+ return load_json(path)
64
+ raise ValueError(f"Unknown public schema: {name}")
65
+
66
+
67
+ __all__ = [
68
+ "SPEC_PACKAGE",
69
+ "SPEC_NPM_PACKAGE",
70
+ "SPEC_VERSION",
71
+ "__version__",
72
+ "list_public_files",
73
+ "load_json",
74
+ "load_manifest",
75
+ "load_schema",
76
+ "read_public_bytes",
77
+ "read_public_text",
78
+ ]
@@ -0,0 +1,99 @@
1
+ # canon v2
2
+
3
+ The canonical-serialization + content-addressing contract for `spec` (spine contract **S1**,
4
+ roadmap Phase **0A**). One serialization, identical bytes in every language, content-addressed by
5
+ domain-separated SHA-256.
6
+
7
+ > **canon v1 → v2 (NFCBOUNDARY).** Unicode NFC moved OUT of `canonical_bytes` to the **ingestion
8
+ > boundary** (`py/canon_ingest.py`); canon v2 is Unicode-DB-independent and the digest prefix is
9
+ > `spec-canon:v2:`. v1 certificates stay valid *as v1 records* (never compared to v2; mixed-version
10
+ > graphs rejected). See [`SPEC.md`](./SPEC.md) §5/§8/§9. All other rules are unchanged.
11
+
12
+ - **Normative contract:** [`SPEC.md`](./SPEC.md) — read this first; it is the source of truth.
13
+ - **Conformance vectors:** [`vectors/canon-vectors.json`](./vectors/canon-vectors.json) — the
14
+ executable form of the contract (inputs → exact bytes → exact digest), generated by the Python
15
+ reference impl and reproduced byte-for-byte by the TypeScript one.
16
+ - **Reference impls:** [`py/canon.py`](./py/canon.py), [`ts/canon.ts`](./ts/canon.ts) — clean-room
17
+ custom encoders. Neither uses stdlib JSON (`json.dumps` / `JSON.stringify`) for canonical output.
18
+
19
+ ## The contract in one paragraph
20
+
21
+ `canonical_bytes(value)` produces a deterministic UTF-8 byte string for a restricted value domain
22
+ (objects with string keys, arrays, NFC strings, integers, booleans, null). Object keys are sorted
23
+ by **Unicode code point** at every depth; arrays **always** preserve insertion order; strings are
24
+ **already-NFC** (normalized once at the ingest boundary, NOT by canon v2) and emitted as raw UTF-8
25
+ with only `"`, `\`, and control chars U+0000–U+001F escaped (`\uXXXX` lowercase); **floats / NaN /
26
+ Infinity / unpaired surrogates are rejected**.
27
+ `digest(value, profile)` = lowercase-hex `SHA-256("spec-canon:v2:" + profile + "\n" || bytes)` over
28
+ one of four profiles (`semantic-content`, `run`, `artifact-byte`, `certificate`); a top-level
29
+ `digest` field is excluded from its own digest. Volatile/provenance data lives in a non-hashed
30
+ `locator` envelope, never in canonical content. **Produce once, store verbatim, never recompute.**
31
+
32
+ ## Quick use
33
+
34
+ Python:
35
+
36
+ ```python
37
+ from canon import canonical_bytes, digest, split_record
38
+ canonical_bytes({"b": 2, "a": 1}) # b'{"a":1,"b":2}'
39
+ digest({"id": "x"}, "semantic-content") # lowercase sha-256 hex
40
+ content, envelope = split_record(rec, ["id", "kind"]) # only `content` is hashed
41
+ ```
42
+
43
+ TypeScript (Node v24+, large ints as `bigint`):
44
+
45
+ ```ts
46
+ import { canonicalBytes, digest, splitRecord } from "./canon.ts";
47
+ canonicalBytes({ b: 2n, a: 1n }); // Uint8Array of {"a":1,"b":2}
48
+ digest({ id: "x" }, "semantic-content"); // lowercase sha-256 hex
49
+ ```
50
+
51
+ ## Running the conformance gate (THE fleet gate)
52
+
53
+ ```bash
54
+ bash canon/conformance/check.sh
55
+ ```
56
+
57
+ It (1) runs the Python impl against the pinned vectors, (2) runs the TypeScript impl against the
58
+ **same** vectors, and (3) asserts both produce **byte-identical** canonical output and identical
59
+ digests on every vector. Exit 0 = green; any divergence prints a diff and exits non-zero.
60
+
61
+ ### Prerequisites
62
+
63
+ - **Python 3** (stdlib `hashlib`) **plus `unicodedata2==16.0.0`** — the pinned Unicode 16.0 backport.
64
+ In canon v2, canon itself needs no Unicode DB (it no longer normalizes); the pin is required by the
65
+ **ingest boundary** (`py/canon_ingest.py`), which performs NFC and fail-closed-asserts the pinned
66
+ version at import (see `py/requirements.txt`; `check.sh` installs it). Stdlib `unicodedata` is
67
+ deliberately NOT used for ingest NFC: it is bound to the host CPython's Unicode version and would not
68
+ match the Node port byte-for-byte.
69
+ - **Node v24+** and **`tsx`** to run the `.ts` files. `check.sh` invokes `npx --yes tsx`, which
70
+ **fetches `tsx` from the npm registry on first run** — so the gate needs network access the first
71
+ time (or a pre-installed/vendored `tsx` for an offline CI box). Pin/vendor `tsx` if your CI is
72
+ air-gapped.
73
+
74
+ ## Regenerating vectors
75
+
76
+ `vectors/canon-vectors.json` is generated (never hand-authored) from the Python reference impl:
77
+
78
+ ```bash
79
+ python3 canon/vectors/gen_vectors.py
80
+ ```
81
+
82
+ Expected bytes (base64) and digests (hex) are pinned in the file; the TypeScript impl must
83
+ reproduce them. After regenerating, re-run `check.sh`. The file is written ASCII-safe
84
+ (`ensure_ascii=True`) purely as a transport so non-ASCII inputs and the lone-surrogate reject
85
+ vector survive identically under both `json.load` and `JSON.parse` — this does not affect canon
86
+ output, which emits non-ASCII raw.
87
+
88
+ ## Known cross-language traps this contract pins (and how it resolves each)
89
+
90
+ | Trap | Resolution |
91
+ |---|---|
92
+ | JS default key sort is UTF-16 code-unit order (wrong for astral) | sort by **code point** (`compareCodePoints`); vector `astral-key-sort` (U+E000 vs U+10000) fails any code-unit impl |
93
+ | `isinstance(True, int)` is True in Python | dispatch tests `bool` **before** `int`; vector `boolean-value` |
94
+ | `json.dumps`/`JSON.stringify` escaping & whitespace differ | custom encoder, no stdlib JSON for canonical output; control chars `\uXXXX` lowercase, no short escapes |
95
+ | `ensure_ascii` divergence | non-ASCII emitted **raw** in both |
96
+ | float formatting differs across langs | **floats forbidden**; caller pre-represents as string/scaled int |
97
+ | `NaN`/`Infinity` not valid JSON; `1.0`→float (py) vs int (js); int > 2^53 precision | vector inputs are **type-tagged** (`$int`/`$float`/`$nan`/`$inf`); TS uses `bigint` |
98
+ | lone surrogate: Python raises, JS emits U+FFFD | **rejected** in both; vector `reject-lone-surrogate` |
99
+ | NFC depends on Unicode version (skew between ports) | RESOLVED then RELOCATED (canon v2): NFC moved out of the hash to the ingest boundary, so canon is Unicode-DB-independent. The `unicodedata2==16.0.0` pin + fail-closed assertion + the `post13-*` discriminators now live in the ingest tests (`test_ingest_nfc.py` / `test_unicode_skew.py`), which prove the pin is load-bearing (the byte-identity gate can't, since canon v2 emits verbatim) |
@@ -0,0 +1,380 @@
1
+ # canon v2 — Canonical Serialization + Content-Addressing Contract
2
+
3
+ Status: **NORMATIVE**, frozen for v2. Owned by `spec` (Consiliency). This is the foundational
4
+ content-addressing contract (**S1**) on which the rest of the engine builds.
5
+
6
+ > **canon v1 → v2 (NFCBOUNDARY).** v2 is identical to v1 except that mandatory Unicode **NFC is no
7
+ > longer applied inside `canonical_bytes`** — it relocated to the **ingestion boundary** (callers
8
+ > deliver already-NFC content; see §5 and `py/canon_ingest.py`). canon v2 is therefore **independent
9
+ > of any Unicode DB version**: the `unicodedata2` pin and the fail-closed version assertion moved to
10
+ > the ingest boundary. The digest **domain prefix changed `spec-canon:v1:` → `spec-canon:v2:`** (§8),
11
+ > so a v2 digest can never collide with a v1 digest of the same input. Every other rule — key
12
+ > code-point sort, integers-only, lone-surrogate rejection, minimal escaping, the four profiles, the
13
+ > content/envelope split — is **unchanged**. Historical v1 certificates remain valid *as v1 records*;
14
+ > see §9. Driver: escape the Unicode-16.0 *in-hash* NFC pin so canon's byte-identity gate is no longer
15
+ > Unicode-version-coupled.
16
+
17
+ This document is the single source of truth. A conformance vector suite
18
+ (`vectors/canon-vectors.json`) ships with it and is the executable form of every rule below.
19
+ Two reference ports (Python `py/canon.py`, TypeScript `ts/canon.ts`) MUST agree **byte-for-byte**
20
+ on every vector. That cross-language byte-identity is the exit gate.
21
+
22
+ ---
23
+
24
+ ## 0. Scope and the one hard guarantee
25
+
26
+ `canon` defines **one** function over a restricted value domain:
27
+
28
+ ```
29
+ canonical_bytes(value) -> bytes # deterministic, language-independent UTF-8
30
+ digest(value, profile) -> hex string # SHA-256 over a domain-separated wrapping of those bytes
31
+ ```
32
+
33
+ The hard guarantee: **for any value in the supported domain, every conforming implementation in
34
+ every language produces the identical byte string and the identical digest.** Nothing else in the
35
+ system may content-address by re-serializing; see §9 (produce once, store verbatim).
36
+
37
+ ---
38
+
39
+ ## 1. Supported value domain
40
+
41
+ Canonical content is built only from these JSON-like types:
42
+
43
+ | Type | Notes |
44
+ |-------------|-------|
45
+ | object/map | string keys only; sorted by key (see §3) |
46
+ | array/list | insertion order preserved ALWAYS (see §4) |
47
+ | string | already-NFC (normalized at ingest, not by canon), UTF-8 (see §5) |
48
+ | integer | arbitrary precision; NO floats (see §6) |
49
+ | boolean | `true` / `false` lowercase |
50
+ | null | `null` lowercase |
51
+
52
+ **Explicitly rejected** (encoder MUST raise a clear error, never coerce):
53
+ floats / real numbers, `NaN`, `+Infinity`, `-Infinity`, non-string object keys, **unpaired
54
+ surrogate code points U+D800–U+DFFF in any string** (see §5), and any language-native type not in
55
+ the table (dates, sets, bytes, undefined, functions, etc.).
56
+ Decimals are the caller's responsibility — pre-represent them as strings or scaled integers
57
+ **before** handing a value to `canon`. This deliberately sidesteps cross-language float formatting,
58
+ the single largest source of divergence, for v1.
59
+
60
+ ---
61
+
62
+ ## 2. The vector-input encoding (type-tagged) — why JSON can't carry the inputs directly
63
+
64
+ The conformance vectors live in a JSON file, but **raw JSON cannot losslessly carry the inputs we
65
+ must test**, and the failure is silent and language-asymmetric:
66
+
67
+ - `NaN` / `Infinity` are not valid JSON: `JSON.parse` throws, Python `json.loads` accepts them.
68
+ - `1.0` parses to a float in Python (rejectable) but to the integer `1` in JS (`Number.isInteger`
69
+ is true) — so an "int vs float" vector would diverge purely from *parsing*, before the encoder runs.
70
+ - Integers above 2^53 lose precision under `JSON.parse`.
71
+
72
+ Therefore the `input` field of every vector is a **type-tagged tree** decoded by a tiny shared
73
+ decoder that is identical in both languages. Tags:
74
+
75
+ | Tag form | Decodes to |
76
+ |-------------------------|-----------|
77
+ | `{"$int": "123"}` | integer (decimal string, arbitrary precision, optional leading `-`) |
78
+ | `{"$float": "1.0"}` | a float marker — the encoder MUST reject it |
79
+ | `{"$nan": true}` | NaN marker — encoder MUST reject |
80
+ | `{"$inf": 1}` / `{"$inf": -1}` | +/-Infinity marker — encoder MUST reject |
81
+ | `{"$str": "..."}` | string (explicit; also used to carry a key that looks like a tag) |
82
+ | `{"$bool": true}` | boolean (explicit) |
83
+ | `{"$null": true}` | null (explicit) |
84
+ | `{"$obj": {k: <tagged>, ...}}` | object; keys are literal strings, values tagged |
85
+ | `{"$arr": [<tagged>, ...]}` | array; elements tagged |
86
+
87
+ A bare JSON `true`/`false`/`null`/string/array/object is also accepted by the decoder as the
88
+ obvious thing, but the canonical vectors use explicit tags wherever type intent is load-bearing
89
+ (numbers, booleans, the NaN/Inf/float rejections) so the file is unambiguous in every language.
90
+
91
+ The decoder is part of the **test harness**, not the canon contract; `canonical_bytes` operates on
92
+ already-decoded native values. But both ports MUST decode the file identically, so the decoder is
93
+ specified here and implemented identically in `py/canon.py` and `ts/canon.ts`.
94
+
95
+ ---
96
+
97
+ ## 3. Object keys — sorted by Unicode code point, at every depth
98
+
99
+ Keys are sorted ascending by **Unicode code point**, recursively at every nesting level.
100
+
101
+ **Trap (load-bearing):** JavaScript's default string comparison (`Array.prototype.sort` /
102
+ `<`) compares **UTF-16 code units**, which is WRONG for astral-plane characters (U+10000 and
103
+ above). A high-BMP character such as U+E000 must sort *before* an astral character U+10000, but
104
+ under code-unit comparison the astral char's leading surrogate (0xD800) compares as less than
105
+ 0xE000, flipping the order. Implementations MUST compare by code point (iterate code points /
106
+ `codePointAt`), never by raw UTF-16 unit. Python's default `str` comparison is already by code
107
+ point. The `astral-key-sort` vector exists specifically to fail any code-unit implementation.
108
+
109
+ Keys MUST be **already NFC** when they reach canon (canon v2 does NOT normalize — see §5). The
110
+ ingestion boundary (`py/canon_ingest.py`) NFC-normalizes keys **before** canon sees them, and that
111
+ is where the **key-collision check** now lives: if two distinct input keys normalize to the same NFC
112
+ string, the ingest boundary MUST raise an error (key collision), never silently overwrite. canon v2
113
+ sorts the already-NFC keys by code point as-is.
114
+
115
+ ---
116
+
117
+ ## 4. Arrays — insertion order preserved ALWAYS
118
+
119
+ Array element order is **never** changed by the serializer. Reordering an array yields different
120
+ canonical bytes and a different digest; this is intentional and tested (`array-order-matters`).
121
+ The only lists that may be ordered differently are those a *schema* explicitly declares
122
+ order-insensitive (e.g. candidate-id sets) — and that ordering is performed by the schema/caller
123
+ *before* canon sees the value. The serializer itself sorts nothing in arrays.
124
+
125
+ ---
126
+
127
+ ## 5. Strings — already-NFC, raw UTF-8, minimal escaping
128
+
129
+ 0. **Reject unpaired surrogates** (any code point U+D800–U+DFFF not part of a valid pair). This is
130
+ load-bearing for byte-identity: a lone surrogate makes Python's UTF-8 encoder *raise* while
131
+ JavaScript's `TextEncoder` silently emits U+FFFD (`EF BF BD`) — a silent cross-language byte
132
+ divergence. canon forbids it in both (vector `reject-lone-surrogate`). Surrogate rejection is a
133
+ validity rule and STAYS in canon v2 (it is not NFC).
134
+ 1. **canon v2 does NOT normalize NFC.** Strings reaching canon MUST already be NFC. NFC is applied
135
+ once at the **ingestion boundary** (`py/canon_ingest.py.normalize_string` / `normalize_tree`),
136
+ before any value reaches canon — see the boundary note below. (canon v1 normalized here; v2
137
+ relocated it to escape the in-hash Unicode-version pin.)
138
+ 2. Emit as UTF-8.
139
+ 3. **Escaping — escape only what is required:**
140
+ - `"` -> `\"`
141
+ - `\` -> `\\`
142
+ - every control character U+0000–U+001F -> `\uXXXX` with **lowercase** hex (e.g. newline U+000A
143
+ is emitted as the six-character sequence backslash-u-0-0-0-a, NOT `\n`). There are **no**
144
+ short escapes (`\n`, `\t`, `\b`, `\f`, `\r` are NOT produced).
145
+ - every other character, including all non-ASCII (é, 日本語, emoji, astral), is emitted as
146
+ **raw UTF-8** — never `\uXXXX`. This is the `ensure_ascii=False` equivalent. U+007F (DEL)
147
+ and other non-control characters are emitted raw; only U+0000–U+001F are escaped.
148
+
149
+ String output is `"` + escaped-contents + `"`.
150
+
151
+ > **Unicode-database pin (NFC determinism) — RELOCATED to the ingestion boundary (canon v2).** NFC
152
+ > is defined against a Unicode version, so wherever it is applied the implementations must use the
153
+ > SAME Unicode DB or NFC can diverge for codepoints assigned after the older DB. In canon **v1** NFC
154
+ > ran *inside* the hash, so `canonical_bytes` depended on the pinned Unicode DB. **canon v2 performs
155
+ > no NFC and depends on no Unicode DB version** — the pin moved to the ingest boundary
156
+ > (`py/canon_ingest.py`), which **pins Python to the `unicodedata2==16.0.0` backport** (NOT stdlib
157
+ > `unicodedata`, which is bound to the host CPython build — 13.0.0 on CPython 3.10) and **asserts at
158
+ > import time, fail-closed**, that its Unicode version reduces to `16.0`. All production ingest is
159
+ > Python; the TypeScript canon port is the conformance reference and needs no ingest normalizer.
160
+ > `conformance/check.sh` installs the pin and runs (a) canon self-conformance + cross-language
161
+ > byte-identity, and (b) the ingest-boundary tests — `test_ingest_nfc.py` (feed NON-NFC input through
162
+ > ingest → canon and assert the normalized form) and `test_unicode_skew.py` (the fail-closed assertion
163
+ > must fire on a simulated stale DB; the `post13-*` inputs must byte-diverge between a Unicode-13 DB
164
+ > and the pinned Unicode-16 DB). The ingest tests are load-bearing precisely because the byte-identity
165
+ > gate **cannot** catch a missing/wrong pin on its own: canon v2 passes bytes through verbatim, so
166
+ > both ports stay byte-identical even if ingest NFC were absent. The `post13-*` inputs exercise exactly
167
+ > the codepoints (U+0C3C ccc=7, U+0897 ccc=230, U+1715 ccc=9) that would diverge under the old skew.
168
+
169
+ ---
170
+
171
+ ## 6. Numbers — integers only
172
+
173
+ - **Integers only.** Emitted as the shortest decimal representation: optional leading `-`, then
174
+ digits with no leading zeros (except `0` itself), no `+`, no exponent, no decimal point, no
175
+ trailing `.0`. So both `1` and a hypothetical `1.0` input would have to be the *integer* `1`;
176
+ there is no `1.0` form because floats are rejected.
177
+ - **Floats, `NaN`, `+Infinity`, `-Infinity` are REJECTED** with a clear error (`allow_nan=false`
178
+ and more — no real numbers at all). The caller pre-represents decimals as strings or scaled
179
+ integers.
180
+ - Arbitrary precision: integers beyond 2^53 are supported. Python uses native `int`. TypeScript
181
+ uses `bigint` for emission so large integers do not lose precision; the `$int` decoder produces
182
+ a `bigint`, and the encoder accepts both `bigint` and a safe-range `number` that is an integer.
183
+ A non-integer `number` in TS (e.g. `1.5`) is treated as a float and REJECTED.
184
+
185
+ `true` / `false` / `null` are lowercase literals.
186
+
187
+ > **Python boolean trap (load-bearing):** `isinstance(True, int)` is `True` in Python, so the
188
+ > type dispatch MUST test `bool` **before** `int`, or `true` would serialize as `1`. JS has no
189
+ > such overlap. The `boolean-value` vector guards this asymmetry.
190
+
191
+ ---
192
+
193
+ ## 7. Structural separators
194
+
195
+ - Object: `{` + entries joined by `,` + `}`, each entry is `key-string` + `:` + value.
196
+ - Array: `[` + elements joined by `,` + `]`.
197
+ - **No insignificant whitespace anywhere** — no spaces after `:` or `,`, no newlines, no indent.
198
+ - Empty object is `{}`, empty array is `[]`.
199
+
200
+ ---
201
+
202
+ ## 8. Digest — domain-separated SHA-256
203
+
204
+ ```
205
+ digest(value, profile) = lowercase_hex( SHA-256( domain_prefix(profile) || canonical_bytes(value) ) )
206
+ domain_prefix(profile) = ASCII("spec-canon:v2:" + profile + "\n")
207
+ ```
208
+
209
+ - The `\n` is a single ASCII line feed (0x0a). The prefix is pure ASCII bytes, prepended to the
210
+ canonical UTF-8 bytes **before** hashing. Domain separation prevents a digest under one profile
211
+ from ever colliding with another profile's digest of the same content.
212
+ - **The `v2` literal in the prefix is the canon-version domain separator.** Because the prefix is
213
+ part of the digest preimage, a `spec-canon:v2:` digest can **never** collide with a `spec-canon:v1:`
214
+ digest of the same input — even for already-NFC content where canon v1 and v2 emit identical
215
+ `canonical_bytes`. This is what makes the v1→v2 boundary observable and the historical-cert policy
216
+ (§9) well-defined.
217
+ - **Digest profiles (the verbatim v1 contract set):**
218
+ - `semantic-content` — the meaning-bearing content of an artifact.
219
+ - `run` — a run record (kept separate from semantic content).
220
+ - `artifact-byte` — the byte identity of a stored artifact.
221
+ - `certificate` — a parity certificate.
222
+ - Hex is **lowercase**.
223
+ - **Digest-field exclusion:** if the top-level value is an object containing a key named `digest`,
224
+ that key/value pair is removed before canonicalization so a record can carry its own digest
225
+ without the digest depending on itself. Exclusion is **top-level only** (a nested object's
226
+ `digest` key is ordinary content). This is documented and tested (`digest-field-excluded`).
227
+ - **Preimage caveat (adopters read this):** exclusion happens **only in `digest()`**, not in
228
+ `canonical_bytes()`. So for a digest-bearing record, `canonical_bytes(record)` is **NOT** the
229
+ digest preimage — the preimage is `canonical_bytes(record_without_top_level_digest)`. The
230
+ `digest-field-excluded` vector reflects this: its stored `expected_canonical_bytes_b64`
231
+ *includes* the `digest` key, while its `expected_digest_hex` is computed over the stripped
232
+ record (and equals `digest-field-excluded-baseline`). Do not expect
233
+ `sha256(prefix || stored_bytes) == stored_digest` for such a record.
234
+
235
+ ### Non-hashed `locator` envelope
236
+
237
+ `locator` is **not** a digest profile — it is the documented home for volatile / provenance data
238
+ that MUST NEVER enter canonical content: wall-clock timestamps, absolute filesystem paths,
239
+ locale-dependent collation, hostnames, PIDs, ephemeral run ids. See §10.
240
+
241
+ ---
242
+
243
+ ## 9. Produce once, store verbatim, never recompute
244
+
245
+ A digest and its canonical bytes are produced **once**, at authorship, and stored verbatim.
246
+ Consumers compare the **stored** bytes/digest; **any consumer that re-serializes a value to
247
+ compare it is a bug.** The public API is intentionally just `canonical_bytes(value)` and
248
+ `digest(value, profile)` — there is no "re-canonicalize and diff" helper, by design.
249
+
250
+ ### Historical v1 certificates (canon v1→v2 policy — NORMATIVE)
251
+
252
+ The canon v1→v2 boundary is a **certificate-version boundary**. The policy (chosen for NFCBOUNDARY):
253
+
254
+ - **A v1 certificate stays valid *as a v1 record*.** It is identified by `canon_version = "v1"` and a
255
+ `spec-canon:v1:` digest domain. v2 is the new authoring default (`canon_version = "v2"`,
256
+ `spec-canon:v2:`); v1 artifacts are read-only history.
257
+ - **A v1 digest is NEVER compared against a v2 digest.** Per "produce once, store verbatim, never
258
+ recompute" above, a stored v1 digest is never re-serialized under v2 (and the `v2` prefix makes the
259
+ two domains provably disjoint, §8). Consumers branch on `canon_version`.
260
+ - **A mixed-`canon_version` graph is REJECTED at the consumer** (single-`canon_version` invariant) —
261
+ never silently re-hashed to reconcile the two.
262
+
263
+ Downstream certificate consumers cut over to v2 **wholesale**;
264
+ the v1→v2 boundary is a metadata-only notification to them, not an in-place re-hash.
265
+
266
+ ---
267
+
268
+ ## 10. Splitting content from envelope (volatile data)
269
+
270
+ Volatile/provenance data must live outside hashed content. The reference impls provide a
271
+ documented helper:
272
+
273
+ ```
274
+ split_record(record, content_keys) -> (content, envelope)
275
+ ```
276
+
277
+ `content` holds only the keys in `content_keys` (hashed via `canonical_bytes` / `digest`);
278
+ `envelope` holds everything else (the non-hashed locator: wall-clock, abs paths, locale, host,
279
+ pid, etc.). Changing envelope fields MUST NOT change the digest — proven by the
280
+ `content-envelope-split` vector, where the same content with two different envelopes yields the
281
+ identical `semantic-content` digest.
282
+
283
+ ---
284
+
285
+ ## 11. Conformance and the exit gate
286
+
287
+ `vectors/canon-vectors.json` is an array of:
288
+
289
+ ```json
290
+ {
291
+ "name": "...",
292
+ "input": <type-tagged tree, or {"$error": "..."} for reject cases>,
293
+ "profile": "semantic-content | run | artifact-byte | certificate",
294
+ "expected_canonical_bytes_b64": "<base64 of the canonical UTF-8 bytes>",
295
+ "expected_digest_hex": "<lowercase sha-256 hex>"
296
+ }
297
+ ```
298
+
299
+ Reject vectors (float/NaN/Inf) carry `"expect_error": true` instead of expected bytes/digest;
300
+ both ports MUST raise.
301
+
302
+ The expected values are **generated by the Python reference impl** (never hand-authored base64/hex)
303
+ and pinned in the file. The TS impl must reproduce them.
304
+
305
+ **EXIT GATE** — `bash conformance/check.sh` (canon v2):
306
+ 0. installs the ingest-boundary Unicode pin (`unicodedata2`), then
307
+ 1. runs `py/test_canon.py` (Python canon vs the pinned vectors), and
308
+ 2. runs `ts/canon.test.ts` (TS canon vs the **same** pinned vectors), and
309
+ 3. runs the **ingest-boundary** tests — `conformance/test_ingest_nfc.py` (NON-NFC input through
310
+ ingest → canon yields the normalized form) and `conformance/test_unicode_skew.py` (the fail-closed
311
+ pin assertion fires on a stale DB; the `post13-*` inputs are live U13-vs-U16 discriminators), and
312
+ 4. asserts Python and TS canon produced byte-identical canonical output and identical digests on
313
+ **every** vector.
314
+
315
+ Step 3 is load-bearing and not redundant: canon v2 emits bytes verbatim, so the cross-language
316
+ byte-identity check (step 4) would stay green even if ingest NFC were missing entirely — only the
317
+ ingest tests prove NFC enforcement actually lives at the boundary and that its pin is load-bearing.
318
+
319
+ If any vector cannot be made byte-identical across languages, that is a contract defect to be
320
+ reported and fixed — never papered over.
321
+
322
+ ## 12. canon-core API freeze (IF-0-XG4-1)
323
+
324
+ `canon/core` is the single portable implementation of the canon v2 algorithm. The frozen core
325
+ surface is:
326
+
327
+ - `canonical_bytes(value) -> bytes`
328
+ - `digest(value, profile) -> lowercase sha256 hex`
329
+ - `canonical_bytes_from_json(tagged_json) -> bytes`
330
+ - `digest_from_json(tagged_json, profile) -> lowercase sha256 hex`
331
+
332
+ The JSON entrypoints consume the same type-tagged tree used by `vectors/canon-vectors.json`.
333
+ The Rust core keeps NFC out of the hash exactly like the Python and TypeScript references: callers
334
+ deliver already-normalized content from the ingest boundary, and `digest` applies
335
+ `spec-canon:v2:<profile>\n || canonical_bytes(strip_top_level_digest(value))`.
336
+
337
+ The binding surfaces are thin wrappers over that core:
338
+
339
+ - WASM exports `canonicalBytesFromJson` and `digestFromJson`.
340
+ - PyO3 exports `canonical_bytes_from_json` and `digest_from_json` from module `canon_core`.
341
+
342
+ `conformance/check_xg4_canon_core.sh` is the XG4 exit gate. It runs the existing Python/TypeScript
343
+ canon gate, runs Rust vector tests, diffs Python/TypeScript/Rust emitted bytes and digests over the
344
+ full corpus, and compiles both binding surfaces. Consumers must dual-run against this corpus before
345
+ removing a vendored implementation.
346
+
347
+ ## Open items (0A follow-ups, tracked)
348
+ - **In-hash Unicode-version coupling. — RESOLVED by canon v2 / NFCBOUNDARY (2026-06).** Even after the
349
+ 0A pin below, canon v1's `canonical_bytes` *itself* still depended on the pinned Unicode DB (NFC ran
350
+ in-hash), so the byte-identity gate stayed Unicode-version-coupled. **canon v2 relocates NFC to the
351
+ ingestion boundary** (`py/canon_ingest.py`): canon no longer normalizes and no longer depends on any
352
+ Unicode DB; the `unicodedata2==16.0.0` pin + fail-closed assertion + the post-13 discriminators moved
353
+ to the ingest tests, and the digest prefix bumped `v1`→`v2` (§8). The pin **relocated, it did not
354
+ vanish** — a deterministic Unicode DB is still required at ingest for cross-language NFC. See §5, §8,
355
+ §9 (historical-cert policy).
356
+ - **`idmodel` boundary-id NFC (follow-on, NOT addressed here).** Removing canon's in-hash NFC *unmasks*
357
+ `idmodel`'s own NFC: `idmodel/py/idmodel.py` normalizes occurrence-id content with **stdlib**
358
+ `unicodedata` (host-CPython Unicode version), and under v1 canon (unicodedata2 16.0) re-normalized that
359
+ content so canon was authoritative. Under v2 canon no longer re-NFCs, so idmodel's stdlib NFC becomes
360
+ authoritative for occurrence-ids — a possibly-unpinned Unicode dependency. All committed idmodel content
361
+ is NFC-invariant (verified), so no current artifact is affected; harmonizing idmodel onto the same
362
+ pinned ingest normalizer is a named follow-on, out of NFCBOUNDARY's "NFC placement only" scope.
363
+ - **Unicode DB version skew (0A). — RESOLVED (2026-06).** Previously: Python stdlib `unicodedata` (13.0.0 on
364
+ CPython 3.10) vs Node Unicode 16.0, leaving a determinism hole for identifiers using codepoints assigned
365
+ *after* Unicode 13 (their NFC could differ between ports). Resolution (canon v1; the pin now lives at the
366
+ v2 ingest boundary):
367
+ - **Pinned Unicode version: 16.0** (matches Node's `process.versions.unicode`).
368
+ - **Python NFC now uses the `unicodedata2==16.0.0` backport** (exact pin in `py/requirements.txt`), NOT
369
+ stdlib `unicodedata`. `unicodedata2.unidata_version` is `16.0.0` → reduces to `16.0`, byte-identical NFC
370
+ to Node ICU 16.0 (confirmed on every vector by the cross-language gate).
371
+ - **Fail-closed version assertion at import time** in BOTH ports (`canon.py` and `canon.ts`): the Unicode
372
+ version they use must reduce to `16.0` or they refuse to load — a mismatch can never silently diverge.
373
+ - **Post-13 conformance vectors** added (`post13-nfc-reorder-telugu-nukta`, `…-arabic-pepet`,
374
+ `…-two-new-marks`) using combining marks assigned in Unicode 15.0/16.0 (U+0C3C ccc=7, U+0897 ccc=230,
375
+ U+1715 ccc=9). Their NFC canonical-reordering differs under a Unicode-13 DB (those marks read as ccc=0)
376
+ vs the pinned Unicode-16 DB — verified byte-divergent by `conformance/test_unicode_skew.py`. They pass
377
+ now because both ports use 16.0.
378
+ - **Gate enforcement:** `conformance/check.sh` installs the pin, hard-asserts both ports report `16.0`, runs
379
+ the negative tests (simulated stale DB must trip the assertion; post-13 vectors must byte-diverge under a
380
+ U13 DB), then runs the cross-language byte-identity check over all vectors.
@@ -0,0 +1,14 @@
1
+ # canon-core PyO3 binding
2
+
3
+ The canonical algorithm lives in `canon/core`. Build the PyO3 surface with:
4
+
5
+ ```bash
6
+ cargo build --manifest-path canon/core/Cargo.toml --features pyo3-binding
7
+ ```
8
+
9
+ The Python module is named `canon_core` and exposes:
10
+
11
+ - `canonical_bytes_from_json(tagged_json: str) -> bytes`
12
+ - `digest_from_json(tagged_json: str, profile: str) -> str`
13
+
14
+ Inputs are the existing type-tagged vector JSON shape used by `canon/vectors/canon-vectors.json`.
@@ -0,0 +1,14 @@
1
+ # canon-core WASM binding
2
+
3
+ The canonical algorithm lives in `canon/core`. Build the WASM surface with:
4
+
5
+ ```bash
6
+ cargo build --manifest-path canon/core/Cargo.toml --features wasm-binding --target wasm32-unknown-unknown
7
+ ```
8
+
9
+ The exported JS names are:
10
+
11
+ - `canonicalBytesFromJson(taggedJson: string): Uint8Array`
12
+ - `digestFromJson(taggedJson: string, profile: string): string`
13
+
14
+ Inputs are the existing type-tagged vector JSON shape used by `canon/vectors/canon-vectors.json`.
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # canon v2 EXIT GATE (THE fleet gate, SPEC.md section 11).
4
+ #
5
+ # canon v2 relocated mandatory Unicode NFC OUT of the hash to the ingestion boundary
6
+ # (canon/py/ingest.py), so canon itself is Unicode-DB-independent. The gate asserts:
7
+ # 1. Python canon passes its own conformance (verbatim bytes + digest match the pinned vectors).
8
+ # 2. TypeScript canon passes its own conformance (same pinned vectors).
9
+ # 3. INGEST-boundary NFC is enforced and load-bearing: non-NFC input is normalized at ingest
10
+ # (test_ingest_nfc.py), and the pinned Unicode DB / post-13 skew discriminator is live and the
11
+ # fail-closed version assertion fires (test_unicode_skew.py). This is what the byte-identity
12
+ # check below STRUCTURALLY CANNOT prove — canon v2 passes bytes through verbatim, so both ports
13
+ # stay byte-identical even if ingest NFC were missing entirely.
14
+ # 4. Python and TypeScript canon produce BYTE-IDENTICAL output + IDENTICAL digests on every vector
15
+ # (the cross-language byte-identity gate).
16
+ #
17
+ # Exit 0 = gate green. Any divergence -> non-zero with a diff. No fudging.
18
+
19
+ set -euo pipefail
20
+
21
+ CANON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
22
+ PY="$CANON_DIR/py"
23
+ TS="$CANON_DIR/ts"
24
+ TMP="$(mktemp -d)"
25
+ trap 'rm -rf "$TMP"' EXIT
26
+
27
+ echo "== canon v2 conformance gate =="
28
+ echo "canon dir: $CANON_DIR"
29
+ echo
30
+
31
+ # --- 0. Pinned Unicode DB for the INGEST boundary (fail-closed) ---
32
+ # canon v2 no longer needs a Unicode DB, but the INGEST boundary (canon/py/ingest.py) does — that is
33
+ # where NFC now lives, pinned to Node's Unicode version. Install the exact pin so the ingest tests in
34
+ # step 3 are reproducible in a clean CI (not just where it happens to be present).
35
+ echo "[0/4] Pinned Unicode DB for the ingest boundary (unicodedata2)"
36
+ python3 -m pip install --quiet --disable-pip-version-check -r "$PY/requirements.txt"
37
+ python3 -c "import unicodedata2; print(' unicodedata2', unicodedata2.unidata_version)"
38
+ echo
39
+
40
+ # --- 1. Python canon self-conformance ---
41
+ echo "[1/4] Python canon conformance (vs pinned vectors)"
42
+ python3 "$PY/test_canon.py"
43
+ echo
44
+
45
+ # --- 2. TypeScript canon self-conformance ---
46
+ echo "[2/4] TypeScript canon conformance (vs pinned vectors)"
47
+ npx --yes tsx "$TS/canon.test.ts"
48
+ echo
49
+
50
+ # --- 3. Ingest-boundary NFC enforcement (the byte-identity gate CANNOT prove this) ---
51
+ # canon v2 emits bytes verbatim, so step 4 would stay green even if ingest NFC vanished. These two
52
+ # tests are the ONLY proof that NFC enforcement actually lives at the ingest boundary and that its
53
+ # pinned Unicode DB is load-bearing: test_ingest_nfc feeds NON-NFC input through ingest->canon and
54
+ # asserts the normalized form; test_unicode_skew proves the fail-closed pin assertion fires on a
55
+ # stale DB and that the post-13 inputs are live U13-vs-U16 discriminators.
56
+ echo "[3/4] Ingest-boundary NFC enforcement + pinned-DB skew proof"
57
+ python3 "$CANON_DIR/conformance/test_ingest_nfc.py"
58
+ python3 "$CANON_DIR/conformance/test_unicode_skew.py"
59
+ echo
60
+
61
+ # --- 4. Cross-language byte-identity ---
62
+ echo "[4/4] Cross-language byte-identity (Python emit vs TypeScript emit)"
63
+ python3 "$PY/test_canon.py" --emit > "$TMP/py.json"
64
+ npx --yes tsx "$TS/canon.test.ts" --emit > "$TMP/ts.json"
65
+
66
+ if diff -u "$TMP/py.json" "$TMP/ts.json" > "$TMP/diff.txt"; then
67
+ N=$(wc -l < "$TMP/py.json" | tr -d ' ')
68
+ echo " Python and TypeScript agree byte-for-byte on all $N vectors."
69
+ echo
70
+ echo "CANON v2 CONFORMANCE GATE: PASS"
71
+ exit 0
72
+ else
73
+ echo " DIVERGENCE between Python and TypeScript:"
74
+ cat "$TMP/diff.txt"
75
+ echo
76
+ echo "CANON v2 CONFORMANCE GATE: FAIL"
77
+ exit 1
78
+ fi