Two different payloads produced the same SHA-256. That was the moment we stopped treating serialization as plumbing.
We were building the idempotency layer for exactsend, our open-source project. The job states simply: turn one action into one byte stream, hash it, and let the server recognise a retry. A fuzz run over user-supplied strings handed us two distinct inputs that shared one digest.
The cause was an unpaired surrogate. Somewhere upstream a display name had been truncated mid-emoji. TextEncoder does not fail on that. It substitutes U+FFFD and moves on. Two different names collapse into the same bytes, so a genuinely new action looks like a replay of an earlier one and gets dropped. Silently. No error anywhere in the path.
Numbers gave us the mirror image. An amount that arrives as a float formats differently depending on who serialises it, so the same intent can produce two digests, and a retry that should have been absorbed becomes a second charge instead.
So the encoder stopped being permissive.
Every stream now opens with a domain label, the literal string exactsend/v1 plus a newline, so bytes hashed for one purpose cannot be mistaken for bytes hashed for another. The walk starts at the path $ and every rejection reports the path where it happened.
Numbers are checked against Number.isInteger and then Number.isSafeInteger, and anything that fails throws rather than rounds. Strings are tested for lone surrogates before they ever reach the encoder, and accepted strings get no Unicode normalization and a length prefix in bytes. undefined, functions, symbols and bigints throw with their own error codes instead of vanishing from the output. Dates, Maps, Sets and anything whose prototype is not Object.prototype are refused, because their field order is a library decision, and we are not willing to hash a library decision. Object keys are sorted by code unit, so key order cannot move the digest, while tags and length prefixes keep a key from bleeding into a value.
We carry the takeaway into every money-handling system now: a digest is a claim about bytes, and anything in the encoding path that guesses, pads, replaces or normalises is a place where two different actions become one. Refuse at the boundary and you get a loud error on one request. Coerce, and you probably get a correctness bug that only shows up in reconciliation weeks later.
If you run idempotency keys in production, it is worth knowing what your key actually covers: the full request body, or a hand-picked subset of fields someone chose a year ago.