O nome de máquina que também era o nome de exibição

novidades do produto

A prioridade das tarefas no BugDump era formada por quatro valores fixos: low, medium, high, critical. Quando passei a prioridade para o mesmo armazenamento de todo o resto — uma linha por opção, uma linha por valor —, mantive um metadata.key em cada opção para que o mundo de fora tivesse um nome estável para ela. O widget envia critical, o payload do webhook leva critical, o mapeamento para o Jira faz switch em critical.

Parecia a escolha cuidadosa. Foi justamente o que tornou o conjunto de opções impossível de mudar.

Identidade e exibição eram a mesma string

A intenção era comum: um id para o banco de dados, uma chave para as máquinas, um rótulo para as pessoas. Três camadas, cada uma com um trabalho.

O que aconteceu de fato foi que a chave e o rótulo eram sempre iguais, então nada obrigava a separá-los, e a chave vazou para o caminho de leitura. A consulta que carrega os valores de uma tarefa selecionava a chave:

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

e o mapper a atribuía direto ao campo que todo cliente lê:

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

Então metadata.key não era um nome de máquina quietinho ao lado do nome de exibição. Ele era o nome de exibição: no selo do card, no painel da tarefa, no portal público, na tabela do admin e no servidor MCP. Apague a chave, e a prioridade de todas as tarefas vira null em todo lugar ao mesmo tempo.

Não percebi isso no planejamento. O primeiro rascunho do plano dizia que a saída não precisava de mudanças, com base no fato de que o priority do payload do webhook já era tipado como string | null, só que esse é um caminho de código completamente diferente. Uma rodada de revisão pegou o erro. É fácil confundir as duas coisas porque ambas são «a prioridade como string», e a única diferença entre elas é qual função a produziu.

A forma geral: um identificador estável que é sempre igual ao valor de exibição não são dois campos de verdade. É um campo que dois subsistemas assumiram, cada um, que é seu. Você descobre isso quando tenta mudar um deles.

A classe de bug que falha sem fazer barulho

A parte que teria levado uma regressão para produção é menor e mais traiçoeira.

Quatro funções convertem uma prioridade na severidade de outro sistema:

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

A migração coloca os rótulos com inicial maiúscula, porque um rótulo agora é texto de exibição e low parece nome de máquina. 'Low' não casa com case 'low'. Todas essas funções caem no default.

Nada lança erro. O parâmetro é string | null, então o compilador fica satisfeito. O Jira continua recebendo um ticket válido, só que com Medium. O Slack continua postando, só que em cinza. O Freshdesk continua registrando, com severidade 2. Não há erro para achar num log, nem requisição falhando, nem erro de tipo. A funcionalidade simplesmente fica errada em silêncio, para todo mundo, numa direção que ninguém está monitorando.

É isso que torna perigosos os ramos default num mapeamento de strings. Um default é a resposta certa para uma entrada de fato desconhecida: um projeto que inventa uma prioridade chamada Blocker não tem equivalente no Jira, e chutar seria pior do que um fallback documentado. Mas o mesmo default absorve os seus próprios bugs com exatamente o mesmo silêncio.

Essas quatro funções não tinham testes. Agora têm, e o teste fixa as duas metades: os rótulos iniciais viram severidades específicas, e uma entrada sem correspondência cai no fallback. Reverter a correção deixa seis deles vermelhos, incluindo o que diz que High não pode chegar como Medium.

O que mudei

A identidade foi para o id; a exibição, para o rótulo. TaskSystemValues.priority passou de string | null para { id, label, color } | null, espelhando como o status já funcionava. Os filtros comparam ids de opção. Agora renomear muda o rótulo e nada mais, e esse é o objetivo: uma tarefa marcada como Critical mostra a palavra nova na hora, porque a linha de valor aponta para a opção em vez de guardar uma cópia do nome dela.

O canal de filtro sob medida foi apagado, não afrouxado. A prioridade tinha uma subconsulta escrita à mão que comparava metadata->>'key'. Em vez de ensiná-la a comparar rótulos, removi a subconsulta: agora a prioridade é uma propriedade comum, então passa pelo mesmo filtro genérico de propriedades que todo o resto. De quebra, isso deu aos agentes de IA filtro por prioridade, porque o propertyFilters do servidor MCP já falava essa língua.

Na fronteira, a comparação ignora maiúsculas e espaços. Widgets incorporados há meses ainda enviam critical. Eles são resolvidos para Critical e continuam funcionando, assim como os links ?priority=high que as pessoas compartilharam. Compatibilidade na borda, ids exatos por dentro.

Valores que não se resolvem têm destinos diferentes conforme o canal. Um relatório anônimo que cita uma prioridade que o projeto renomeou depois é registrado sem prioridade: perder um relatório de bug por causa de uma prioridade que ninguém consegue usar seria a falha pior. Uma chamada autenticada, incluindo a ferramenta MCP set_priority, é rejeitada, porque devolver { success: true } sem ter gravado nada informaria errado o que aconteceu.

A migração

Três instruções, cada uma com um escopo que a torna inócua tanto numa segunda execução quanto num projeto que já editou as próprias opções:

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

Depois as cores, onde nenhuma estiver definida, e então metadata - 'key'. Testei restaurando uma cópia da produção, renomeando e mudando as cores das prioridades de um projeto e rodando de novo cada instrução: o projeto editado saiu intacto.

Aqui, idempotência não é cerimônia. Uma migração de dados que sobrescreve edições dos usuários na segunda execução é uma migração que você só pode rodar uma vez, corretamente, para sempre.

O que eu teria dito a mim mesmo antes

Não adicione uma chave estável até que duas coisas precisem ser diferentes. Adicionei uma porque «as integrações precisam de algo estável», o que é verdade em geral e ainda não era verdade aqui. A chave não trouxe nada que o id da opção já não oferecesse, e me custou a possibilidade de renomear.

Um id é um identificador estável melhor que um nome. Ids já são únicos, já são imutáveis e já estão em toda resposta. Uma segunda string imutável existe principalmente para ser exibida por acidente.

Procure a string com grep, não o símbolo. Os leitores perigosos não eram erros de tipo. Eram case 'critical': num arquivo que ninguém associava à funcionalidade de prioridade. Um switch sobre um parâmetro string é invisível para um rename, e um default esconde a consequência.

Teste os mapeamentos que ninguém olha. O mapeamento de severidade ficou meses em produção sem teste porque eram funções de quatro linhas com comportamento óbvio. Comportamento óbvio com um fallback silencioso é exatamente o que precisa ser fixado por um teste.

Leia também