Resolução de Problemas — Metodologia OLP
REGRA ZERO
Ler este documento ANTES de iniciar qualquer investigação ou implementação de correção.
Quando usar este documento vs @audit
| Cenário | Documento |
|---|---|
| "Algo está quebrado / não funciona / aparece errado" | PROBLEM_SOLVING.md (este — diagnóstico de causa raiz) |
| "Vou entregar uma feature, validar conformidade" | AUDIT.md (checklist pós-entrega) |
| "Animação ou UI visual quebrada" | PROBLEM_SOLVING.md → AUDIT_FRONTEND.md §2 (protocolo de diagnóstico visual) |
| "Quero validar padrões pós-entrega de UI" | AUDIT_FRONTEND.md (extensão de @audit frontend) |
Regra de fluxo: investigar com PROBLEM_SOLVING → corrigir → validar com
@audit(e@audit frontendse mexeu em UI) antes da entrega final. Misturar diagnóstico com auditoria leva a reescritas inúteis (ver Caso 12).
1. Diagnóstico (antes de qualquer código)
1.1 Coletar evidências
- Screenshot/descrição do usuário → entender o SINTOMA
- Console logs → warnings e erros reais
- Network requests → respostas do backend
- Banco de dados → estado real dos dados
- NUNCA assumir a causa pelo sintoma
1.2 Separar problemas
- Cada sintoma pode ter causa independente
- Listar todos os problemas reportados
- Investigar cada um separadamente
- Só unificar se a causa raiz for comprovadamente a mesma
1.3 Eliminar hipóteses por camada
Ordem obrigatória (do mais barato ao mais caro):
- Ler o código-fonte relevante
- Verificar console/logs
- Consultar banco de dados
- Testar no browser (último recurso)
2. Análise de causa raiz
2.1 Perguntas obrigatórias
- "Isso é sintoma ou causa?"
- "Onde na cadeia (banco → backend → frontend → UI) o problema NASCE?"
- "Existem warnings/erros no console que apontam a causa?"
- "O fix anterior criou um novo sintoma?" (regressão)
2.2 Padrão "3 porquês"
Exemplo real do projeto:
- Por que o modal quebra? → Select expande além do container
- Por que o Select expande? →
truncateCSS não funciona - Por que
truncatenão funciona? →forwardRefausente no componente base, Radix não consegue medir/conter o elemento
2.3 Classificação do fix
| Tipo | Quando usar | Risco |
|---|---|---|
| Paliativo | Emergência, sem tempo para root cause | Alto (mascara o problema) |
| Contenção | Limita o impacto enquanto investiga | Médio |
| Estrutural | Causa raiz identificada e comprovada | Baixo |
SEMPRE preferir estrutural. Paliativo APENAS com nota de débito técnico documentada.
3. Planejamento da solução
3.1 Mapear impacto
- Listar TODOS os arquivos que usam o componente/função afetada
- Verificar se a correção quebra outros consumidores
- Se for componente base (
ui/*), o impacto é global
3.2 Ordem de execução
Para mudanças coordenadas (banco + backend + frontend):
- Migration SQL primeiro (adiciona antes de remover)
- Backend (alinhado com novo schema)
- Frontend (consome novo contrato)
Deploy atômico: tudo junto, nunca parcial.
3.3 Retrocompatibilidade
- Decisão EXPLÍCITA: manter ou não
- Se não manter: varrer TODAS as referências (
grep/search global) - Se manter: documentar quando será removido
3.4 Modelagem antes de constante
Antes de criar/refatorar uma constante compartilhada que descreve propriedade de linhas de uma tabela, perguntar:
- Essa propriedade existe na tabela como coluna? Se não — deveria.
- A propriedade muda quando entram novos registros (papéis, status, tipos)? Se sim — coluna obrigatória.
- Mais de uma camada (DB trigger + backend + frontend) precisa da classificação? Se sim — SSOT no banco elimina deploy coordenado entre N sites.
Se as 3 respostas forem "sim", a solução é migration (coluna + backfill + CHECK), não constante TS/SQL. Ver Caso 16.
4. Implementação
4.1 Princípio da cirurgia mínima
- Alterar APENAS o necessário para resolver a causa raiz
- Não refatorar código adjacente "de oportunidade"
- Cada mudança deve ser justificável pela causa raiz
4.2 Verificação pós-fix
- O sintoma original sumiu?
- Novos warnings/erros no console?
- Outros consumidores do componente continuam funcionando?
- O fix introduziu regressão visual ou funcional?
5. Anti-padrões (erros reais cometidos)
| Anti-padrão | Consequência | Correção |
|---|---|---|
| Aplicar CSS sem investigar componente base | Fix falha, sintoma migra (lateral→vertical) | Investigar a raiz antes de estilizar |
| Assumir perda de dados sem consultar banco | Pânico desnecessário, ações destrutivas | SELECT antes de qualquer "recuperação" |
| Deployar schema change sem código alinhado | Queries quebram, dados "somem" | Deploy atômico (migration+code) |
Adicionar overflow-hidden sem min-w-0 | Container respeita max-w mas filhos grid expandem | Ambos necessários em layouts grid/flex |
| Tratar warning do console como cosmético | Warning de ref causa falha real de medição/layout | Warnings são sintomas de bugs estruturais |
Funções de hook sem useCallback em deps de useEffect | Loop infinito de re-renders, UI congela | useCallback obrigatório em toda função retornada por hook |
enabled que depende de dado que só vem da própria query | Deadlock circular, query nunca dispara | Primeira chamada com enabled: true, filtro refinado em chamada subsequente |
| Query N+1 em loop por item (buscar fases por olimpíada) | Latência O(n), 20+ queries por page load | .in() consolidado + batch_init action |
queryFn retorna [] em erro em vez de throw | React Query acha que deu certo, não faz retry | Sempre throw em erro dentro de queryFn |
| Tratar timeout (504) como sessão expirada | Logout silencioso sem explicação ao usuário | Diferenciar _transient vs auth error, retry antes de deslogar |
useEffect redundante chamando funções que React Query já auto-executa | Requests duplicados no mount, risco de loop | Remover useEffect manual — confiar no useQuery |
Lista hardcoded de strings classificando linhas de tabela do banco (["administrador","especialista","escola"]) repetida em 2+ sites | Drift entre sites; incidente de segurança quando 1 fica desatualizado | Promover para coluna na tabela (ex: papeis.escopo); todo guard lê do banco |
| Refactor de denylist/allowlist parando no primeiro arquivo encontrado | Cópia idêntica em outra action sobrevive; segurança aparente, drift garantido | rg "<padrão>" supabase/ src/ retornar zero matches antes de fechar a tarefa |
| "Test deferred" sem entrada concreta em backlog | Dívida invisível que só vira trabalho quando alguém audita explicitamente | TODO comentado no código + entrada em memory de feature OU task tracking |
| Decisão de aceitar risco apenas em mensagem de chat | Próximo turno/dev não reconcilia com o scanner; risco volta como bug | RISK_ACCEPTED_LOG.md com ID RA-NNN, mitigações, data de revisão; manage_security_finding(ignore, reason) no scanner |
| Discutir "denylist vs allowlist" sem perguntar se a classificação devia estar no schema | Solução de código onde caberia solução de modelagem; menor manutenibilidade | 3ª opção sempre considerada: "isso é coluna?" (ver §3.4) |
Reportar mem://... escrita sem verificar persistência | Memory fantasma — orientação some no próximo turno | code--view/ls no mesmo turno do write |
Logar CPF/telefone/e-mail/JID cru via console.*, registrarLog() ou alertas ntfy | Vazamento LGPD em logs_transacoes, Edge Logs e provedor terceiro; impossível "limpar" depois (logs são forward-only e replicados) | Sempre passar pelos helpers de _shared/pii-helpers.ts (maskEmail/maskCPF/maskTelefone/maskCodigo/maskTextoComTelefones); para diffs de update, excluir PII_CONTATO_FIELDS e emitir *.contato_alterado; para alertas externos, usar template ${papel} @ ${escola_nome}. CI bloqueia via scripts/audit/pii-helpers-coverage.ts. Ver docs/lgpd/PII_LOG_MASKING_PLAN.md. |
6. Template de investigação
Para cada problema reportado, preencher antes de codar:
### Problema: [descrição em 1 linha]
- **Sintoma**: o que o usuário vê
- **Console**: warnings/erros relevantes
- **Hipótese 1**: [causa provável] → [como validar]
- **Hipótese 2**: [causa provável] → [como validar]
- **Causa raiz comprovada**: [após investigação]
- **Tipo de fix**: paliativo / contenção / estrutural
- **Arquivos afetados**: [lista]
- **Risco de regressão**: baixo / médio / alto
- **Validação**: [como confirmar que resolveu]7. Casos de estudo
Caso 1: Modal da Agenda (março/2026)
Contexto
Modal de detalhes da tarefa quebrava layout ao selecionar olimpíada com nome longo. Três tentativas de CSS falharam antes da resolução definitiva.
Tentativas falhadas
truncateno SelectTrigger → Não funcionou porque o componenteSelectnão usavaforwardRef, impedindo Radix de medir o elementooverflow-hiddenno DialogContent → O conteúdo parou de vazar lateralmente mas passou a crescer verticalmentemax-wforçado no trigger → Limitou o trigger mas não o popover
Resolução definitiva
- Causa raiz:
src/components/ui/select.tsxusava function components semforwardRef - Fix estrutural: Refatorar Select para usar
React.forwardRefem todos os sub-componentes (Trigger, Content, Item, Label, Separator, ScrollUp/Down) - Contenção complementar:
max-h-[calc(100vh-2rem)]+overflow-y-autono DialogContent - Resultado: Zero warnings, truncamento funcional, layout estável
Segundo problema (mesmo ticket)
- Sintoma: "Tarefas do dia 20/03 sumiram"
- Investigação: Query no banco → tarefas existem em outra escola do mesmo usuário
- Causa: Contexto de escola ativa diferente, não perda de dados
- Resolução: Nenhuma mudança de código necessária — comportamento correto
Lições
- Console warnings (
Function components cannot be given refs) eram a pista direta da causa raiz - Sintomas visuais diferentes (overflow lateral vs vertical) podem ter a mesma causa
- "Dados sumiram" quase sempre é problema de contexto/filtro, não perda real
Caso 2: Feature multi-camada com deploy atômico (2026-03-20)
Contexto
Implementação de cor de identificação por coordenador em tarefas. Envolve 3 camadas: migration SQL (nova tabela coordenador_cores), backend (3 actions em user-profile, JOIN em tarefas-escola), frontend (hooks, card visual, perfil).
Padrão aplicado
- Tabela sem RLS policies:
coordenador_coresusa padrão "RLS enabled, zero policies" — acesso exclusivo viaservice_roleno backend (padrão do projeto para tabelas de sistema). createSupabaseSystem()para queries em tabela sem policies (cores),createSupabaseClient(req)para queries com RLS (tarefas).- Promise.all para buscar tarefas + cores + contagem de coordenadores em paralelo, sem degradar latência.
- Meta no response: Campo
meta.total_coordenadoresretornado junto com a lista de tarefas para o frontend decidir se aplica cores (regra: >1 coordenador). - Paleta fixa: 10 cores pastel pré-definidas, validadas no backend. Cores em uso por outros coordenadores desabilitadas no frontend.
- Upsert com conflito:
onConflict: "usuario_id,escola_id"para idempotência na atribuição de cor.
Lições
- Features visuais condicionais (cor só se >1 coord) precisam de dados contextuais do backend — não assumir no frontend
- Tabelas auxiliares de UI (cores) seguem o padrão
service_roledo projeto para simplicidade - Deploy atômico: migration → backend → frontend, tudo na mesma entrega
Caso 3: Performance N+1 e Batching (março/2026)
Contexto
Tela de Resultados fazia 4 requests HTTP paralelos no mount, cada um passando pelo pipeline completo de auth + feature flag + CORS. No backend, cada olimpíada aderida disparava queries individuais para fases, inscrições e resultados — um padrão N+1 clássico.
Sintomas
- Page load ~5 segundos (com 3-4 olimpíadas)
- Network tab mostrava 4 requests paralelos para o mesmo backend
- Cada request interno fazia ~4 queries ao banco por olimpíada
3 Porquês
- Por que demora 5s? → 4 requests HTTP paralelos, cada um com overhead de auth+flag
- Por que cada request é lento? → Dentro de cada um, N queries sequenciais por olimpíada
- Por que N queries? →
for (const olp of olimpiadas) { await supabase.from('fases')... }— loop sequencial
Resolução
- Action
batch_init_resultados: Consolida 4 requests em 1 único .in('olimpiada_id', ids): Busca fases/inscrições/resultados de todas olimpíadas de uma vezPromise.allno backend para paralelizar queries independentes- Resultado: De ~5s para <1s
Padrão Replicável
Aplicado também no Mural (batch_init no mural-escola). Qualquer tela com 3+ requests para o mesmo backend é candidata.
Caso 4: Dependência Circular em useQuery (março/2026)
Contexto
useResultadosInit tinha enabled: anoEdicao !== null para filtrar por ano. Porém, anoEdicao só era populado quando a query retornava dados — criando um deadlock: query não roda → dados não vêm → ano nunca é setado → query nunca roda.
Sintomas
- Tela mostra "Nenhuma olimpíada encontrada" permanentemente
- Nenhum request disparado no Network tab (query
enabled: false) - Sem erros no console
3 Porquês
- Por que mostra "sem olimpíadas"? →
olimpiadasestá[] - Por que
[]? → Query nunca executou (enabled: false) - Por que
enabled: false? → Depende deanoEdicaoque só vem da própria query → deadlock circular
Resolução
enabled: true— primeira chamada sem filtro carrega dados base- Segunda chamada com ano específico usa queryKey diferente → cacheada separadamente
- Regra:
enablednunca deve depender de dado que só existe no resultado da própria query
Caso 5: Loop Infinito de Re-renders — Mensagens (março/2026)
Contexto
Hook useComunicacaoEscola retornava 12 funções imperativas sem useCallback. No componente comunicacao.tsx, essas funções eram usadas como dependências de useEffect. Nova ref a cada render → useEffect dispara → setState → re-render → loop infinito.
Sintomas
- UI congela completamente ao clicar em "Templates"
- Network tab mostra avalanche de requests idênticos (100+ em <2 segundos)
- Browser fica em estado "não responde"
- Cursor trava em estado pointer
Padrão de Detecção
Abrir Network tab → avalanche de requests idênticos com timestamp crescente em <1s é sinal inequívoco de loop infinito de re-renders.
3 Porquês
- Por que a UI congela? → Loop infinito de re-renders saturando o event loop
- Por que loop infinito? →
useEffectdispara a cada render porque deps mudam - Por que deps mudam? → Funções retornadas pelo hook sem
useCallbackcriam nova referência a cada render
Resolução
useCallbackem todas as 12 funções retornadas pelo hook- Remoção do
useEffectde mount redundante — React Query já auto-carrega viauseQuery - Resultado: Zero re-renders desnecessários, UI responsiva
Regra Derivada
Toda função retornada por hook custom DEVE usar
useCallback. Dependências devem ser estáveis (queryClient, mutation, refetch) — nunca dados reativos comomensagens.length.
Caso 6: Timeout Transitório vs Sessão Expirada (março/2026)
Contexto
Supabase retornou 504 (timeout de infra transitório). O auth-context interpretou como "sessão expirada" → logout silencioso. Simultaneamente, banners falharam com 500 e retornavam [] em vez de throw → React Query não fez retry. Resultado: usuário redirecionado para login com tela de banners vazia.
Cascata de Falhas
Infra timeout (504) ──┬──→ auth-context: "sem sessão" → logout silencioso
└──→ banners: retorna [] → sem retry → tela vazia1 falha de infra gerou 3 sintomas aparentemente independentes.
3 Porquês
- Por que logout silencioso? → auth-context trata qualquer falha do
/mecomo sessão expirada - Por que banners vazios? →
queryFnretorna[]em erro → React Query acha que deu certo - Por que tudo ao mesmo tempo? → Timeout de infra afetou ambos os endpoints simultaneamente
Resolução
invokeEdge: retry automático (max 2) com backoff para 502/503/504auth-context: flag_transientdiferencia timeout de sessão expirada; toast informativo + retry em 5s- Banners:
throwem erro dentro dequeryFn→ React Query faz retry - Resultado: Timeout transitório é invisível para o usuário; sessão realmente expirada mostra toast explicativo
Regra Derivada
queryFnDEVE fazerthrowem erro — nunca retornar estado vazio. Retornar[]em erro impede retry e mascara falha.
Caso 7: Colisão de Permissões Multi-Papel na Mesma Escola (abril/2026)
Contexto
Usuário com diretor e coordenador na mesma escola. Ao abrir o badge diretor no modal admin, as permissões apareciam vazias (sem tabs, sub-flags). Ao clicar "Salvar permissões", nada acontecia — sem toast de sucesso nem erro. Na visão do usuário, o papel selecionado ficava sem acesso correto.
Sintomas
- Badge
diretorabre sem permissões, mesmo com features globais ativas - Botão "Salvar permissões" não produz feedback (silencioso)
- Usuário logado como
diretorvê sidebar/tabs inconsistentes
3 Porquês (Causa Raiz Múltipla)
Por que permissões aparecem vazias? →
get_permissoes_usuariorecebia sóusuario_id + escola_ide pegavavinculos?.[0](primeiro papel). Secoordenadorveio primeiro,diretorrecebia permissões do coordenador (ou zero se incompatíveis).Por que salvar não funciona? → Modelo de dados:
usuarios_escola_permissoescom UNIQUE(usuario_id, escola_id, permissao)— sempapel_id. Salvardiretorsobrescreviacoordenadorou gerava conflito silencioso.Por que sem toast? → Wrappers em
admin-usuarios.tsxengoliam erro nocatche retornavam{ success: false }sem chamarolpToast. Frontend não reidratava após save.
Cascata de Falhas
Modelo sem papel_id ──┬──→ get_permissoes: retorna papel errado → UI vazia
├──→ update_permissoes: salva contra papel errado → 0 permissões válidas
├──→ /me: resolve menu sem distinguir papel ativo → sidebar inconsistente
└──→ Frontend: wrappers silenciosos → sem feedback1 falha estrutural (modelo de dados) gerou 4 sintomas aparentemente independentes.
Resolução (Estrutural — 5 camadas)
- Migration: Adicionou
papel_idemusuarios_escola_permissoeseusuarios_escola_sub_permissoes. Novo UNIQUE:(usuario_id, escola_id, papel_id, permissao). Backfill de dados existentes por papel compatível. - Backend CRUD:
get_permissoes_usuarioeupdate_permissoes_usuariorecebem e filtram porpapel_idexplícito. - Helpers:
seedPermissionsForRoleeremovePermissionsForRoleescopados porpapel_id. - Frontend admin:
AdminPermissoesGridrecebepapel_id, implementa feedback comolpToast, rehydrata após save. /me: Resolvepapel_iddo papel ativo e retorna permissões isoladas.
Regras Derivadas
Toda tabela de permissão DEVE ter escopo de
papel_id— nunca apenasusuario_id + escola_id. Se um usuário pode ter múltiplos papéis na mesma escola, escopo por papel é obrigatório.
Toda operação de save DEVE ter feedback visual (toast sucesso/erro) — sem caminhos silenciosos. Wrappers que engolem erros com
catch { return { success: false } }são 🔴 CRÍTICO.
Pós-save: rehydratar estado da UI — após save com sucesso, invalidar cache ou refetch para que a UI reflita o estado persistido. Nunca confiar no estado local pré-save.
Anti-padrão Catalogado
| Anti-padrão | Consequência | Correção |
|---|---|---|
Permissões escopadas só por (usuario_id, escola_id) | Papéis colidem na mesma escola | Adicionar papel_id ao modelo + queries |
Backend pega "primeiro vínculo" sem papel_id | UI mostra dados de outro papel | Receber e filtrar por papel_id explícito |
| Wrapper com catch silencioso | Usuário sem feedback de save | olpToast em todo success/error path |
Frontend mapeia papel_id: '' | Backend não encontra vínculo | Preservar UUID real do mapeamento |
gestao-usuarios-escola INSERT sem papel_id | Registros órfãos → /me não encontra → AccessBlockedScreen | Resolver papel_id do vínculo ativo antes de INSERT |
DELETE sem scoping por papel_id | Apaga permissões de todos os papéis | .eq("papel_id", resolved) em todo DELETE |
| Falta seed de sub_permissoes no create | Tabs vazias até admin salvar manualmente | seedSubPermissoesForRole() após criação |
Unique index com NULL (papel_id) | Duplicatas ilimitadas no PostgreSQL | ALTER COLUMN papel_id SET NOT NULL |
| Backend não valida novo valor de enum | Frontend envia valor válido, backend rejeita com 400 genérico | Atualizar array de validação no backend ao adicionar valor no frontend (ex: estrutura: 'por_segmento' rejeitado em olimpiada-helpers.ts L649) |
Caso 8: Flash de Skeleton em Tabs com Cache Pré-populado (abril/2026)
Contexto
Seção Controle com 3 tabs usando Lazy Mount Once. O batch_init popula cache de todas as tabs em 1 request. Ao trocar de tab, a nova tab monta e exibe skeleton por 1 frame antes de renderizar os dados que já estão no cache.
Sintomas
- Flash visual de ~16ms (1 frame) ao clicar em tab pela primeira vez
- Skeleton aparece e desaparece instantaneamente ("pisca")
- Dados aparecem corretamente logo após — não é bug de fetch
3 Porquês
- Por que flash ao trocar tab? → Skeleton aparece por 1 frame antes do conteúdo
- Por que skeleton aparece? → Guard usa
if (isLoading) return <Skeleton />, eisLoadingétrueno primeiro render do hook - Por que
isLoadingétruese tem cache? → React Query inicializauseQuerycomisLoading: truepor 1 tick antes de detectar cache existente viaqueryKey
Resolução (2 Camadas)
Camada 1 — Sub-tabs: Trocar o guard de loading:
// ❌ ANTES — skeleton aparece mesmo com cache populado
if (isLoading) return <Skeleton />;
// ✅ DEPOIS — skeleton só aparece quando cache está genuinamente vazio
if (isLoading && !data) return <Skeleton />;Camada 2 — Container (causa raiz principal): O visitedTabs era atualizado via useEffect, que executa DEPOIS do render. Isso causava 1 frame em branco entre setActiveTab e a adição ao Set:
1. Clique → handleTabChange → setActiveTab("aplicacoes")
2. Re-render: currentTab = "aplicacoes", MAS visitedTabs NÃO tem "aplicacoes"
3. isVisited = false → return null → ⚡ FRAME EM BRANCO ⚡
4. useEffect dispara → adiciona ao visitedTabs → re-render → conteúdo apareceCorreção: mover setVisitedTabs para dentro do handleTabChange, no mesmo batch síncrono que setActiveTab:
// ❌ ANTES — useEffect assíncrono causa frame em branco
useEffect(() => {
if (currentTab && !visitedTabs.has(currentTab)) {
setVisitedTabs(prev => { const next = new Set(prev); next.add(currentTab); return next; });
}
}, [currentTab]);
const handleTabChange = useCallback((newTab) => {
tryNavigate(() => setActiveTab(newTab));
}, [tryNavigate]);
// ✅ DEPOIS — batch síncrono, zero frames em branco
const handleTabChange = useCallback((newTab) => {
tryNavigate(() => {
setVisitedTabs(prev => {
if (prev.has(newTab)) return prev;
const next = new Set(prev); next.add(newTab); return next;
});
setActiveTab(newTab);
});
}, [tryNavigate]);Regra Derivada
Estado de "lazy mount" (visitedTabs, mountedPanels, etc.) DEVE ser atualizado no mesmo handler síncrono que muda a tab ativa. Usar
useEffectpara derivar estado de montagem de tabs causa frames em branco inevitáveis.Em componentes com cache pré-populado, SEMPRE guardar o skeleton com
isLoading && !data.
→ Ref: docs/development/ATOMIC_RENDERING.md
Caso 9: useEffect Redundante Causando Re-renders em Componentes Memoizados (abril/2026)
Contexto
Tab Aplicações com tabela editável inline. Cada row é React.memo. Um useEffect calculava horario_termino = horario_inicio + duracao_minima e chamava handleChange para atualizar o estado.
Sintomas
- Ao editar um campo em qualquer row, TODAS as rows re-renderizam
- Performance visivelmente degradada com >10 olimpíadas
React.memoaparentemente "não funciona"
3 Porquês
- Por que todas as rows re-renderizam ao editar uma? →
handleChangeé chamado nouseEffectde cada row a cada render - Por que useEffect dispara? → Dependência instável (state local que muda a cada keystroke)
- Por que useEffect existe? → Cálculo derivado (
termino = inicio + duracao) foi implementado como side-effect em vez de computação inline
Resolução (Estrutural)
Remover o useEffect. Mover o cálculo de horario_termino para o momento do save:
// ❌ ANTES — useEffect dispara handleChange → invalida React.memo
useEffect(() => {
if (horarioInicio && duracaoMinima) {
const termino = calcularTermino(horarioInicio, duracaoMinima);
handleChange(id, 'horario_termino', termino);
}
}, [horarioInicio, duracaoMinima]);
// ✅ DEPOIS — campo derivado computado no save
const handleSave = () => {
const termino = calcularTermino(dados.horario_inicio, dados.duracao_minima);
salvar({ ...dados, horario_termino: termino });
};Regra Derivada
Campos derivados (ex:
termino = inicio + duracao) NUNCA devem ser calculados viauseEffect. Computar inline no save ou comouseMemo.useEffectque chamasetState/handleChangepara campos derivados é anti-padrão — causa re-renders cascata que invalidamReact.memo.
Caso 10: Tooltip Wrapper em SelectItem Causa Warning de Ref (abril/2026)
Contexto
Na tab Aplicações, SelectItem do Radix UI foi envolvido em TooltipTrigger para mostrar texto completo de opções longas.
Sintomas
- Console warning: "Function components cannot be given refs"
- Tooltip não funciona corretamente dentro do Select
- Potencial quebra de acessibilidade (focus management do Radix)
3 Porquês
- Por que warning de ref? →
TooltipTriggertenta passarrefpara o filho, que não aceita - Por que não aceita? →
SelectItemdo Radix é gerenciado internamente e espera controle exclusivo de ref e focus - Por que wrapper foi adicionado? → Tentativa de melhorar UX com tooltip em opções longas, sem investigar compatibilidade Radix
Resolução
Remover o TooltipTrigger de dentro do Select. Usar atributo title nativo do HTML:
// ❌ ANTES — wrapper Radix em primitiva Radix
<TooltipTrigger>
<SelectItem value="x">{textoLongo}</SelectItem>
</TooltipTrigger>
// ✅ DEPOIS — title nativo, sem conflito de ref
<SelectItem value="x" title={textoLongo}>{textoLongo}</SelectItem>Regra Derivada
Nunca envolver primitivas Radix (SelectItem, DropdownMenuItem, etc.) em outro wrapper Radix (Tooltip, Popover). Cada primitiva Radix espera controle exclusivo de ref e focus. Composição entre primitivas Radix distintas causa conflitos de ref forwarding e quebra de acessibilidade.
Caso 11: Flicker pós-drop em DnD de Headers de Novidades (abril/2026)
Contexto
Aba Controle de Headers de Novidades (/especialista/header-novidades) usa drag-and-drop entre 3 seções (ATIVOS / RASCUNHOS / DESATIVADOS) e reorder interno em ATIVOS. Após o drop, o card "voltava" visualmente para a posição original e só depois "piscava" para a posição final.
Sintomas
- Drop dentro de ATIVOS: card retorna ~1 frame ao slot original e pisca para o novo slot 300-800ms depois.
- Drop RASCUNHO → ATIVO: item permanece em RASCUNHOS por ~500ms antes de migrar.
- Mesma latência percebida em ATIVO → DESATIVADO e exclusão.
3 Porquês
- Por que o pisca? →
<SortableContext>re-renderiza com a ordem do cache (antiga) entre osetActiveId(null)e a chegada do refetch. - Por que o cache estava antigo? → A mutation usava só
onSuccess → invalidateQueries. O refetch leva 300-800ms; nesse intervalo, a UI mostra o estado pré-drop. - Por que não foi pego antes? → Padrão pessimista era aceitável em formulários, mas DnD exige feedback de 1 frame para parecer físico.
Resolução
Aplicar Optimistic Update (onMutate + onError + onSettled) em reordenarMutation, toggleMutation e excluirMutation no hook useHeadersNovidades:
onMutate: async (ids) => {
await queryClient.cancelQueries({ queryKey: QUERY_KEYS.headers });
const snapshot = queryClient.getQueryData(QUERY_KEYS.headers);
queryClient.setQueryData(QUERY_KEYS.headers, /* novo estado calculado */);
return { snapshot };
},
onError: (_e, _v, ctx) => {
if (ctx?.snapshot) queryClient.setQueryData(QUERY_KEYS.headers, ctx.snapshot);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: QUERY_KEYS.headers }),Regra Derivada
Toda mutation acionada por interação contínua (drag-and-drop, sliders, toggles inline) DEVE usar Optimistic Update via
onMutate/onError/onSettled. O cache otimista atualiza no frame 1 e oonSettledreconcilia com o servidor depois — preservando atomicidade visual sem abrir mão de consistência. Para mutations acionadas por submit explícito de formulário, o padrão pessimista (onSuccess + invalidate) continua aceitável.
Caso 12: Shimmer URGENTE Travado — Anti-padrão de Pular o Diagnóstico (abril/2026)
Contexto
Header de "URGENTE" no carrossel de novidades exibia uma faixa de shimmer diagonal que aparecia estática na tela — sem qualquer movimento. Foram feitas 3 reescritas completas antes de identificar a causa real.
Sintomas
- Faixa de shimmer visível, mas imóvel
- Múltiplos screenshots em momentos diferentes mostravam EXATAMENTE a mesma posição
- Console limpo, sem erros nem warnings
- Componente montava e renderizava normalmente
useShimmerProgressaparentemente "rodando" (em React DevTools)
Os 3 diagnósticos errados (anti-padrão)
| # | Hipótese | Ação tomada | Por que falhou |
|---|---|---|---|
| 1 | setTimeout acumula drift entre frames | Reescrever para requestAnimationFrame | Mecanismo não era o problema — o loop rAF nunca era atingido |
| 2 | Z-index escondendo a faixa atrás do gradiente | isolate + 3 camadas explícitas (z-0/10/20) | Empilhamento já estava OK; faixa estava VISÍVEL, só não se movia |
| 3 | Race condition entre CSS transition e re-render React | Remover transition, calcular transform por frame | prefers-reduced-motion ativo cortava o fluxo ANTES do rAF |
Padrão comum nos 3 erros: nenhuma das 3 reescritas inspecionou o DOM real para validar a hipótese. Todas começaram do código-fonte. O dev (e a AI) leu o código, formou hipótese, reescreveu — sem evidência observável.
Causa raiz (4º diagnóstico — correto)
O componente tinha um early-return para prefers-reduced-motion:
if (reduced) {
setState({ phase: 'reduced', progress: 0 });
return; // ← rAF nunca é chamado
}A máquina do desenvolvedor tinha "Reduzir Movimento" ativo nas configurações do sistema operacional. O componente entrava nesse caminho silenciosamente, renderizava a faixa em posição estática (translateX(75%)) e nunca animava.
Causa raiz: 1 if de 3 linhas. Nenhuma das 3 reescritas anteriores tocou nesse if.
Como o diagnóstico CORRETO foi feito (em <2 minutos)
- Etapa que foi ignorada nas 3 tentativas: inspeção do DOM real no DevTools
- Atributo
data-shimmer-phase="reduced"estava visível no elemento desde sempre - Confronto com código:
phase === 'reduced'só ocorre no early-return de acessibilidade - Confronto com SO:
System Settings → Accessibility → Reduce Motion: ON - Causa raiz comprovada — sem reescrever uma linha de código antes da evidência
Resolução
Refatorar useShimmerProgress para SEMPRE rodar o loop rAF, mesmo em prefers-reduced-motion — apenas com cadência mais lenta (4s/6s fixa) e opacidade reduzida (0.6). A política de "URGENTE deve ser perceptível mesmo com reduced-motion" foi documentada explicitamente no JSDoc do componente (HeaderPreview), justificando a escolha de produto.
Adicionado também data-motion-policy no DOM, expondo o caminho ativo (off | reduced | animated-forced) — diagnóstico futuro custa <30 segundos.
Cascata de falhas anti-padrão
Pular inspeção do DOM ──┬──→ Hipótese 1 (setTimeout) → reescrita inútil
├──→ Hipótese 2 (z-index) → reescrita inútil
└──→ Hipótese 3 (transition) → reescrita inútil
↓
3 commits desperdiçados, código mais complexo,
risco de quebrar outros consumidoresPor que o diagnóstico errou 3 vezes (causa-mãe)
Lacuna estrutural na documentação:
AUDIT.md(atualAUDIT.md) cobria conformidade pós-entrega — não diagnóstico de bug visualPROBLEM_SOLVING.mdtinha 11 casos: 0 sobre animação, CSS, media query- Não existia SSOT de "como debugar UI" — apenas "como construir UI"
Correção estrutural aplicada na mesma entrega:
- Criação de AUDIT_FRONTEND.md com §2 dedicado a Protocolo de Diagnóstico Visual
- Reestruturação de AUDIT.md com matriz "modo de invocação → seções" no topo
- Este caso (Caso 12) catalogado como exemplo seminal
Regras Derivadas
Sintoma visual NUNCA deve gerar reescrita de código antes de inspeção do DOM real. Atributos
data-*, computed styles e media queries ativas são evidência de 30 segundos que economiza horas de reescrita inútil.
Componentes com
prefers-reduced-motionDEVEM expor a política viadata-motion-policy(off | reduced | animated | animated-forced). Sem isso, o estado de acessibilidade fica invisível e gera diagnósticos errados cascateados.
Early-return que renderiza JSX intermediário (com
transformestático calculado) é anti-padrão. Ou anima totalmente (com cadência reduzida), ou não renderiza o elemento decorativo. Estado intermediário "congelado" parece bug travado e dispara reescritas equivocadas.
Antes de reescrever animação travada: executar AUDIT_FRONTEND.md §2 (Protocolo de Diagnóstico Visual). Cada etapa custa <2 minutos. Pular equivale a "operar sem exames".
Anti-padrões catalogados
| Anti-padrão | Consequência | Correção |
|---|---|---|
| Reescrever animação antes de inspecionar DOM no DevTools | Múltiplas tentativas inúteis, código mais complexo | Etapas 1–3 de AUDIT_FRONTEND.md §2 antes de tocar código |
prefers-reduced-motion retorna JSX com transform estático intermediário | Aparenta bug visual (elemento "travado") | Animar com cadência reduzida OU não renderizar a decoração |
Estado interno do hook não exposto no DOM via data-* | Diagnóstico exige ler código-fonte completo | data-phase, data-state, data-motion-policy em todo componente animado |
| "Animação travada" sem checar Animations panel do DevTools | Não distingue "rodando lento" de "não rodando" | DevTools → Animations OU Performance timeline (Etapa 4) |
CSS transition: transform combinado com transform calculado por rAF | Race condition entre paint e cálculo de frame | Escolher um mecanismo só (rAF puro OU CSS puro) |
| Diagnóstico baseado em "o que faz sentido pelo código" sem evidência observável | Hipóteses encadeadas, cada uma quebrando algo novo | Sempre validar hipótese com inspeção REAL antes de codar |
Caso 13: Importação de Templates Salva Título como NULL (abril/2026)
Contexto
ZIP de 119 templates de mensagem importado pelo wizard. Tela mostrava o card "Nome do Template" preenchido corretamente, mas o card "Conteúdo da Mensagem → Título" aparecia vazio com placeholder em todos os templates importados.
Sintomas
- Após importação, edição de qualquer template mostrava
Título *vazio - Banco confirmou:
assunto IS NULLem 121 dos 143 templates nome_templatepreenchido corretamente em todos- Conteúdo (
conteudo) preenchido corretamente em todos
Diagnóstico errado tentador (que NÃO foi feito desta vez)
"A tela tem dois campos confusos (Nome vs Título). Vou unificar em um só campo, ou trocar a ordem dos cards."
Esse caminho mascararia o bug real (modelo de dados tem dois campos distintos por design — o problema era o que estava sendo salvo).
Diagnóstico correto (causa raiz)
Aplicado o Protocolo de Diagnóstico Visual (AUDIT_FRONTEND.md §2):
Inspeção do banco (não da tela):
SELECT COUNT(*) WHERE assunto IS NULL→ 121Conferência do payload (
Networkou leitura do código que monta o request):ts// src/components/templates-import/index.tsx — payload do import_batch assunto: it.titulo || null, // ← null quando .txt não tem marcador "Título:"Conferência da regra de negócio (informada pelo usuário):
"no NOME DO ARQUIVO .txt temos o TÍTULO DA OLIMPÍADA, o conteúdo do arquivo é o corpo da mensagem"
Confronto: o ZIP é "corpo puro" (sem marcador
Título:). O parser retornatitulo: ''corretamente (sinaliza fallback). Mas o wizard salvava NULL ao invés de aplicar a regra de fallback: título = nome do arquivo (sem.txt).
Resolução (Estrutural — 3 camadas)
Parser (
stripTxtExtension): regex/(?:\.txt)+$/ipara tratar.txt.txtduplicado dos arquivos de origem.Wizard, inicialização do
ParsedItem: quandoparsed.tituloestá vazio, cair nonomeTemplate(=fileNamesem.txt):tsconst tituloFinal = parsed.titulo.trim() || nomeTemplate;Wizard, payload do
import_batch: mesma regra na borda final (defesa em profundidade — caso o usuário edite e apague o título manualmente, o nome do arquivo continua sendo o fallback):tsassunto: (it.titulo && it.titulo.trim()) ? it.titulo : it.nomeTemplate,Migration de purga idempotente dos 143 templates corrompidos, mantendo
template_hubsintactos para reimportação limpa.
Cascata de falhas anti-padrão (que NÃO ocorreu)
Bug visual (título vazio na edição) ─┬─→ Hipótese 1: tela com 2 campos confusos
│ → mexer em template-edicao.tsx
│ → quebrar usuários que dependem da separação
│
└─→ Hipótese 2: parser não extrai título
→ reescrever parseTxtContent
→ não resolveria (parser está correto)Ambas as hipóteses tentadoras seriam mexidas no lugar errado. A causa real estava no payload enviado ao backend — três linhas, em um único arquivo.
Regras Derivadas
Quando a UI mostra "campo vazio" mas o modelo de dados TEM o campo, conferir o PAYLOAD ENVIADO ao backend ANTES de mexer na tela. O bug raramente está em "como o campo é exibido" — quase sempre está em "o que foi salvo lá". Network tab → request body → comparar com o que a tela renderiza.
Regras de negócio com fallback ("se A vazio, usa B") devem ser aplicadas em DUAS camadas: na inicialização do estado E na borda final do payload. Inicialização garante UX correta no preview; borda final garante integridade mesmo se o usuário editar e apagar o campo manualmente.
Migration de purga sempre idempotente — usar
IF EXISTS+DELETEcondicional. Permite reexecução sem efeitos colaterais (ver MIGRATION_GUIDELINES.md §1).
Anti-padrões catalogados
| Anti-padrão | Consequência | Correção |
|---|---|---|
| Confundir "campo vazio na UI" com "tela mal desenhada" | Refactor inútil que não resolve | Conferir payload no Network ANTES de mexer na UI |
| Fallback aplicado só na inicialização do estado | Usuário apaga manualmente → NULL no banco | Mesmo fallback também na borda do payload (defesa em profundidade) |
regex /\.txt$/ para sanitizar extensão | .txt.txt duplicado vira .txt no nome | regex /(?:\.txt)+$/i para múltiplos sufixos |
Migration de seed/purga sem IF EXISTS guard | Falha em ambiente sem a tabela (staging novo) | Sempre wrap em DO $$ BEGIN IF EXISTS ... END $$ |
Caso 14: Sintoma de RLS que NÃO é RLS — batch_init do Mural (maio/2026)
Ver auditoria detalhada:
AUDIT_MURAL_COORDENADOR_LINK_2026-05-03.md.
Sintoma
Coordenador autenticado não conseguia ver/copiar o link do Mural da escola. Hipótese imediata (e errada): policy de escola_mural_config bloqueando o papel.
Diagnóstico correto
- Conferir a policy —
principal_role IN ('coordenador','diretor')está presente. ✅ - Conferir a query manual no banco com a chave do JWT real — retorna a linha. ✅
- Conferir os logs da Edge Function —
portal.slug=null (ok)em escola que tem slug. ❌ Anti-sinal. - Inspecionar a orquestração —
Promise.allcom 4 queries, destructuring com 3 variáveis → resultado deslocado.
A causa estava no passo 4, não nos passos 1-2.
Anti-sinal a tratar como bug imediatamente
diagnostico='ok'(ousuccess: true) com campo de identidade nulo (slug, id, …) em registro confirmadamente existente no banco = problema de integração/orquestração. Nunca é "comportamento esperado". Nunca tratar como UX edge case.
Regra derivada
Antes de aplicar fix em RLS por sintoma de "dado não aparece", percorrer a ordem: policy → query manual no banco → logs do backend → orquestração (destructuring, Promise.all, mapeamento de resultados). RLS é o último suspeito quando o backend retorna sucesso — não o primeiro.
Anti-padrão catalogado
| Anti-padrão | Consequência | Correção |
|---|---|---|
Promise.all com N queries e destructuring com N-1 variáveis | Resultado errado é silenciosamente atribuído à variável seguinte | Code review obrigatório quando array tiver ≥3 itens; contar 1:1 |
Tratar success: true + campo: null como "feature ausente" | Mascarar bug de integração como UX | Endurecer shape no backend; error em vez de ok quando shape inesperado |
| Pular para "deve ser RLS" sem conferir logs do backend primeiro | Mexer em policy correta, introduzindo regressão de segurança | Seguir ordem: policy → query manual → logs → orquestração |
Caso 15: Payload eco — mapa stale sobrepõe escalar editado (Fases por Nível, maio/2026)
Sintoma
Editar pontuação máxima / nº de questões / tempo de uma fase no modo Por Nível (OBF, OMA, etc.): salvar mostra "Olimpíada salva!" verde, log olimpiada.fases_save registra fases_atualizadas: N, mas ao recarregar a tab o valor antigo volta. Sintoma intermitente: às vezes persiste, às vezes não — depende de qual coluna estava previamente preenchida.
Diagnóstico (5 minutos, sem chutar)
- Conferir o DB —
SELECT pontuacao_maxima_por_nivel FROM fases_olimpiada WHERE id=…→ confirma que o valor no banco é o antigo, não o novo. - Conferir o payload do POST (Network → request body do
especialista-olimpiadas) →fasesPorNivel[nivelId][i]tem dois campos divergentes:pontuacaoMaxima: 200(escalar editado pela UI)pontuacaoMaximaPorNivel: { [nivelId]: 20 }(mapa STALE vindo do GET, nunca tocado pela UI)
- Conferir o backend —
persistirFasesPorNivelRelacionalpriorizava o mapa sobre o escalar. Bug de precedência, não de RLS, não de validação, não de UI.
Causa raiz
Contrato escalar↔mapa duplo + read helper devolvendo ambos + UI editando só o escalar + backend lendo só o mapa. Nenhum dos pontos isolados está errado; o acoplamento implícito entre eles é que estava.
Princípio canônico
No modo
modo_config_fases='por_nivel', 1 fase pertence a 1 nível ⇒ o escalar é SSOT (questoes,pontuacaoMaxima,tempoMinutos). Os mapas (*_por_nivel) existem por compatibilidade com o modouniformee devem ser derivados do escalar no write — nunca consumidos como fonte.
Atalho de verificação (catálogo)
"Salva mas não persiste" + log de write OK + DB com valor antigo ⇒ comparar payload enviado vs valor gravado, não a renderização. Se o payload contém o mesmo dado em duas representações divergentes, achou o bug — é precedência, não cache, não RLS.
Regra derivada
Em qualquer endpoint de write que aceita o mesmo dado em dois shapes (escalar e mapa, único e lista, antigo e novo), escolher um SSOT explícito no contrato e tratar o outro como fallback de migração — nunca como par.
Correção aplicada
supabase/functions/_shared/olimpiada-helpers.ts::persistirFasesPorNivelRelacional— escalar é SSOT; mapa restrito a{ [nivelId]: valor }.src/components/olimpiada-detalhes/tab-fases.tsx—onChangedo escalar espelha no mapa restrito ao nível corrente (defesa em profundidade).- 7 testes de integração em
supabase/functions/_shared/__tests__/olimpiada-helpers.test.tscobrindo R1 (regressão), R2 (escalar), R3 (mapa legado), R5 (range), R6 (insert), R7/R7b (delete vs warning).
Anti-padrão catalogado
| Anti-padrão | Consequência | Correção |
|---|---|---|
| Endpoint de write aceita dado em dois shapes sem SSOT explícito | UI edita um shape, backend lê o outro → "salva mas não persiste" | Escolher SSOT no contrato; outro shape vira fallback de migração |
| Helper de read devolve escalar e mapa derivado | Round-trip GET→PUT carrega mapa stale que sobrescreve edição do escalar | Devolver só o SSOT; consumidor que precisa da outra forma deriva |
| Diagnosticar "não persiste" pela UI sem checar payload+DB | Caça-fantasma em invalidação de cache, RLS, React Query | Caso 15: DB+payload primeiro, UI por último |
Caso 16: Sintoma de Segurança vira Refactor de Modelo — do denylist hardcoded à coluna SSOT (maio/2026)
Contexto
Três findings críticos do scanner Supabase entregues em sequência num único ciclo de auditoria:
realtime_messages_no_rls— qualquer autenticado podia se inscrever em qualquer canal Realtime.gestor_escola_insert_usuario_papeis_escalation— gestor escola podia inserir papeladministradoremusuario_papeis(privilege escalation).inscricoes_portal_responsavel_scope— responsável vinculado a aluno de outra escola enxerga dados pedagógicos.
UX e dados aparentavam estar OK; nenhum incidente reportado. Os 3 findings foram resolvidos em 3 tasks distintas (Realtime RLS → anti-escalation → cobertura de testes + bônus de derivação por escopo).
Sintomas iniciais
- Scanner emitindo alertas
level: errorem rotina periódica. - Nenhum log de exploração; ataques teóricos.
- Existia "documentação verbal" no chat sobre o portal-responsável ser feature legítima — mas sem artefato versionado.
3 Porquês — causa-mãe comum
- Por que 3 findings de classes diferentes na mesma rodada? → Modelagem de papéis usa lista de strings em código, não coluna no banco. Cada guard hardcoda sua cópia.
- Por que listas espalhadas? → A tabela
papeistinhanomemas faltava classificação (escopo). Sem coluna, cada consumidor (DB trigger, edge function, frontend) inventou sua interpretação. - Por que ninguém viu antes? → Hardcode "funciona" enquanto o domínio é estático. A primeira vez que se cogita um papel novo (ou se faz auditoria explícita), o drift aparece.
Causa-mãe: ausência de uma coluna papeis.escopo que tornasse a classificação de papéis SSOT do banco.
Cascata de falhas — o que ACONTECEU durante as 3 tasks
| # | Falha real | Detecção |
|---|---|---|
| 1 | Refactor parcial de hardcode: Fase B.2 trocou denylist em _shared/gestao-usuarios-helpers.ts (action create) mas a MESMA lista sobreviveu intacta em gestao-usuarios-escola/index.ts:653 (action update). | Audit Task C com rg papeisProibidos |
| 2 | Lista hardcoded em 3 sites distintos (fn_validar_escola_usuario_papel, helper edge create, action edge update) — drift inevitável quando entrar papel novo. | Achado durante planejamento Task C |
| 3 | "Test deferred" sem rastreio: Task B fechou marcando teste contractual como follow-up, sem entrada concreta em backlog/memory. Só virou trabalho ao usuário pedir auditoria explícita. | Pergunta direta do usuário entre Task B e C |
| 4 | Memory escrita "fantasma": AI reportou criação de mem://security/usuario-papeis-anti-escalation-standard na Task B, mas ls mem/security/ retornou vazio quando Task C foi verificar — só ficou persistido após re-write na Task C. | Audit Task C |
| 5 | Sintoma de segurança como diretriz de fix: scanner disse "anyone can subscribe" e a primeira hipótese foi "criar policies". A raiz era de modelagem (topics sem claim no nome) — tratada por convenção <resource>:<scope>:<id> + RLS, não só RLS. | Diagnóstico Task A |
| 6 | Discussão "denylist vs allowlist" iniciada como binária, sem perceber que a 3ª opção (SSOT via coluna nova) era estruturalmente superior. Só apareceu quando o assistente foi pedido a discordar e considerar manutenibilidade. | Pergunta adversarial do usuário em Task B |
| 7 | Risco aceito tratado como decisão verbal até virar artefato versionado (RISK_ACCEPTED_LOG.md + ID RA-001). | Pedido explícito do usuário em Task B |
Cascata de falhas que NÃO aconteceu — o que @audit evitou
- Refactor parcial chegou a deploy intermediário, mas auditoria explícita pedida pelo usuário (
@audit inconsistencias e dívidas técnicas) detectou antes da próxima feature pisar em cima. - Sem audit, o drift entre
_shared/eindex.tsda MESMA edge function teria virado bug-fantasma na primeira mudança de domínio (papel novo).
Resolução estrutural — 4 ondas
Onda 1 (Task A) — Topic-scoped Realtime:
- RLS habilitada em
realtime.messagescom 6 policies derivando de claims do JWT. - Convenção de nomenclatura:
<resource>:<scope>:<id>(notificacoes:user:<sub>,presence:escola:<id>,feature_flags:global,presence:olp_team). - Frontend e emissor de broadcast realinhados.
Onda 2 (Task B) — Coluna SSOT + trigger anti-escalation:
- Migration única adicionando
papeis.escopo text NOT NULL CHECK (escopo IN ('global','escola','portal'))com backfill dos 11 papéis. - Trigger
fn_block_escola_role_escalationlêpapeis.escopo(não hardcoda). - Allowlist via escopo: gestor escola só atribui
escopo='escola'em sua própria escola, nunca o papelescola.
Onda 3 (Task C — Fases B/B.2/B.3) — Refactor downstream:
fn_validar_escola_usuario_papel(DB trigger) reescrita lendopapeis.escopo._shared/gestao-usuarios-helpers.tsreescrito (Fase B.2).gestao-usuarios-escola/index.ts:653reescrito (Fase B.3 — descoberto no audit).
Onda 4 (Task C — Fase A) — Cobertura contratual:
tests/security/papeis-escopo-integrity.test.ts(regression guard contra backfill futuro).tests/security/usuario-papeis-escalation.test.ts(matriz 42501/ok do trigger).tests/security/realtime-messages-isolation.test.ts(isolamento de canais).
Risco aceito formal
RA-001 — Portal responsável cross-escola: responsável que conhece matrícula+DN consegue se vincular a aluno de outra escola. Feature de produto em validação jurídica, mitigações documentadas, próxima revisão definida em docs/security/RISK_ACCEPTED_LOG.md. Finding marcado como ignore no scanner com referência ao log.
Regras Derivadas
Lista de strings em código que descreve propriedade de entidade do banco DEVE virar coluna. Quando o mesmo array (ex:
["administrador","especialista","escola"]) aparece em 2+ lugares classificando linhas de uma tabela, falta uma coluna. Resolver por modelagem (colunaescopo), não por extração de constante compartilhada — constante ainda obriga deploy coordenado entre N sites quando o domínio cresce.
Refactor de denylist/allowlist hardcoded é incompleto até
rgpor TODAS as ocorrências do padrão retornar zero matches relevantes. O arquivo "óbvio" (helper) raramente é o único site. Edge function tipicamente tem 2+ actions; cada uma pode ter sua cópia. Audit pós-refactor com grep é obrigatório, não opcional.
Toda decisão de "test deferred" DEVE virar entrada concreta — task em backlog, comentário
// TODO(test): ...no código, ou nota no memory de feature. "Vou fazer depois" sem artefato é dívida invisível que só vira trabalho quando alguém audita explicitamente.
@security-memory,RLS_POLICIES.md,RISK_ACCEPTED_LOG.mdsão artefatos versionados, não conversas de chat. Toda decisão "ignore com justificativa" precisa de ID (RA-NNN), arquivo, mitigações e data de revisão. Decisão verbal não sobrevive ao próximo turno/dev.
Defense-in-depth deve declarar qual camada é o "gate real" (SSOT) e qual é "guard de UX/edge". Quando o gate real (trigger SQL) lê SSOT mas o guard (edge function) hardcoda, a segurança está íntegra mas manutenibilidade é zero — drift é só questão de tempo. Documentar explicitamente a hierarquia em mem/SSOT.
Discussão de opção em segurança nunca é binária. "Denylist vs allowlist" é falso dilema quando existe uma 3ª opção: tornar a classificação SSOT do banco. Antes de decidir entre A e B, perguntar "isso é dado de domínio que devia estar no schema?" (ver §3.4).
Memory write deve ser verificado com
view/lsno mesmo turno —code--write mem://...reportar sucesso não garante persistência observável.ls mem/<categoria>/é o oráculo.
Sintoma de segurança não é diretriz de fix. Scanner aponta "anyone can subscribe" — a raiz pode ser RLS ausente, mas pode ser também nomenclatura de topic sem claim, ou modelagem de canal sem escopo. Tratar finding como evidência inicial, não como solução.
Anti-padrões catalogados
| Anti-padrão | Consequência | Correção |
|---|---|---|
Tabela com classificação implícita (papéis sem coluna escopo) consumida por 3+ camadas | Cada camada hardcoda sua interpretação; drift entre N sites; auditoria descobre tarde | Coluna explícita + CHECK constraint + backfill + leitura SSOT |
migration única que junta schema change + refactor de N consumidores | Rollback complexo; revisão difícil; risco aumentado | Schema em migration; refactor de cada consumidor em PR/migration separado, marcado como follow-up explícito |
| Fechar task com test contractual "para depois" sem TODO/backlog | Dívida silenciosa; auditoria descobre na próxima rodada | TODO no código + memory de feature + entrada em backlog antes de marcar done |
rg apenas no arquivo "óbvio" depois de refactor | Cópia em outra action sobrevive | rg em supabase/ src/ retornar 0 matches antes de fechar |
Reportar criação de memory sem verificar com ls/view | Memory fantasma | Verificação no mesmo turno é parte do contrato de write |
| Falar "risco aceito" só no chat, sem artefato com ID | Próximo dev/scanner não reconcilia; risco volta como bug | RISK_ACCEPTED_LOG.md com RA-NNN + manage_security_finding(ignore) |
| Discutir denylist vs allowlist sem perguntar "isso é coluna?" | Solução de código onde caberia solução de modelagem | §3.4 — modelagem antes de constante |
Lições para o playbook (@audit)
- Após qualquer refactor de hardcode —
rgglobal do padrão é checagem mínima, não opcional. - Antes de fechar uma task — confirmar que cada follow-up tem artefato concreto (TODO, memory, backlog).
- Após
code--write mem://...— confirmar persistência comcode--viewno mesmo turno. - Antes de aplicar fix em sintoma de segurança — perguntar "isso é guard de código ou falta de modelo?".
- Antes de
manage_security_finding(ignore)— exigir entrada emRISK_ACCEPTED_LOG.mdcom ID, mitigações e data de revisão.
Caso 17: CI quebra em 0s após adicionar lint Deno — lockfile v5 vs Deno 1.x (maio/2026)
Sintoma: novo step Lint LGPD — PII helpers coverage no job lint-and-build falha imediatamente (0s) com:
error: Unsupported lockfile version '5'. Try upgrading Deno or recreating the lockfilebun run build é pulado em cascata. Localmente, o script roda sem erro.
Diagnóstico (camadas, na ordem):
- Tempo de falha = 0s → não é o script. É a inicialização do runtime Deno antes de avaliar o arquivo.
deno --versionlocal:2.6.10.ci.yml:deno-version: v1.x.deno.lockno repo: primeiro campo"version": "5". Lockfile v5 é gerado por Deno 2.x e não é retrocompatível com Deno 1.x (que entende até v3/v4).- Conclusão: mismatch de toolchain CI ↔ local introduzido no momento em que o script Deno passou a ser executado pelo CI (antes só
supabase/setup-clirodava Deno embutido, sem ler o lockfile do repo).
Causa raiz: o deno-version: v1.x foi cópia de cargo-cult de outro workflow; ninguém validou contra o deno.lock versionado.
Correção:
ci.yml→deno-version: "2.6.10"(pino exato, igual ao local).- Adicionar
--no-lockemdeno run/deno testdos scripts de auditoria (não precisam resolver deps externas — leitura pura de FS). - Documentar em
CODING_STANDARDS.md§4.2 que bump de Deno local exige atualizar o CI no mesmo commit.
Por que NÃO regenerar o lockfile em formato antigo: regrediria a toolchain local e abriria janela para resolução de deps inconsistente entre máquinas.
Lições:
- Falha de CI em 0s = problema de runtime/setup, não de código aplicação. Comparar versão da toolchain antes de tocar no script.
- Lockfiles versionados são contrato CI ↔ local. Qualquer step que leia o lockfile precisa de versão pinada do runtime que o gerou.
- Ranges (
v1.x,v2.x,latest) em CI são bombas-relógio — funcionam até o dia em que uma release sobe formato de lockfile, schema de manifest, ou flag default. - Adicionar lint de auditoria ao CI ≠ adicionar comando ao YAML. Inclui validar que o runtime do CI consegue ler os arquivos do repo (lockfile, config, etc.).
Caso 18: Deploy de docs quebra por dead link / fenced block / mustache Vue / lockfile drift (recorrente)
Sintoma: bun run docs:build (ou o bun install que o antecede) aborta no job docs-validate (PR), no deploy-docs (push em main), ou no build do Cloudflare Pages com uma destas mensagens:
Found dead link /<secao>/index in file <arquivo>.md→Build failedThe language 'env' is not loaded, falling back to 'txt'(warning ruidoso recorrente)Error parsing JavaScript expression: Unexpected token(mustacheliteral)error: lockfile had changes, but lockfile is frozen(driftpackage.json×bun.lock)
Causa raiz típica: uma das quatro classes — link de índice de seção escrito como /<secao>/index em vez da rota canônica /<secao>/, fenced block com linguagem fora da allowlist (env, dotenv, deno), literal no markdown sendo interpretado como expressão Vue, ou alguém alterou package.json (bump de dep, novo pacote) sem rodar bun install para regenerar bun.lock. O deploy do Cloudflare Pages roda bun install --frozen-lockfile; o docs-validate no GH Actions roda o mesmo desde o incidente de 2026-06-04 (log docs-4f8a9b2c).
Protocolo:
- Não relaxar
ignoreDeadLinksemdocs/.vitepress/config.ts— isso esconde o problema e desabilita a validação para casos legítimos futuros. - Para drift de lockfile: rodar
bun installlocalmente (sem--frozen-lockfile) e commitarbun.lockno mesmo PR dopackage.json. Confirmar depois combun install --frozen-lockfile(deve sair limpo). - Para os erros de markdown/VitePress: corrigir o fonte seguindo o SSOT DOCUMENTATION_MAINTENANCE.md — Docs Build Guardrails.
- Validar localmente com
bun install --frozen-lockfile && bun run docs:buildantes de re-push.
Lição: todo doc tocado — ou toda mudança de package.json — é uma chance de quebrar o site público. A validação local é gate de PR; não delegar ao CI.
Caso 19: Synthetic UI default promovido a write real — modal batch multi-item (Premiação por Nível, junho/2026)
Sintoma: Coordenador (Centro de Excelência Master, fase Canguru) edita faixas um nível por vez no modal "Definir Premiações" em modo por_nivel e salva. Ao reabrir, valores do nível editado parecem corretos, mas os demais níveis aparecem com valores "padrão" idênticos (ex.: 90/80/70/60) que ele nunca digitou. Próximo save por nível "sobrescreve" o anterior. Acusação inicial recai sobre recomputeFaseForOlimpiada ou fallback global.
Diagnóstico (payload-first, conforme Caso 13):
- Network do submit: payload de
set_premiacao_batchcontém 6 níveis, mas o usuário só editou 1. Os outros 5 vão comFAIXAS_DEFAULTsintéticos. - Backend (
handleSetPremiacaoBatch): faz upsert fiel do que recebe — não inventa, não tem bug.cleanupem modopor_nivelsó remove a linhaglobal(nivel_id IS NULL), nunca níveis ausentes do payload. - Recompute: reativo — apenas reclassifica conforme as faixas que o frontend mandou persistir.
Causa raiz: premiacao-faixas-modal.tsx seedava FAIXAS_DEFAULT em useState ao abrir o modal ou ao trocar para modo por_nivel. O onConfirmar enviava o batch completo com todos os níveis, fazendo defaults sintéticos (placeholders visuais) virarem writes reais. Cada save isolado parecia "voltar ao padrão" porque o save anterior fora soterrado pelos seeds do novo render.
Anti-padrão (geral, não só Resultados):
Modais batch que renderizam N itens NÃO devem inicializar o state com valores sintéticos só porque o input precisa de algo para exibir. Placeholder visual ≠ estado persistível.
Solução:
- Não seedar defaults no state. Manter
state[item] = undefined/emptyquando não há config persistida. - Placeholder visual via prop
placeholderdo<Input />(sugestão UX, nunca commitada). - Tracking explícito de intenção com dois
Set<string>:touched— itens que o usuário editou nesta sessão (handler de change adiciona).existentes— itens que já tinham config persistida no backend ao abrir.
- Submit filtra payload:
itens.filter(i => touched.has(i.id) || existentes.has(i.id)). Atalhos tipo "aplicar a todos" devem marcar todos comotouchedexplicitamente. - Validação E2E: após editar 1 item e salvar, demais linhas no DB têm
atualizado_emantigo (preservadas). Confirmado em Master/Canguru: Nível P @ 18:50, demais @ 18:52 — saves incrementais isolados.
Protocolo de diagnóstico em ordem (não pular):
| Camada | Pergunta | Sinal de que é aqui |
|---|---|---|
| 1. Payload (Network) | O que o frontend está mandando? | Payload contém itens não tocados |
| 2. Backend handler | Upsert/Delete corresponde ao payload? | Nenhuma divergência → bug é no §1 |
| 3. Recompute/reativo | Recompute introduz dados novos? | Só se §1 e §2 estiverem limpos |
Lição: "voltou ao padrão" em sintoma de UI quase sempre = UI persistindo seu próprio placeholder. Inspecionar payload antes de culpar backend ou recompute. Ver mem://ui/no-synthetic-defaults-in-batch-modals e Caso 13 (payload-first).
→ Ref: src/components/coordenador/resultados/premiacao-faixas-modal.tsx, docs/features/RESULTADOS.md §14, mem://debugging/payload-trust-check.
Caso 20: Recompute síncrono pós-mutação singular (write fan-out no caminho do Response) — junho/2026
Sintoma: Coordenador edita UMA célula no modal "Editar resultado do aluno" (situação ou pontuação). O toast "Salvando…" trava ~10s antes de fechar o modal. Reproduzido no Colégio Conquista (272 inscrições, fase com nota de corte). Mais lento em escolas grandes — em Centro de Excelência Master (Canguru, 1417) seria >30s.
Acusações iniciais (todas erradas):
- "É a escola Conquista" → não. O custo cresce com
inscricoes_olimpiadada fase, não com o aluno editado. - "É a rede / Cloudflare" → não. Network panel mostra
set_situacao_alunoretornando 200 após ~10s. - "É trigger no Postgres" → não existe trigger em
resultados_aluno. - "É RLS recursivo" → policies usam
escola_iddireto, sem subselect.
Causa raiz (duas camadas combinadas):
Camada A — N UPDATEs individuais no recompute
recomputeFaseForOlimpiada (supabase/functions/_shared/resultados-compute.ts) escrevia TODAS as linhas da fase, uma update().eq('id', …) por linha, em lotes paralelos de 50. Para 272 inscrições isso era ~6 round-trips PostgREST. Pior: 271 dessas linhas tipicamente não mudaram (mesma situação, mesmo rank).
Camada B — Recompute + snapshot + log NO caminho do Response
O handler handleSetSituacaoAluno em _shared/resultados-config.ts aguardava await recomputeFaseForOlimpiada + await recomputeSnapshotsIfPublished + registrarLog ANTES de devolver 200. Toast só aparece quando o último await resolve.
Fix canônico (aplicado):
- Recompute diff-based:
recomputeFaseForOlimpiadaagora compara o estado calculado com o estado atual em memória e só escreve as linhas que de fato mudaram (changed.lengthtipicamente 1 numa edição singular). - Recompute em background:
recomputeFaseForOlimpiada+recomputeSnapshotsIfPublished+registrarLogrodam viaEdgeRuntime.waitUntil. O Response sai imediatamente após a linha alvo ser persistida. - Frontend:
useGestaoResultados.setSituacaoAlunoMut.onSuccessinvalida React Query imediatamente (linha alvo já está correta) e agenda um segundoinvalidateAll()em 2.5s para capturar ranks recomputados em background.
Validação:
- Network panel:
set_situacao_alunocai de ~10s para <500ms. - Linha alvo aparece atualizada no instante do toast.
- Ranks dos demais alunos convergem em ~2s (refetch atrasado).
Anti-padrão a NUNCA repetir:
Mutação que afeta 1 linha NUNCA deve disparar O(N) writes síncronos no caminho do Response. Recompute global é trabalho derivado: roda em
EdgeRuntime.waitUntil, com diff em memória + 1 round-trip de write.
Protocolo de diagnóstico para "edição lenta de 1 item":
| Etapa | Pergunta | Ferramenta |
|---|---|---|
| 1. Tempo do Response | O 200 demora >500ms? | Network panel → Timing |
| 2. O handler escreve N linhas para 1 mutação? | Grep Promise.all + update().eq('id', no handler | rg |
| 3. O handler aguarda recompute/snapshot/log antes do Response? | Procurar await entre upsert e return new Response | code view |
| 4. Há diff antes do write? | Procurar .filter(u => u.mudou) ou equivalente | code view |
Quando aplicar este padrão: qualquer handler que rode recompute global, snapshot publicado, ou log auditável após uma mutação singular. Hoje aplicável a set_situacao_aluno e set_premiacoes_manual (mesmo arquivo). Próximas mutações singulares devem nascer já neste padrão.
→ Refs: mem://architecture/performance/post-mutation-recompute-standard, mem://architecture/edge-function-background-task-standard, docs/architecture/adrs/ADR-016-recompute-pos-mutacao-assincrono.md.
Caso 21: fatura_id: Required — Contrato invokeAction wrapper vs Zod no body raiz (junho/2026)
Sintoma: ao clicar no ícone "Gerar boleto" em /admin/financeiro, o modal abria e imediatamente disparava olpToast.error("Falha ao gerar boleto", { description: "Payload inválido (fatura_id: Required)" }). Reproduzia 100% das vezes, mesmo com fatura existente, hidratada e com UUID válido na lista.
Acusações iniciais (todas erradas):
- "É race com a hidratação da lista —
fatura.idchegaundefined." → falso. Network panel mostrou o body literal:jsonO UUID estava lá. Hipótese de race foi um beco sem saída que custou 1 hotfix decorativo ({"action":"gerar","params":{"fatura_id":"250fd5e8-e8bb-4c72-a864-7bcd08fe98df","dry_run":true}}assertFaturaIdno hook) sem corrigir nada. - "O modal monta antes da fatura selecionada." → falso, o
pendingFaturaestava preenchido no clique. - "Validação Zod do hook está estrita demais." → o regex UUID estava correto.
Causa raiz real (descoberta inspecionando o Network): quebra de contrato entre o wrapper invokeAction e o handler da Edge Function.
// src/lib/edge-function.ts
invokeAction(fn, action, params)
// envia body: { action, params: { ...params } }
// supabase/functions/mercadopago-boleto/index.ts (ANTES)
const body = await req.json();
const parsed = GerarSchema.safeParse(body); // ⛔ esperava body.fatura_id
// → fatura_id mora em body.params.fatura_id, Zod sempre rejeitaMesmo com UUID válido, o Zod parseava o body raiz, onde só existia { action, params }. Como fatura_id não estava no nível raiz, retornava "fatura_id: Required". O frontend nunca conseguiria acertar enquanto chamasse via invokeAction.
Por que os testes existentes não pegaram:
- O teste do hook (
useMercadoPagoBoleto.integration.test.tsx) mockavainvokeActione usava IDs placeholder ('f1','fatura-1'). Nunca testou o body real que sai na rede. - O teste contratual da edge function só validava CORS/401/action desconhecida — nunca chamou
gerarcom payload completo. - O
assertFaturaIdadicionado no hotfix anterior validava UUID client-side, o que escondia ainda mais o problema verdadeiro: o backend rejeitava o UUID válido.
Fix canônico:
// supabase/functions/mercadopago-boleto/index.ts (DEPOIS)
const body = await req.json();
const rawParams = body.params ?? body; // compatível com invokeAction E flat legado
const parsed = GerarSchema.safeParse(rawParams);Cobertura adicionada:
supabase/functions/mercadopago-boleto/__tests__/payload-contract.test.ts— parser puro alimentado com o body literal capturado do Network ({ action, params: {...} }) E o formato flat legado. Sem mock.src/hooks/__tests__/useMercadoPagoBoleto.integration.test.tsx— substituiu todos os placeholders por UUIDs válidos + 3 testes garantindo que UUID inválido rejeita antes de chamarinvokeAction.
Lições obrigatórias:
- Sintoma de "payload inválido" em mutation que chama Edge Function via
invokeAction→ SEMPRE abrir Network ANTES de mexer no hook ou na UI. Se o body tem{ action, params: {...} }e o handler fazSchema.safeParse(body), a raiz é esta. Ver Caso 13 (mesmo princípio: payload no Network antes da UI). - Edge Function com action dispatch + Zod DEVE extrair
body.params ?? bodyantes do parse. Padrão registrado em ADR-003 §Contrato cominvokeActione em AUDIT.md §5. - Teste de hook com
invokeActionmockado não substitui teste de parser puro. O mock satisfaz a interface do hook mas não exercita o contrato real. Toda Edge Function com Zod precisa de teste do parser consumindo o body literal. Ver DEV_WORKFLOW.md §Fase 2. - Não adicionar guard client-side como "fix" sem provar que o problema é client-side. O
assertFaturaIdficou na base como defesa em profundidade, mas o hotfix original era decorativo — escondia o erro real ao invés de corrigi-lo.
→ Refs: AUDIT §5 Edge Functions, AUDIT §15 Testes, DEV_WORKFLOW Fase 2, ADR-003, mem://architecture/edge-function-action-dispatch-zod-contract, docs/features/billing/boleto-manual.md §"Bug histórico".
Caso 22: Status 502 mascara erro de domínio do provedor — limite de 29 dias do MP (junho/2026)
Sintoma
Usuário escola clica em "Gerar boleto" para a fatura OLP-2026-0007 (julho/2026). Toast vermelho:
Falha ao gerar boleto — Servidor temporariamente indisponível (502)
Console: Status 502, retry 1/2 em 2000ms → retry 2/2 em 4000ms → Status 502 após 2 retries. Total: ~6s de espera, 3 chamadas ao backend, 3 chamadas ao Mercado Pago, nenhuma pista do motivo na UI.
Diagnóstico (camada por camada — onde a mensagem foi perdida)
Consulta direta a logs_transacoes mostra a causa real, repetida 3x:
select detalhes->>'erro' from logs_transacoes
where acao = 'pagamento.boleto_erro' order by criado_em desc limit 5;
-- "The expiration date can not be greater than 29 days"Cascata de 4 camadas:
MP → 400 "expiration date can not be greater than 29 days"
↓
[1] payment-gateway.createBoleto → { success: false, error: <msg do MP> } ✅ ok
↓
[2] mercadopago-boleto/index.ts → HTTP 502 "Falha ao emitir boleto: ${msg}" ❌ deveria ser 422
↓
[3] invokeEdge (frontend) → 502 está em TRANSIENT_STATUS_CODES ❌ retry 2x
→ mensagem genérica "Servidor temporariamente indisponível (502)"
→ descarta data.message do backend
↓
[4] hook + toast → "Falha ao gerar boleto / 502" ❌ usuário não vê causaCausa raiz
Duas violações arquiteturais conjugadas:
Backend confundiu erro de domínio com falha de upstream. O MP retornou 4xx (regra de negócio: boleto só aceita vencimento ≤29 dias), mas o handler converteu indiscriminadamente para HTTP 502. 502 deveria significar "upstream caiu" — não "upstream rejeitou meu payload".
Wrapper frontend descarta o body em status transitório.
invokeEdgechamavaretryem 502/503/504 ANTES de ler o body. Mesmo quando o backend devolvia mensagem útil, ela era jogada fora e substituída por "Servidor temporariamente indisponível".
Efeito multiplicador: 1 clique → 3 chamadas ao MP → 3 logs pagamento.boleto_erro → cota desperdiçada + usuário sem informação.
Fix
Camada [1] — adapter (_shared/payment-gateway.ts): propagar status HTTP do MP dentro de raw. Criar helper SSOT classifyMpError() + constante MP_BOLETO_MAX_DAYS_AHEAD = 29.
Camada [2] — handler (mercadopago-boleto/index.ts):
- Pré-validação local: rejeita com 422 +
code: 'vencimento_fora_janela_mp'quandodiasAteVencimento > 29. Zero chamadas ao MP. - Quando MP devolve erro: chamar
classifyMpError({error, raw})→ 422 para 4xx, 502 só para 5xx/timeout/network. - Log
pagamento.boleto_erroenriquecido commp_response,classificacao_status,classificacao_code,vencimento_dias_distancia. - Nova ação
pagamento.boleto_pre_validacao_falhoupara rejeições antes do MP.
Camada [3] — wrapper (src/lib/edge-function.ts):
- Ler
response.clone().json()ANTES de decidir retry em status transitório. - Se o body contém
{success:false, message}→ é erro de domínio: passa adiante intacto, sem retry. - Caso contrário → mantém comportamento de retry (upstream realmente caiu).
Camada [4] — UI (pagamentos-escola.tsx):
- Botão de boleto fica
disabledcomtitle=explicativo quandovencimento - hoje > 29dias: "Disponível a partir de DD/MM/AAAA (limite de 29 dias do Mercado Pago)".
Lições
- HTTP 502 é semântico — não é "tudo deu errado". 502 significa "upstream/gateway falhou". Erro de domínio do provedor é 422. Confundir os dois ativa o pior caminho do wrapper (retry desperdiçado + mensagem genérica).
- Retry transitório DEVE inspecionar o body antes de decidir. Wrapper que descarta
data.messageem status 5xx esconde causa raiz e multiplica chamadas externas — duas falhas pelo preço de uma. - Limites operacionais conhecidos do provedor exigem pré-validação local. Toda chamada de boleto MP fora da janela de 29 dias é desperdício garantido — não faz sentido descobrir isso no MP. Catalogar limites em doc de feature.
- Logs de transação ponta a ponta existem — mas só são úteis se o erro chegar ao usuário ou ao operador. A mensagem "expiration date" estava em
logs_transacoesdesde o primeiro segundo; ninguém olhava porque a UI dizia "502". Enriquecer log é necessário mas não suficiente: a mensagem útil tem que SUBIR. - Sintoma de "502 intermitente" em produção SEMPRE inspecionar
logs_transacoes.detalhes.erroantes de culpar infra. Edge Functions, Supabase e MP raramente caem juntos.
→ Refs: AUDIT §5 (Edge Function), mem://architecture/external-provider-error-status-mapping, docs/features/billing/boleto-manual.md §"Limites operacionais do Mercado Pago", testes payment-gateway-error-mapping.test.ts, pre-validation-janela-mp.test.ts, edge-function-transient.test.ts.