Le nom technique qui servait aussi de nom affiché

nouveautés produit

Dans BugDump, la priorité d'une tâche était autrefois une valeur fixe parmi quatre : low, medium, high, critical. Quand j'ai déplacé la priorité dans le même stockage que tout le reste (une ligne par option, une ligne par valeur), j'ai gardé une metadata.key sur chaque option pour que le monde extérieur ait un nom stable par lequel la désigner. Le widget envoie critical, le payload du webhook transporte critical, le mapping Jira fait un switch sur critical.

Ça semblait le choix prudent. C'est justement ce qui a rendu la liste d'options impossible à modifier.

L'identité et l'affichage étaient la même chaîne

L'intention était banale : un id pour la base de données, une clé pour les machines, un libellé pour les humains. Trois couches, chacune avec un seul rôle.

Ce qui s'est réellement passé, c'est que la clé et le libellé étaient toujours égaux, donc rien ne les obligeait à diverger, et la clé s'est infiltrée dans le chemin de lecture. La requête qui charge les valeurs d'une tâche sélectionnait la clé :

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

et le mapper l'affectait directement au champ que lisent tous les clients :

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

metadata.key n'était donc pas un nom technique sagement rangé à côté du nom affiché. C'était le nom affiché, pour le badge de la carte, le panneau de la tâche, le portail public, le tableau d'administration et le serveur MCP. Supprimez la clé, et la priorité de chaque tâche devient null partout à la fois.

Je ne l'ai pas remarqué en préparant le plan. Le premier jet disait que les sorties n'avaient besoin d'aucun changement, sur la foi du fait que la priority du payload de webhook était déjà typée string | null, ce qui relève d'un tout autre chemin de code. Une relecture l'a repéré. La confusion est facile parce que les deux choses sont « la priorité sous forme de chaîne », et que seule la fonction qui les a produites les distingue.

La forme générale : un identifiant stable toujours égal à la valeur affichée, ce ne sont pas vraiment deux champs. C'est un seul champ que deux sous-systèmes ont chacun cru être le leur. On s'en aperçoit quand on essaie de changer l'un des deux.

La classe de bugs qui n'échoue pas bruyamment

La partie qui aurait livré une régression est plus petite et plus sournoise.

Quatre fonctions font correspondre une priorité à la sévérité d'un autre système :

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

La migration met une majuscule aux libellés, parce qu'un libellé est désormais une chaîne d'affichage et que low se lit comme un nom technique. 'Low' ne correspond pas à case 'low'. Chacune de ces fonctions retombe sur default.

Rien ne lève d'erreur. Le paramètre est string | null, donc le compilateur est satisfait. Jira reçoit toujours un ticket valide, en Medium. Slack publie toujours, en gris. Freshdesk enregistre toujours, en sévérité 2. Pas d'erreur à trouver dans un log, pas de requête en échec, pas d'erreur de type. La fonctionnalité devient simplement fausse en silence, pour tout le monde, dans une direction que personne ne surveille.

C'est ce qui rend dangereuses les branches default dans un mapping de chaînes. Un default est la bonne réponse pour une entrée réellement inconnue : un projet qui invente une priorité appelée Bloquant n'a pas d'équivalent Jira, et deviner serait pire qu'un repli documenté. Mais le même default absorbe vos propres bugs dans exactement le même silence.

Ces quatre fonctions n'avaient aucun test. Elles en ont maintenant, et le test fixe les deux moitiés : les libellés par défaut correspondent à des sévérités précises, et une entrée sans correspondance prend le repli. Annuler la correction en fait passer six au rouge, dont celui qui exige que High n'arrive pas en Medium.

Ce que j'ai changé

L'identité est passée à l'id, l'affichage au libellé. TaskSystemValues.priority est passé de string | null à { id, label, color } | null, sur le modèle du statut. Les filtres comparent des id d'options. Un renommage ne change désormais que le libellé, et c'est tout l'intérêt : une tâche marquée Critical affiche le nouveau mot immédiatement, parce que la ligne de valeur pointe vers l'option au lieu de stocker une copie de son nom.

Le canal de filtrage sur mesure a été supprimé plutôt qu'assoupli. La priorité avait une sous-requête écrite à la main qui comparait metadata->>'key'. Plutôt que de lui apprendre à comparer des libellés, je l'ai supprimée : la priorité est maintenant une propriété ordinaire, elle passe donc par le même filtre générique de propriétés que tout le reste. Au passage, les agents IA ont obtenu le filtrage par priorité sans effort de plus, parce que les propertyFilters du serveur MCP parlaient déjà ce langage.

À la frontière, la comparaison ignore la casse et les espaces. Des widgets intégrés il y a des mois envoient toujours critical. Ils sont résolus en Critical et continuent de fonctionner, tout comme les liens ?priority=high que des gens ont partagés. Compatibilité aux bords, id exacts à l'intérieur.

Les valeurs impossibles à résoudre sont traitées selon le canal. Un rapport anonyme qui nomme une priorité que le projet a renommée depuis est enregistré sans priorité : perdre un rapport de bug à cause d'une priorité dont personne ne peut rien faire serait le pire échec. Un appelant authentifié, y compris l'outil MCP set_priority, est rejeté, parce que renvoyer { success: true } sans rien avoir écrit fausserait le compte rendu de ce qui s'est passé.

La migration

Trois instructions, chacune limitée de sorte qu'une nouvelle exécution et un projet qui a déjà modifié ses options restent tous deux sans effet :

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

Puis les couleurs là où aucune n'est définie, puis metadata - 'key'. Je l'ai testée en restaurant une copie de la production, en renommant et recolorant les priorités d'un projet, puis en rejouant chaque instruction : le projet modifié en est sorti intact.

L'idempotence n'est pas ici une formalité. Une migration de données qui écrase les modifications des utilisateurs à sa deuxième exécution est une migration qu'on ne peut lancer qu'une seule fois, correctement, pour toujours.

Ce que je me dirais si je pouvais revenir en arrière

N'ajoutez pas de clé stable tant que deux choses n'ont pas besoin de différer. J'en ai ajouté une parce que « les intégrations ont besoin de quelque chose de stable », ce qui est vrai en général et ne l'était pas encore ici. La clé n'apportait rien que l'id de l'option ne fournissait déjà, et m'a coûté la possibilité de renommer.

Un id est une meilleure référence stable qu'un nom. Les id sont déjà uniques, déjà immuables, et déjà présents dans chaque réponse. Une seconde chaîne immuable existe surtout pour être affichée par accident.

Faites un grep sur la chaîne, pas sur le symbole. Les lecteurs dangereux ne provoquaient pas d'erreurs de type. C'étaient des case 'critical': dans un fichier que personne n'associait à la fonctionnalité de priorité. Un switch sur un paramètre string est invisible pour un renommage, et un default en cache la conséquence.

Testez les mappings que personne ne regarde. Le mapping des sévérités tournait en production depuis des mois sans test, parce que c'étaient des fonctions de quatre lignes au comportement évident. Un comportement évident avec un repli silencieux, c'est exactement ce qu'il faut verrouiller par un test.

Pour aller plus loin