La prioridad de las tareas de BugDump eran cuatro valores fijos: low, medium, high, critical. Cuando pasé la prioridad al mismo almacenamiento que todo lo demás (una fila por opción, una fila por valor), mantuve un metadata.key en cada opción para que el mundo exterior tuviera algo estable con lo que nombrarla. El widget envía critical, el payload del webhook lleva critical, el mapeo de Jira hace switch sobre critical.
Parecía la opción prudente. Fue justo lo que hizo imposible cambiar el conjunto de opciones.
Identidad y nombre visible eran la misma cadena
La intención era corriente: un id para la base de datos, una clave para las máquinas, un nombre visible (label) para las personas. Tres capas, cada una con un trabajo.
Lo que pasó en realidad es que la clave y el nombre visible siempre eran iguales, así que nada obligaba a separarlos, y la clave se coló en el camino de lectura. La consulta que carga los valores de una tarea seleccionaba la clave:
optionKey: sql<string | null>`${CustomPropertyOption.metadata}->>'key'`
y el mapper la asignaba directamente al campo que leen todos los clientes:
case 'priority':
values.priority = row.optionKey;
Así que metadata.key no era un nombre para máquinas que convivía tranquilamente con el nombre visible. Era el nombre visible: para la insignia de la tarjeta, el panel de la tarea, el portal público, la tabla de administración y el servidor MCP. Borra la clave y la prioridad de todas las tareas pasa a ser null en todas partes a la vez.
No me di cuenta al planificar. El primer borrador del plan decía que la salida no necesitaba cambios, porque el priority del payload del webhook ya tenía el tipo string | null, y ese es un camino de código completamente distinto. Lo detectó una revisión. Es fácil confundirlos porque los dos son «la prioridad como cadena», y lo único que los distingue es qué función la produjo.
La forma general: un identificador estable que siempre es igual al valor visible no son en realidad dos campos. Es un solo campo que dos subsistemas han dado por suyo. Lo descubres cuando intentas cambiar uno de ellos.
La clase de bug que no falla haciendo ruido
La parte que habría llegado a producción como regresión es más pequeña y más traicionera.
Cuatro funciones traducen una prioridad a la severidad de otro sistema:
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 migración pone en mayúscula inicial los nombres visibles, porque ahora son texto para mostrar y low parece un nombre de máquina. 'Low' no coincide con case 'low'. Todas estas funciones caen en default.
Nada lanza una excepción. El parámetro es string | null, así que el compilador está contento. Jira sigue recibiendo un ticket válido, con Medium. Slack sigue publicando, en gris. Freshdesk sigue creando el ticket, con severidad 2. No hay ningún error que buscar en un log, ninguna petición que falle, ningún error de tipos. La funcionalidad simplemente pasa a estar mal en silencio, para todo el mundo, en una dirección que nadie vigila.
Esto es lo que hace peligrosas las ramas default en un mapeo de cadenas. Un default es la respuesta correcta para una entrada de verdad desconocida: un proyecto que se inventa una prioridad llamada Blocker no tiene equivalente en Jira, y adivinar sería peor que un valor por defecto documentado. Pero ese mismo default se traga tus propios bugs con exactamente el mismo silencio.
Estas cuatro funciones no tenían tests. Ahora sí, y los tests fijan las dos mitades: los nombres visibles iniciales se traducen a severidades concretas, y la entrada que no se puede traducir toma el valor por defecto. Revertir la corrección pone seis de ellos en rojo, incluido el que dice que High no debe llegar como Medium.
Lo que cambié
La identidad pasó al id; lo visible, al label. TaskSystemValues.priority pasó de string | null a { id, label, color } | null, igual que ya funcionaba el estado. Los filtros comparan ids de opción. Ahora renombrar cambia el nombre visible y nada más, que es justo de lo que se trata: una tarea marcada como Critical muestra la palabra nueva al instante, porque la fila del valor apunta a la opción en lugar de guardar una copia de su nombre.
Eliminé el canal de filtrado a medida en vez de flexibilizarlo. La prioridad tenía una subconsulta escrita a mano que comparaba metadata->>'key'. En lugar de enseñarle a comparar nombres visibles, la quité: la prioridad ahora es una propiedad normal, así que pasa por el mismo filtro genérico de propiedades que todo lo demás. Eso además les dio a los agentes de IA el filtrado por prioridad gratis, porque el propertyFilters del servidor MCP ya hablaba ese idioma.
En la frontera, la comparación ignora mayúsculas y espacios. Los widgets embebidos hace meses siguen enviando critical. Se resuelven a Critical y siguen funcionando, igual que los enlaces ?priority=high que la gente ha compartido. Compatibilidad en el borde, ids exactos por dentro.
Los valores que no se resuelven se tratan según el canal. Un reporte anónimo que nombra una prioridad que el proyecto ya renombró se guarda sin prioridad: perder un reporte de bug por una prioridad con la que nadie puede hacer nada sería el peor de los dos fallos. A quien llama con autenticación, incluida la herramienta MCP set_priority, se le rechaza la petición, porque devolver { success: true } sin haber escrito nada contaría mal lo que pasó.
La migración
Tres sentencias, cada una acotada para que tanto volver a ejecutarla como aplicarla a un proyecto que ya editó sus opciones no hagan nada:
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');
Luego, colores donde no hay ninguno, y después metadata - 'key'. La probé restaurando una copia de producción, renombrando y cambiando los colores de las prioridades de un proyecto y volviendo a ejecutar cada sentencia: el proyecto editado salió intacto.
Aquí la idempotencia no es ceremonia. Una migración de datos que sobrescribe las ediciones de los usuarios en su segunda ejecución es una migración que solo puedes ejecutar una vez, sin fallos, para siempre.
Lo que me diría a mí mismo antes de empezar
No añadas una clave estable hasta que dos cosas necesiten ser distintas. Yo la añadí porque «las integraciones necesitan algo estable», lo cual es cierto en general y aquí todavía no lo era. La clave no aportó nada que el id de la opción no diera ya, y me costó la posibilidad de renombrar.
Un id es mejor referencia estable que un nombre. Los ids ya son únicos, ya son inmutables y ya vienen en cada respuesta. Una segunda cadena inmutable existe sobre todo para acabar mostrándose por accidente.
Busca con grep la cadena, no el símbolo. Los lectores peligrosos no eran errores de tipos. Eran case 'critical': en un archivo que nadie asociaba con la funcionalidad de prioridades. Un switch sobre un parámetro string es invisible para un renombrado, y un default oculta la consecuencia.
Prueba los mapeos que nadie mira. El mapeo de severidades llevaba meses en producción sin un test porque eran funciones de cuatro líneas con un comportamiento obvio. Un comportamiento obvio con un valor por defecto silencioso es justo lo que hay que fijar con tests.
Te puede interesar
Niveles de prioridad de bugs: por qué cuatro opciones fijas nunca encajan — la misma decisión vista desde el producto: por qué las prioridades de BugDump pasaron a ser editables.
Campos personalizados en un bug tracker: qué añadir y qué evitar — qué campos se ganan su sitio en una tarea y cuáles acaban vacíos.
Lo que aprendí creando un servidor MCP para una herramienta de desarrollo — las decisiones de diseño de las que depende que un agente acierte a la primera.