P1.5.4 — Canonical Serialization — Verification
Step 5 of the 5-step chain (CLAUDE.md §6).
§1. Identity
Branch: feature/p1-5-4-canonical
Base: 7218b34b (post-R84, P1.2.2 Parser merged via #205)
Worktree: .worktrees/claude/p1-5-4-canonical
Implementation commit: cbde608a
Files added:
src/domains/rules/canonical.ts(302 LOC)src/__tests__/domains/rules/canonical.test.ts(594 LOC)docs/audits/p1-5-4-canonical-audit.mddocs/contracts/p1-5-4-canonical-contract.mddocs/packets/p1-5-4-canonical-packet.mddocs/verification/p1-5-4-canonical-verification.md(this file)
§2. Acceptance criteria — evidence
| Criterion | Expected | Observed | Pass |
|---|---|---|---|
canonicalize(ast_or_ruleset): string |
Byte-identical output across calls | All G6 fixtures pass | ✅ |
| Keys sorted at every object level | Codepoint order | G3 covers reverse insertion, mixed case, special chars, numeric-looking, Object.create(null) | ✅ |
| No whitespace in output | Single-line JSON | All fixtures (G1–G5) verify exact strings with no spaces | ✅ |
| Integer literals preserved | bigint → decimal, no exponent, no n |
G1 covers 0n, 13n, -7n, 9999999999999999999n, -9999999999999999999n |
✅ |
| String escapes use canonical JSON form | \", \b, \f, \n, \r, \t, \uXXXX |
G2 covers all seven single-char escapes, NUL, 0x01, 0x0b, 0x0e, 0x0f, 0x1a, 0x1f | ✅ |
| Idempotent round-trip | canonicalize(parse(x)) stable |
G6 includes property test of 100 random AST shapes; all idempotent. DSL fixture parses + canonicalizes identically across separate calls | ✅ |
| No locale dependence | Codepoint sort, not locale-aware | G7 forces LANG=tr_TR.UTF-8 and LC_ALL=tr_TR.UTF-8; sort order unchanged |
✅ |
§3. Test gates
3.1 Build
$ npm run build
> tsc
> node scripts/copy-migrations.mjs
copy-migrations: copied 6 migration(s) ...
✅ Pass — strict TypeScript with noUncheckedIndexedAccess + exactOptionalPropertyTypes accepts the implementation.
3.2 Lint
$ npm run lint
> eslint src
(no errors, no warnings)
✅ Pass.
3.3 Test
$ npm test
Test Suites: 33 passed, 33 total
Tests: 1535 passed, 1535 total
Snapshots: 0 total
Time: 27.393 s
✅ Pass — 1535/1535. Baseline at base SHA was 1467; this slice adds 68 new tests (10 groups × ~6.8 avg). No regression.
3.4 Determinism corpus self-scan
src/__tests__/domains/rules/determinism.test.ts §Group 12 scans every *.ts
in src/domains/rules/ (except determinism.ts) for forbidden tokens:
Math.*, Date.*, new Date, setTimeout/setInterval/setImmediate,
fetch, XMLHttpRequest, require('fs'), from 'fs', crypto.*,
process.hrtime/process.nextTick, await, async function, float
literals, [native code].
PASS src/__tests__/domains/rules/determinism.test.ts
rule-engine corpus self-scan
✓ no forbidden tokens in src/domains/rules/*.ts (excluding determinism.ts + __tests__)
✅ Pass — canonical.ts introduces no forbidden tokens.
3.5 Coverage
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
canonical.ts | 96.62 | 92.30 | 100 | 96.62 | 205,259-260
✅ Above the contract gate (≥ 95% lines, ≥ 90% branches). The three uncovered lines are defensive guards:
- Line 205 —
unrecognised runtime typebranch. Reachable only for hypothetical exotic runtime types (e.g. legacydocument.allwhosetypeofwas"undefined"). Cannot be triggered from JavaScript today. - Lines 259–260 —
keys[j] === undefinedearly-continue inside the object-keys loop.Object.keys()returns a non-sparse string array;noUncheckedIndexedAccessforces the type check, but the runtime path is unreachable for our bound.
These are kept as belt-and-suspenders defenses against future refactors.
§4. Round-trip evidence
The G6.2 test parses the canonical AcceptCommitment fixture, canonicalizes it twice, and asserts string-equality. Then it re-parses the same DSL into a different JS object graph and asserts the canonical bytes still match the first run:
test('canonicalize of a real κ DSL parse is stable', () => {
const dsl =
'rule AcceptCommitment { guards { $a > 0 -> admit } effects { record($a) } }';
const r1 = parse(dsl);
expect(r1.errors).toEqual([]);
expect(r1.ast).toHaveLength(1);
const c1 = canonicalize(r1.ast);
const c2 = canonicalize(r1.ast);
expect(c1).toBe(c2);
const r2 = parse(dsl);
const c3 = canonicalize(r2.ast);
expect(c3).toBe(c1);
});
This satisfies the task-breakdown.md §P1.5.4 idempotence acceptance.
§5. Locale-independence evidence
test('Turkish locale does not alter sort order', () => {
process.env.LANG = 'tr_TR.UTF-8';
// 'I' = 0x49, 'i' = 0x69, 'İ' = 0x130
const obj = { 'İ': 1, i: 2, I: 3 };
expect(canonicalize(obj)).toBe('{"I":3,"i":2,"İ":1}');
// ...restore env...
});
Under localeCompare(undefined, { locale: 'tr-TR' }) the dotted/dotless-i
collation merges ‘I’/’İ’ and ‘i’/’ı’ — but Array.prototype.sort default
on strings is the abstract < operator, which is UTF-16 code unit
comparison and does not consult locale state. The fixture above proves the
sort is locale-independent.
A second fixture (LC_ALL=tr_TR.UTF-8) confirms sort behaviour is also
independent of LC_ALL.
§6. Property-test evidence
test('property: 100 random AST shapes are idempotent under canonicalize', () => {
const rng = makeSeededRng(0xc0ffee); // deterministic LCG
// generate 100 trees of mixed atoms, arrays, plain objects;
// depth-bounded at 6, ASCII-printable strings, small integers/bigints;
// canonicalize each twice, assert byte-identity.
let trial = 0;
while (trial < 100) {
const v = genValue(6);
expect(canonicalize(v)).toBe(canonicalize(v));
trial = trial + 1;
}
});
Seed 0xc0ffee makes the run deterministic — re-running produces identical
shapes and identical canonical strings. No Math.random is used (the LCG
is internal to the test).
§7. Quota-safety + commit ordering
Per the round prompt’s quota mitigation policy, the implementation was committed before verification was written:
| # | Commit SHA | Message |
|---|---|---|
| 1 | ad57ae8e |
audit(p1-5-4-canonical): inventory surface |
| 2 | fb1672f1 |
contract(p1-5-4-canonical): behavioral contract |
| 3 | 6392e4bc |
packet(p1-5-4-canonical): execution plan |
| 4 | cbde608a |
feat(p1-5-4-canonical): byte-identical json serialization |
| 5 | (this commit) | verify(p1-5-4-canonical): test evidence |
The branch was pushed to origin/feature/p1-5-4-canonical after commit #4
to maximise PR-readiness in case of mid-session quota exhaustion.
§8. P1.5.1 unblocking
This module is the input to P1.5.1 Version Hash Computation:
// P1.5.1 (future):
const canonical = canonicalize(rules);
const hash = sha256(canonical + ENGINE_VERSION);
Because canonicalize produces byte-identical output across platforms,
the rule_version_hash is now computable in a way that two arbiters can
independently agree on. This satisfies the docs/3-world/physics/laws/
rule-engine.md §Rule versioning requirement that “a silent rule change
is impossible: either all arbiters upgrade together, or the upgrade
creates a fork under RULE_UPGRADE.”
§9. Summary
✅ All seven contract acceptance criteria satisfied.
✅ All three CI gates (build, lint, test) green.
✅ Determinism corpus self-scan green.
✅ Coverage on canonical.ts 96.62% lines, 92.30% branches — above gate.
✅ No regression on the 1467-test baseline; 1535/1535 passing.
✅ Branch pushed to origin; ready for PR.
P1.5.4 ready for merge.