When an ID is both a reference and a label, you can't just rename it
A record's human-readable ID often does double duty: a stable reference other things point at, and a label that encodes where the record lives. Move the record and those two jobs collide. The fix isn't to pick one — it's to re-mint the label and keep every old ID as an alias the lookup still resolves.
I had records with human IDs like ACME-42 — a prefix naming the parent bucket, plus a
per-bucket counter. That ID is lovely because it’s readable and it tells you where the record
belongs. Then I added the ability to move a record to a different bucket, and the ID quietly
started doing two incompatible jobs at once:
- A stable reference. It shows up in commit messages, URLs, and other people’s notes. Things outside the database point at it by that string.
- A label. The
ACMEprefix is supposed to tell you which bucket the record is in.
Move ACME-42 into the BETA bucket and you have to choose which job to break:
- Keep the ID (
ACME-42underBETA). References survive, but the label now lies — the prefix says one bucket, the record lives in another. Do this a few times and every bucket is a mix of prefixes. - Hard-rename to
BETA-7. The label is honest again, but every existing reference is now a dead link. Anything that resolvedACME-42— automation, URLs, memory — breaks silently.
Neither is acceptable if both jobs actually matter. The way out is to stop treating the visible ID as a single immutable thing and split identity from history:
Re-mint the label, and keep the old one as an alias. On move, ACME-42 becomes BETA-7
(a fresh number from the target bucket’s counter), and ACME-42 gets written into an alias table
pointing at the same row. Then resolution becomes: look up the live ID first, then fall back to
aliases. Now BETA-7 is the honest current label, and ACME-42 still resolves to the exact same
record forever. References don’t break; the label doesn’t lie.
Three details made it clean rather than hacky:
- Alias-aware resolution in one place. Every lookup path — direct fetch, update, the URL route,
the automation that parses IDs out of commit messages — goes through a single
resolveRefthat tries the live ID/label, then the alias table. One choke point, so nothing forgets aliases exist. - A monotonic counter kills the collision worry. Because each bucket’s counter only ever
increases, a re-minted
BETA-7can never equal a live ID or a previously-aliased one. No uniqueness dance needed. - Vacate + re-mint in a single transaction. The old label moves into the alias table and the row takes its new label atomically, so there’s never a window where a reference resolves to nothing.
The general lesson outlives this feature: the moment a user-facing identifier is also an external reference, renaming it is a data-migration problem, not a string edit. Redirects/aliases are the standard answer — the same reason URL shorteners keep old slugs and VCS forges keep old issue links alive. If you ever find yourself weighing “break the label” against “break the references,” that’s the signal you need an alias layer, not a coin flip.