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 (recorrente)
Sintoma: bun run docs:build aborta no job docs-validate (PR) ou deploy-docs (push em main) 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)
Causa raiz típica: uma das três 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), ou literal no markdown sendo interpretado como expressão Vue.
Protocolo:
- Não relaxar
ignoreDeadLinksemdocs/.vitepress/config.ts— isso esconde o problema e desabilita a validação para casos legítimos futuros. - Corrigir o markdown fonte seguindo o SSOT DOCUMENTATION_MAINTENANCE.md — Docs Build Guardrails.
- Validar localmente com
bun run docs:buildantes de re-push.
Lição: todo doc tocado é uma chance de quebrar o site público. A validação local é gate de PR — não delegar ao CI.