Tyche Labs · Finding 001 · 24 August 2026
The same number, two canonical forms
The first round of our open interlaboratory comparison was meant to measure whether an independently written verifier reproduces our reference verdicts. Before any outside participant arrived, it produced a finding against us: our own two reference implementations disagree, and the one we call the reference is the one that is wrong.
What happened
An action evidence package is signed over canonical JSON. A verifier re-serialises the
received object, hashes it, and compares. That only works if every implementation agrees
on the byte string. For one vector in the round — an amount of 1e-7 inside the signed
payload — they do not. CPython writes 1e-07; JavaScript writes
1e-7. The Python reference allowed the package; the JavaScript port
recomputed a different hash and rejected it as mutated content. Same document, same
keys, opposite verdicts.
It is not one number
Probing the space systematically, ten of nineteen tested values diverge, through five distinct mechanisms:
| mechanism | value | CPython | ECMAScript |
|---|---|---|---|
| exponent zero-padding | 1e-7 | 1e-07 | 1e-7 |
| threshold for exponential form | 0.00001 | 1e-05 | 0.00001 |
| large integers | 1e16 | 1e+16 | 10000000000000000 |
| integral float | 2.0 | 2.0 | 2 |
| precision above 253 | 9007199254740993 | exact | 9007199254740992 |
The last row is not a formatting question at all. An ECMAScript JSON parser puts the integer into a binary64 double and the value silently changes before any canonicalisation runs. No serialisation rule can repair that; only a bound on the input can.
Which side is wrong
RFC 8785, the JSON Canonicalization Scheme, requires numbers to be serialised by the ECMAScript rule of ECMA-262. Where the two runtimes differ on formatting, the conformant output is the JavaScript one — so the deviation belongs to our Python reference, and the port we would have suspected first is the correct one. Naming an implementation “the reference” is a statement about our workflow, not about its conformance.
Why the existing test suite missed it
Our verifier parity harness runs on every build of this site and had never failed, because every shipped sample is all-integer — a fact the port's own source comment records. The claim it supports was therefore true and correctly scoped; what was wrong was the width of the corpus, not the honesty of the claim. A parity suite proves agreement over the cases it contains and nothing beyond them, which is the entire argument for comparisons run by someone other than the author.
What we changed, and what we did not
We did not touch either verifier. The round's assigned values were sealed by hash before it opened, and editing an implementation to make a divergence disappear would defeat the instrument; the vector stays in the corpus as an unscored probe, and any verdict change belongs to a later round with a new commitment.
What we added is a bound on the input. A producer-side guard rejects non-integer numbers and integers beyond 253−1 in signed payloads, and the site build now fails if any shipped sample leaves that range — turning an accidental property of the corpus into an enforced one. The alternative, implementing the ECMAScript number-to-string algorithm in Python, is a larger commitment than this format needs; a format that carries money and identifiers has no good reason to carry binary floating point at all.
The corpus, the rules and the sealed commitment are public in the
aep-sandbox repository under interlab/round0. The assigned
values are withheld until the round closes; only their SHA-256 is published. Independent
verdicts, and findings against us, are what the round is for.