BugDump's task priority used to be four fixed values: low, medium, high, critical. When we moved priority into the same storage as everything else — one row per option, one row per value — we kept a metadata.key on each option so the outside world had something stable to name it by. The widget sends critical, the webhook payload carries critical, the Jira mapping switches on critical.
It seemed like the careful choice. It was the thing that made the option set impossible to change.
Identity and display were the same string
The intent was ordinary: an id for the database, a key for machines, a label for humans. Three layers, each doing one job.
What actually happened is that the key and the label were always equal, so nothing forced them apart, and the key leaked into the read path. The query that loads a task's values selected the key:
optionKey: sql<string | null>`${CustomPropertyOption.metadata}->>'key'`
and the mapper assigned it straight to the field every client reads:
case 'priority':
values.priority = row.optionKey;
So metadata.key was not a machine name sitting quietly beside the display name. It was the display name, for the card badge, the task panel, the public portal, the admin table and the MCP server. Delete the key and every task's priority becomes null everywhere at once.
We did not notice this while planning. The first draft of the plan said outbound needed no changes, on the strength of the webhook payload's priority already being typed string | null — which is a different code path entirely. A review pass caught it. The conflation is easy to make because both things are "the priority as a string", and the only thing distinguishing them is which function produced it.
The general shape: a stable identifier that is always equal to the display value is not really two fields. It is one field that two subsystems have each assumed is theirs. You find out when you try to change one of them.
The bug class that does not fail loudly
The part that would have shipped a regression is smaller and nastier.
Four functions map a priority onto another system's severity:
function mapPriorityToJira(priority: string | null): string {
switch (priority) {
case 'critical': return 'Highest';
case 'high': return 'High';
case 'medium': return 'Medium';
case 'low': return 'Low';
default: return 'Medium';
}
}
The migration capitalises the labels, because a label is now a display string and low reads as a machine name. 'Low' does not match case 'low'. Every one of these falls through to default.
Nothing raises. The parameter is string | null, so the compiler is satisfied. Jira still receives a valid ticket — at Medium. Slack still posts — in grey. Freshdesk still files — at severity 2. There is no error to find in a log, no failing request, no type error. The feature simply becomes quietly wrong, for everyone, in a direction nobody is monitoring.
This is what makes default branches in string mapping dangerous. A default is the right answer for genuinely unknown input — a project that invents a priority called Blocker has no Jira equivalent, and guessing would be worse than a documented fallback. But the same default absorbs your own bugs with exactly the same silence.
These four functions had no tests. They do now, and the test pins both halves: the seeded labels map to specific severities, and unmappable input takes the fallback. Reverting the fix turns six of them red, including the one that says High must not arrive as Medium.
What we changed
Identity moved to the id; display moved to the label. TaskSystemValues.priority went from string | null to { id, label, color } | null, mirroring how status already worked. Filters match on option ids. A rename now changes the label and nothing else, which is the whole point: a task marked Critical shows the new word immediately, because the value row points at the option rather than storing a copy of its name.
The bespoke filter channel was deleted rather than loosened. Priority had a hand-written subquery matching metadata->>'key'. Rather than teach it to match labels, we removed it — priority is an ordinary property now, so it goes through the same generic property filter as everything else. That also gave AI agents priority filtering for free, because the MCP server's propertyFilters already spoke that language.
Matching at the boundary is case- and space-insensitive. Widgets embedded months ago still send critical. They resolve to Critical and keep working, and so do ?priority=high links people have shared. Compatibility at the edge, exact ids on the inside.
Unresolvable values split by channel. An anonymous report naming a priority the project has since renamed is filed without a priority — losing a bug report over a priority nobody can act on would be the worse failure. An authenticated caller, including the MCP set_priority tool, is rejected, because returning { success: true } having written nothing would misreport what happened.
The migration
Three statements, each scoped so a re-run and a project that already edited its options are both no-ops:
UPDATE "CustomPropertyOption" o
SET "label" = initcap(o."label"), "updatedAt" = now()
FROM "CustomProperty" p
WHERE o."propertyId" = p."id"
AND p."system" = 'priority'
AND o."label" IN ('low', 'medium', 'high', 'critical');
Then colours where none is set, then metadata - 'key'. We tested it by restoring a copy of production, renaming and recolouring one project's priorities, and replaying every statement: the edited project came out untouched.
Idempotency here is not ceremony. A data migration that overwrites user edits on its second run is a migration you can only ever run once, correctly, forever.
What we would tell ourselves earlier
Do not add a stable key until two things need to differ. We added one because "integrations need something stable", which is true in general and was not yet true here. The key bought nothing the option id did not already provide, and cost us the ability to rename.
An id is a better stable handle than a name. Ids are already unique, already immutable, and already in every response. A second immutable string exists mainly to be displayed by accident.
Grep for the string, not the symbol. The dangerous readers were not type errors. They were case 'critical': in a file nobody associated with the priority feature. switch over a string parameter is invisible to a rename, and a default hides the consequence.
Test the mappings nobody looks at. Severity mapping had shipped for months without a test because it was four-line functions with obvious behaviour. Obvious behaviour with a silent fallback is exactly what needs pinning.
Read next: Bug Priority Levels: Why Four Fixed Choices Never Fit and What we learned building an MCP server for a developer tool.