Canonical forms
RFC 8785 JCS and the rules Manav adds on top of it.
Canonical forms
Last updated 24 September 2026
The canonical form is chosen by the object's version field, so a verifier never guesses.
JCS (RFC 8785) — everything from receipt v2 onward
Implementation: core/jcs.php (manav_jcs), js/jcs.js (manavJcs), packages/verify-py/manav_verify/jcs.py. Vectors: tests/vectors/jcs/ (the six RFC 8785 test files plus 37 manav vectors); php tests/jcs-vectors.php, node tests/jcs-vectors.mjs; differential test over generated values in tests/run.php group 10.
Rules on top of RFC 8785:
- Key order is by UTF-16 code unit (RFC 8785 §3.2.3). PHP's
strcmpsorts UTF-8 bytes, which diverges for non-BMP characters: for the keys U+E000 and U+10000, byte order givesE000, 10000; JCS gives10000, E000, because the surrogate pair starts at D800.core/jcs.phpconverts keys to UTF-16BE before comparing. JavaScript's default sort already compares code units. - Integers only. Floats are rejected (
float_not_permitted) rather than serialised, so no two implementations can disagree about ECMAScript number formatting. Integers must lie within ±(2^53 − 1). Money is a fixed-precision string with an explicit currency. - Strings (rule R5): NFC-normalised; bidi controls, zero-width and other default-ignorable code points, BOM, soft hyphen, variation selectors and tag characters are rejected (
r5_unsafe_string:invisible_or_bidi); C0/C1 controls are rejected (:control); mixing scripts among letters inside one string is rejected (:mixed_script), except Latin with CJK. - Empty object vs empty array is preserved: PHP callers pass objects as
stdClass(manav_to_object, orjson_decode(…, false)).
Signing input for hybrid-signed objects
M = UTF8(object["v"]) || 0x0A || JCS(object without "sig"). The type tag inside the signed bytes prevents cross-type replay (a lease signature can never be presented as a receipt signature). Receipt v2 uses the tag manav-receipt/2 even though its stored v is the integer 2.
Legacy v1 form — frozen
core/canonical.php::manav_canonical_encode_legacy_v1: recursive ksort (byte order) + json_encode(JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES). It is not RFC 8785 and MUST NOT be used for anything new. It exists so that objects signed before 2026-09 keep verifying (tests/corpus/v1, group 17). The v1 revocation list at /v2/revoked also uses it.