Skip to content

module: exact JSON equality (cic-canonical-json/1); Plan no longer calls different values a noop - #42

Merged
sinkog merged 1 commit into
develfrom
feature/canonical-json-exact-equality
Nov 8, 2026
Merged

sinkog merged 1 commit into
develfrom
feature/canonical-json-exact-equality

Conversation

@sinkog

@sinkog sinkog commented Nov 8, 2026

Copy link
Copy Markdown
Member

What

canonicalEqual re-encoded both sides through encoding/json, so every number became a float64: two different integers above 2^53 compared equal and Plan answered noop for a real change (reproduced on a real Plan call: plan = "noop" with 0 operations). A repeated object key was resolved to the last one, and an invalid UTF-8 byte or a lone surrogate escape became U+FFFD, so distinct texts could collapse into one.

This PR replaces it with cic-canonical-json/1, a strict parser and writer of its own, and makes Plan use it. It is the prerequisite the owner set before M4a (the plan re-derivation must not rest on a wrong equality).

  • Numbers are exact (no float64): written as <digits>e<exponent>; 1, 1.0, 1e0, 10e-1 are one value, 2^53+1 differs from 2^53, 0.1 differs from 0.10000000000000001, zero in any spelling is 0.
  • Object keys sorted by decoded UTF-8 bytes; a repeated key is refused; arrays keep order.
  • Strings decoded and written with only quote, backslash and controls (\u00xx, lower case) escaped, no HTML escaping; a lone or mismatched surrogate, invalid UTF-8, a BOM, raw control characters are refused.
  • Bounds (refused, not truncated): input 1 MiB, nesting 64, 1000 digits, exponent 6 written digits and |10000| normalized. Errors carry the byte offset and reason, never the text.
  • Plan refuses desired.data / observed.data that are not canonicalizable (validation, field_path: desired.data / observed.data) and compares values (whole payloads and field by field) by the canonical form.
  • Not Go-only: the expectations are language-neutral vectors in module/testdata/canonical_json_vectors.json (25 canonical outputs, 9 equal, 11 distinct, 40 refused incl. byte-level cases); the rules are in docs/design/specs/canonical-json.md (also why it is not JCS: ES6 float formatting is the loss this avoids).

Evidence

  • RED on the old code: the noop reproduction fails; duplicate-key and too-deep payloads were accepted (ok instead of validation); equivalence controls (whitespace, key order, 1 vs 1.0, escapes) pass on both. An earlier RED run was invalid (non-hex schema_hash in my test setup) and was redone; only the rerun counts.
  • GREEN: full suite; gofmt, vet, staticcheck clean; the four real-WASM tests PASS against a fresh guest (so TinyGo compiles it). No existing test changed (including observe_derivation_test, which uses canonicalEqual).
  • Mutants (13 compiling): float64 numbers, duplicate-key check off, lone-surrogate repair, depth/digits/input/exponent bounds off, UTF-8 check off, key sort off, HTML escaping, trailing-zero stripping off, the Plan check off -> each fails a test. One mutant (raw-bytes fallback on one side after an error) survives and is equivalent: an invalid text can never equal a valid canonical form byte for byte; the stronger variant (both sides) is killed.
  • A deterministic mutation fuzz (4000 cases, fixed seed) never panics and every accepted output is a fixed point.
  • My own first vector file had wrong expected outputs ({"a":2} instead of {"a":2e0}); the tests caught it, the vectors were corrected, the implementation was not changed for it.

Limits / not in this PR

  • validate and the other operations are unchanged. The M4a digests (plan_digest, target_digest, execution_digest) are not here; this only fixes the equality they will rest on.
  • A behaviour change on purpose: Plan now refuses duplicate-key and over-bound payloads that it used to read leniently.
  • Not a production GO. module.wasm not rebuilt or re-signed. No merge without explicit owner GO.

🤖 Generated with Claude Code

…lls different values a noop

canonicalEqual re-encoded both sides through encoding/json, so every number
became a float64: two different integers above 2^53 compared equal and Plan
answered noop for a real change; a repeated object key was resolved to the last
one; an invalid UTF-8 byte or a lone surrogate escape became U+FFFD.

cic-canonical-json/1 is a strict parser and writer of its own: exact numbers
(<digits>e<exponent>, so 1, 1.0 and 1e0 are one value and 2^53+1 differs from
2^53), keys sorted by UTF-8 bytes, a repeated key or lone surrogate refused,
only the quote, backslash and controls escaped, bounds on input size, nesting,
digits and exponent. Its expected results are language-neutral vectors in
testdata/canonical_json_vectors.json. Plan refuses desired.data and
observed.data that are not canonicalizable (validation) and compares values by
the canonical form. The noop reproduction, the vectors, the bounds and a
deterministic mutation fuzz are tests. Plan digests (M4a) are separate.

---
[signing-metadata]
key = cic-my-sign-key
signature = vault:v1:MEQCIHNLToZ7X+thijfXoqS2ALOfTHOeGvZVK7hCOj3Q0uX+AiBB1LDOLk9O4Ne22lgxHEWRyoZKIJNXFmYUfoj1gpN/gw==
hash-algorithm = sha256
digest = 9q1QUR2K4a+XpIoRGaT2dmnSIHZppO5opzHywkMyEUM=

[certificate]
-----BEGIN CERTIFICATE-----
MIICBjCCAaygAwIBAgIUSnRMR6RPnEbg296XWPOqq/u5PCwwCgYIKoZIzj0EAwIw
QzELMAkGA1UEBhMCSFUxGTAXBgNVBAoMEENlbnRyYWxJbmZyYUNvcmUxGTAXBgNV
BAMMEENJQyBEZXZlbG9wZXIgQ0EwHhcNMjYwMzIwMTMyMjU5WhcNMjYxMjMxMTMy
MjU5WjBFMQswCQYDVQQGEwJIVTEZMBcGA1UECgwQQ2VudHJhbEluZnJhQ29yZTEb
MBkGA1UEAwwSR2Fib3IgWm9sdGFuIFNpbmtvMFkwEwYHKoZIzj0CAQYIKoZIzj0D
AQcDQgAEIG2CVmTfmLB9pLLclj7YmP2eedAjklpy4LGrU2ijoiy6Xqpuybv7OgJe
i+ez31s65NEV8+X/ByeX1cstR988z6N8MHowCQYDVR0TBAIwADAdBgNVHQ4EFgQU
yZN6AIX/TNnIJ9GwAa/NRN3ujHAwHwYDVR0jBBgwFoAUXn6CHYzPUqU4JVP8g+OS
WeDYjhcwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEF
BQcDBDAKBggqhkjOPQQDAgNIADBFAiEA+bFzXRoJ4PCQbhAAtpkcMjt0vNj5rEW0
lOMBGDNyaWkCIB1vmM7PcZzv/c9bIrxF5kqv6QXomouhByUfeNUTbpKW
-----END CERTIFICATE-----
@sinkog
sinkog marked this pull request as ready for review November 8, 2026 12:11
@sinkog
sinkog merged commit 9ce8a79 into devel Nov 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant