Der Maschinenname, der zugleich der Anzeigename war

Entwickler-Tools

Die Priorität von Aufgaben in BugDump bestand früher aus vier festen Werten: low, medium, high, critical. Als ich die Priorität in denselben Speicher wie alles andere verschoben habe (eine Zeile pro Option, eine Zeile pro Wert), habe ich an jeder Option einen metadata.key behalten, damit die Außenwelt einen stabilen Namen dafür hat. Das Widget sendet critical, der Webhook-Payload enthält critical, das Jira-Mapping prüft im switch auf critical.

Das schien die sorgfältige Wahl zu sein. Genau das machte es unmöglich, die Optionen zu ändern.

Identität und Anzeige waren derselbe String

Die Absicht war gewöhnlich: eine ID für die Datenbank, ein Key für Maschinen, ein Label für Menschen. Drei Ebenen, jede mit einer Aufgabe.

Tatsächlich waren Key und Label immer gleich, nichts zwang sie auseinander, und der Key sickerte in den Lesepfad. Die Abfrage, die die Werte einer Aufgabe lädt, selektierte den Key:

ts
optionKey: sql<string | null>`${CustomPropertyOption.metadata}->>'key'`

und der Mapper wies ihn direkt dem Feld zu, das jeder Client liest:

ts
case 'priority':
  values.priority = row.optionKey;

metadata.key war also kein Maschinenname, der still neben dem Anzeigenamen lag. Er war der Anzeigename, für das Badge auf der Karte, das Aufgaben-Panel, das öffentliche Portal, die Admin-Tabelle und den MCP-Server. Lösch den Key, und die Priorität jeder Aufgabe wird überall gleichzeitig null.

Beim Planen ist mir das nicht aufgefallen. Der erste Entwurf des Plans sagte, im ausgehenden Pfad seien keine Änderungen nötig, weil priority im Webhook-Payload schon als string | null typisiert war, und das ist ein ganz anderer Codepfad. Ein Review-Durchgang hat es gefunden. Die Verwechslung passiert leicht, weil beides „die Priorität als String“ ist und nur die Funktion, die ihn erzeugt hat, die beiden unterscheidet.

Das allgemeine Muster: Ist ein stabiler Bezeichner immer gleich dem Anzeigewert, sind das in Wahrheit nicht zwei Felder. Es ist ein Feld, das zwei Subsysteme jeweils für ihres halten. Das merkst du, sobald du eines davon ändern willst.

Die Bug-Klasse, die nicht laut scheitert

Der Teil, der eine Regression ausgeliefert hätte, ist kleiner und gemeiner.

Vier Funktionen bilden eine Priorität auf die Severity eines anderen Systems ab:

ts
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';
  }
}

Die Migration schreibt die Labels groß, weil ein Label jetzt ein Anzeigestring ist und low wie ein Maschinenname aussieht. 'Low' passt nicht auf case 'low'. Jede dieser Funktionen fällt bis zum default durch.

Nichts wirft einen Fehler. Der Parameter ist string | null, der Compiler ist also zufrieden. Jira bekommt weiterhin ein gültiges Ticket, nur mit Medium. Slack postet weiterhin, nur in Grau. Freshdesk legt weiterhin ein Ticket an, nur mit Severity 2. Es gibt keinen Fehler im Log, keinen fehlschlagenden Request, keinen Typfehler. Das Feature wird einfach still falsch, für alle, in eine Richtung, die niemand überwacht.

Das macht default-Zweige beim Mapping von Strings gefährlich. Ein default ist die richtige Antwort für wirklich unbekannte Eingaben: Ein Projekt, das eine Priorität namens Blocker erfindet, hat kein Jira-Gegenstück, und Raten wäre schlechter als ein dokumentierter Fallback. Aber derselbe default schluckt auch deine eigenen Bugs, genauso still.

Diese vier Funktionen hatten keine Tests. Jetzt haben sie welche, und die Tests nageln beide Hälften fest: Die vorbelegten Labels werden auf bestimmte Severities abgebildet, und nicht abbildbare Eingaben nehmen den Fallback. Nehme ich den Fix zurück, werden sechs davon rot, darunter der, der sagt, dass High nicht als Medium ankommen darf.

Was ich geändert habe

Die Identität ist in die ID gewandert, die Anzeige ins Label. TaskSystemValues.priority wurde von string | null zu { id, label, color } | null, so wie es beim Status schon war. Filter vergleichen Options-IDs. Eine Umbenennung ändert jetzt das Label und sonst nichts, und genau darum geht es: Eine Aufgabe mit Critical zeigt sofort das neue Wort, weil die Wertzeile auf die Option zeigt, statt eine Kopie ihres Namens zu speichern.

Den eigenen Filterkanal habe ich gelöscht, statt ihn aufzuweichen. Die Priorität hatte eine handgeschriebene Subquery, die auf metadata->>'key' verglich. Statt ihr beizubringen, Labels zu vergleichen, habe ich sie entfernt. Die Priorität ist jetzt eine gewöhnliche Eigenschaft und läuft durch denselben generischen Eigenschaftsfilter wie alles andere. Damit bekamen auch KI-Agenten den Prioritätsfilter geschenkt, weil die propertyFilters des MCP-Servers diese Sprache schon sprachen.

Der Abgleich an der Grenze ignoriert Groß- und Kleinschreibung und Leerzeichen. Widgets, die vor Monaten eingebunden wurden, senden weiterhin critical. Das wird zu Critical aufgelöst und funktioniert weiter, genauso wie geteilte Links mit ?priority=high. Kompatibilität am Rand, exakte IDs im Inneren.

Nicht auflösbare Werte werden je nach Kanal unterschiedlich behandelt. Ein anonymer Bericht, der eine Priorität nennt, die das Projekt inzwischen umbenannt hat, wird ohne Priorität angelegt. Einen Fehlerbericht wegen einer Priorität zu verlieren, mit der niemand etwas anfangen kann, wäre das schlimmere Versagen. Ein authentifizierter Aufruf, auch über das MCP-Tool set_priority, wird abgelehnt, denn { success: true } zurückzugeben, ohne etwas geschrieben zu haben, würde falsch melden, was passiert ist.

Die Migration

Drei Statements, jedes so eingegrenzt, dass ein erneuter Lauf und ein Projekt, das seine Optionen schon bearbeitet hat, nichts verändern:

SQL
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');

Danach Farben, wo keine gesetzt ist, dann metadata - 'key'. Getestet habe ich das, indem ich eine Kopie der Produktion wiederhergestellt, die Prioritäten eines Projekts umbenannt und umgefärbt und jedes Statement erneut abgespielt habe: Das bearbeitete Projekt blieb unangetastet.

Idempotenz ist hier keine Zeremonie. Eine Datenmigration, die beim zweiten Lauf Änderungen aus den Projekten überschreibt, ist eine Migration, die du für alle Zeiten genau einmal ausführen darfst, und zwar fehlerfrei.

Was ich mir vorher gern gesagt hätte

Füge keinen stabilen Key hinzu, solange nicht zwei Dinge voneinander abweichen müssen. Ich habe einen hinzugefügt, weil „Integrationen etwas Stabiles brauchen“, was allgemein stimmt, hier aber noch nicht. Der Key brachte nichts, was die Options-ID nicht schon bot, und kostete mich die Möglichkeit umzubenennen.

Eine ID ist ein besserer stabiler Griff als ein Name. IDs sind schon eindeutig, schon unveränderlich und schon in jeder Antwort. Ein zweiter unveränderlicher String existiert vor allem, um versehentlich angezeigt zu werden.

Grep nach dem String, nicht nach dem Symbol. Die gefährlichen Stellen, die den Wert lasen, waren keine Typfehler. Es waren case 'critical': in einer Datei, die niemand mit dem Prioritäts-Feature in Verbindung brachte. Ein switch über einen string-Parameter ist für ein Rename unsichtbar, und ein default versteckt die Folgen.

Teste die Mappings, auf die niemand schaut. Das Severity-Mapping lief monatelang ohne Test, weil es Vierzeiler mit offensichtlichem Verhalten waren. Offensichtliches Verhalten mit stillem Fallback ist genau das, was man festnageln muss.

Mehr zum Thema