Part 1 said the job's fingerprint is a SHA-384 over nine fields. That hides a problem. A hash is over bytes, and the same nine fields can be written as bytes in a dozen ways. Put the keys in a different order, add a space, write 10 as 10.0, write a small number as 1e-6 instead of 0.000001 — same data, different bytes, different hash. Two shops would compute two fingerprints for one job and conclude they were looking at different things.
So before anything is hashed, it's rendered into one canonical form — a single agreed way of writing the data as bytes — and the rules for that form are the least glamorous and most load-bearing part of the spec. The Secure Job Envelope has two reference implementations — TypeScript and Rust, written separately, sharing no code — and the whole point of having two is that they must produce the identical bytes for the same job. This post is the rules, why each exists, and how the two implementations are held to them.
Rule 1: keys in code-point order, no whitespace
Object keys are sorted lexicographically by Unicode code point (which is the same as UTF-8 byte order), and the output is compact — no spaces, no newlines. Absent or undefined members are omitted entirely. Field names are ASCII by convention, but non-ASCII keys sort by code point on both sides, so even an astral-plane key (a character outside Unicode's basic range, such as an emoji) would land in the same place in both languages.
This is where a real bug lived. JavaScript has a rule most people never hit: when you enumerate an object's keys, integer-like keys — "2", "10" — come out in numeric order, before everything else, regardless of how they were inserted or sorted. A canonicalizer that sorts the keys and then serializes the rebuilt object will quietly emit them in JavaScript's order, not the spec's. Rust doesn't do this.
The shared test file key-order-digits exists to pin exactly that, and the register notes what happened: this vector caught a real bug in the TypeScript reference serializer on its first run. The serializer is now an explicit emitter — it writes keys in the order it decided, rather than handing an object to JSON.stringify and hoping. The spec says so in as many words: JavaScript implementations must not serialize a rebuilt object.
That's the whole argument for public test files in one sentence. The bug wasn't in a corner case anyone would have reviewed for. It was found because two independent programs were made to disagree out loud.
Rule 2: numbers live in a range, or they're refused
Numbers are where cross-language agreement usually dies. A number is canonical only if it's finite, its magnitude is at most 2⁵³ − 1, and — if it isn't an integer — its magnitude is at least 1e-5 (or it's exactly zero). Anything else is refused: not rounded, not approximated, not hashed. The implementations reject the document.
Both bounds come from real divergences:
- 2⁵³ − 1 is the largest integer an IEEE-754 double (the standard 64-bit floating-point number most languages use) represents exactly. Above it, a JSON round-trip through JavaScript silently changes the value — and therefore the hash — while Rust would keep it. The two sides would disagree without either one being wrong.
- 1e-5 is where Rust's number formatter switches to exponential notation (
1e-6) while JavaScript stays in fixed notation (0.000001). Same number, different bytes.
Inside the range, the renderings are pinned: integer-valued floats emit without a trailing .0 (a thickness of 10.0 hashes as 10), negative zero emits as 0, and non-integers emit their shortest round-trip decimal in fixed notation. Within this range, JavaScript's Number.toString and Rust's formatter already agree.
The spec then goes one step further and mostly avoids the problem. Money is an integer count of the currency's minor unit — $14.20 is written as 1420 — so prices never touch floating point at all. The published JSON Schemas bound every numeric field inside the canonical range, and the sole non-integer field in the format, thickness_mm, is bounded at or above 1e-4 so it always renders in fixed notation. The canonicalizer remains the final gate regardless of what the schema allows.
The principle, in the spec's words: refuse rather than hash ambiguously. The register's C3 row is the test.
Rule 3: one way to say "nothing"
Part 1 covered this rule; it matters here because it decides the bytes. Before hashing, absent optionals, empty strings, and empty arrays are dropped from the quoteable body. The result is that processes: [] and a missing processes produce the same bytes — on both sides, pinned by the l0-empty-arrays vector (C10).
Rule 4: dates that can't be two instants
A timestamp is a fixed-width YYYY-MM-DDTHH:MM:SS(.f)?Z — UTC only, a trailing Z, no offsets. Both implementations use a real, leap-aware calendar, and both reject the same impossible values: hour 24, second 60, February 31st, a timezone offset. This matters beyond hashing, because quote expiry and binding are computed from these instants; two sides that parsed valid_until differently would disagree about whether a quote is still good (C13).
Rule 5: strings that are what they look like
A string containing a lone UTF-16 surrogate — a half of a character that has no UTF-8 encoding — is refused at parse on both sides. A hashed or length-limited string with leading or trailing whitespace is refused, not trimmed: silently trimming would mean the two sides could hash different bytes for what one of them considered the "same" document (C14). String-length bounds are mirrored down to the unit (UTF-16 code units on both sides, because that's how the TypeScript schema library counts length), and unknown keys are rejected — deny_unknown_fields in Rust matches .strict() in the TypeScript schema — so a document one side accepts, the other accepts, field for field (C12).
How the two are held together
Rules on paper aren't parity. The repo holds the two implementations to the same verdicts four ways, all of which run in CI on every push:
- Shared vectors.
conformance/is a language-agnostic corpus of documents with expected canonical bytes, hashes, levels, and accept/reject verdicts. The TypeScript implementation generates the vectors; the Rust implementation independently verifies them. Both runners must pass every file. - A golden vector. One traveler whose hash both sides must reproduce byte for byte — the single assertion that would fail first if the canonical rules ever drifted.
- A differential fuzzer. A seeded generator mutates thousands of documents and feeds each to both implementations, comparing parse, hash, level, and binding. The register's C15 row records the current result: zero divergence.
- A committed corpus and a soak. The fuzz corpus is checked in and replayed inside
cargo testwith no Node in the loop, so the Rust side can be verified on a machine that has never seen the TypeScript. A lifecycle soak transacts hundreds of jobs end to end and asserts the invariants hold across all of them.
And one edge is stated rather than hidden (G6): the 512 KiB size limit is checked against raw input length in Rust and against the re-serialized length in TypeScript, so a document padded with whitespace to just under the cap could, in principle, be accepted by one and refused by the other at the exact boundary. Negligible, documented, and exactly the kind of thing the register exists to say out loud.
If you're implementing this
Four things, in order of how often they bite:
- Emit keys explicitly, in sorted order. Never serialize a rebuilt object in a language that reorders integer-like keys.
- Refuse numbers outside the range. Don't round. Don't clamp. Fail.
- Store money in minor units. You'll never have the decimal argument.
- Run the public vectors before you trust your hash. They were written by watching two real implementations disagree. They will catch what your code review won't.
Next in the series: now that both sides can agree what a job says, how both sides agree on where it stands — the levels a traveler earns, the guards on quoting and awarding, and the lock.