Your idempotency keys are broken on macOS
An idempotency key is supposed to mean: this is the same piece of work, don't do it twice. If two machines can write what looks like the same key and your system reads them as different, the key has stopped doing its only job — and it fails silently, in the direction of doing the work twice.
We found this in our own code. It had shipped. Here is the whole bug.
Two spellings of one word
Unicode can represent café two ways. As four code points, where é
is a single character (NFC). Or as five, where é is a plain e
followed by a combining acute accent (NFD). Both render identically. Both are correct. They
are different bytes.
> Buffer.byteLength('café'.normalize('NFC'))
5
> Buffer.byteLength('café'.normalize('NFD'))
6
> 'café'.normalize('NFC') === 'café'.normalize('NFD')
false
Which one you get depends on where the string came from. macOS filesystems hand you NFD. Most other sources — web forms, Postgres, JSON from a typical API — hand you NFC. So the same customer name, read from a file on a developer's Mac and from a database on a Linux box, is two different strings.
What that does to a gate
We run a service that decides whether an agent may perform a side effect. You send an
idempotency key derived from the work; we tell you execute exactly once for
that key, and refuse every caller after.
We compared those keys as raw bytes. So:
agent on Linux, key "facture:café_8812" (NFC) → execute
agent on macOS, key "facture:café_8812" (NFD) → execute
Both authorised. Same invoice, charged twice. Not a race, not a retry storm — just two machines spelling the same word differently, in a system whose entire purpose is to notice that they meant the same thing.
The second failure is quieter and, in some ways, worse. We also fingerprint the payload to
detect a key being reused for different work. A legitimate retry whose payload happened to
be encoded the other way produced a different fingerprint, so we rejected it as
idempotency_key_reuse — telling a correct caller they had made a mistake, and
blocking work that should have proceeded.
One bug, two opposite failures: authorise something that should be refused, and refuse something that should be authorised.
The fix
Normalise to NFC before comparing anything that functions as an identifier. This is what Unicode Annex #15 and the W3C recommend for exactly this reason.
const NON_ASCII = /[^\x00-\x7F]/;
export function normalizeText(v) {
return NON_ASCII.test(v) ? v.normalize('NFC') : v;
}
The ASCII guard is not premature optimisation, it is the common case: most keys are ASCII, where normalisation is a no-op, and this keeps it off the hot path entirely.
NFC only merges sequences that are canonically equivalent — strings Unicode
defines as the same string. It will not collapse anything genuinely distinct.
café and cafe stay different, as they should.
Where to apply it
Everywhere a caller-supplied identifier is compared, which is more places than you would guess. In our case:
- the idempotency key on the way in
- the same key on every lookup — a read that misses what a write stored is the same bug wearing a different hat
- string values inside the payload fingerprint
- object keys inside it too — an object key is text like any other
- every other caller-supplied identifier; ours had a second one, and all four of its query sites needed it
One place we deliberately did not touch: our effect-type field is constrained by
schema to ^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$. It cannot contain non-ASCII,
so normalising it would be ceremony.
How to check your own
This takes about a minute. Send the same key twice, encoded both ways, and see whether your system agrees they are one thing.
import unicodedata, requests
key = "facture:café_8812"
for form in ("NFC", "NFD"):
r = requests.post(URL, json={
"idempotency_key": unicodedata.normalize(form, key),
# ... the rest of your request
})
print(form, r.json())
If the second call is treated as new work, you have this bug. It applies well beyond
agents: any dedupe key, cache key, or unique constraint over user-supplied text has the
same exposure. Postgres will happily hold both spellings in a UNIQUE column,
because to Postgres they are different strings.
Why we nearly missed it
Our test suite covered concurrency, tenant isolation, lease fencing, and replay. It passed throughout. We went looking for this only because someone asked whether the service was ready for users outside the English-speaking world, and the honest answer required testing rather than assertion.
The first thing we checked was whether non-ASCII keys worked — Japanese, Arabic, Cyrillic, emoji, combining marks. All seven passed. It would have been easy to stop there and report that internationalisation was fine. The bug only appears when you ask a harder question: not does this string work, but do two encodings of this string agree.
We build Ratchet, a gate that decides whether an AI agent may perform a side effect. The fix above is in the open-source repo, along with the tests that pin it.