Skip to content

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árioDocumento
"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 frontend se 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):

  1. Ler o código-fonte relevante
  2. Verificar console/logs
  3. Consultar banco de dados
  4. 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? → truncate CSS não funciona
  • Por que truncate não funciona? → forwardRef ausente no componente base, Radix não consegue medir/conter o elemento

2.3 Classificação do fix

TipoQuando usarRisco
PaliativoEmergência, sem tempo para root causeAlto (mascara o problema)
ContençãoLimita o impacto enquanto investigaMédio
EstruturalCausa raiz identificada e comprovadaBaixo

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):

  1. Migration SQL primeiro (adiciona antes de remover)
  2. Backend (alinhado com novo schema)
  3. 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:

  1. Essa propriedade existe na tabela como coluna? Se não — deveria.
  2. A propriedade muda quando entram novos registros (papéis, status, tipos)? Se sim — coluna obrigatória.
  3. 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ãoConsequênciaCorreção
Aplicar CSS sem investigar componente baseFix falha, sintoma migra (lateral→vertical)Investigar a raiz antes de estilizar
Assumir perda de dados sem consultar bancoPânico desnecessário, ações destrutivasSELECT antes de qualquer "recuperação"
Deployar schema change sem código alinhadoQueries quebram, dados "somem"Deploy atômico (migration+code)
Adicionar overflow-hidden sem min-w-0Container respeita max-w mas filhos grid expandemAmbos necessários em layouts grid/flex
Tratar warning do console como cosméticoWarning de ref causa falha real de medição/layoutWarnings são sintomas de bugs estruturais
Funções de hook sem useCallback em deps de useEffectLoop infinito de re-renders, UI congelauseCallback obrigatório em toda função retornada por hook
enabled que depende de dado que só vem da própria queryDeadlock circular, query nunca disparaPrimeira 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 throwReact Query acha que deu certo, não faz retrySempre throw em erro dentro de queryFn
Tratar timeout (504) como sessão expiradaLogout silencioso sem explicação ao usuárioDiferenciar _transient vs auth error, retry antes de deslogar
useEffect redundante chamando funções que React Query já auto-executaRequests duplicados no mount, risco de loopRemover useEffect manual — confiar no useQuery
Lista hardcoded de strings classificando linhas de tabela do banco (["administrador","especialista","escola"]) repetida em 2+ sitesDrift entre sites; incidente de segurança quando 1 fica desatualizadoPromover para coluna na tabela (ex: papeis.escopo); todo guard lê do banco
Refactor de denylist/allowlist parando no primeiro arquivo encontradoCópia idêntica em outra action sobrevive; segurança aparente, drift garantidorg "<padrão>" supabase/ src/ retornar zero matches antes de fechar a tarefa
"Test deferred" sem entrada concreta em backlogDívida invisível que só vira trabalho quando alguém audita explicitamenteTODO comentado no código + entrada em memory de feature OU task tracking
Decisão de aceitar risco apenas em mensagem de chatPróximo turno/dev não reconcilia com o scanner; risco volta como bugRISK_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 schemaSolução de código onde caberia solução de modelagem; menor manutenibilidade3ª opção sempre considerada: "isso é coluna?" (ver §3.4)
Reportar mem://... escrita sem verificar persistênciaMemory fantasma — orientação some no próximo turnocode--view/ls no mesmo turno do write
Logar CPF/telefone/e-mail/JID cru via console.*, registrarLog() ou alertas ntfyVazamento 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

  1. truncate no SelectTrigger → Não funcionou porque o componente Select não usava forwardRef, impedindo Radix de medir o elemento
  2. overflow-hidden no DialogContent → O conteúdo parou de vazar lateralmente mas passou a crescer verticalmente
  3. max-w forçado no trigger → Limitou o trigger mas não o popover

Resolução definitiva

  1. Causa raiz: src/components/ui/select.tsx usava function components sem forwardRef
  2. Fix estrutural: Refatorar Select para usar React.forwardRef em todos os sub-componentes (Trigger, Content, Item, Label, Separator, ScrollUp/Down)
  3. Contenção complementar: max-h-[calc(100vh-2rem)] + overflow-y-auto no DialogContent
  4. 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

  1. Console warnings (Function components cannot be given refs) eram a pista direta da causa raiz
  2. Sintomas visuais diferentes (overflow lateral vs vertical) podem ter a mesma causa
  3. "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_cores usa padrão "RLS enabled, zero policies" — acesso exclusivo via service_role no 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_coordenadores retornado 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

  1. Features visuais condicionais (cor só se >1 coord) precisam de dados contextuais do backend — não assumir no frontend
  2. Tabelas auxiliares de UI (cores) seguem o padrão service_role do projeto para simplicidade
  3. 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

  1. Por que demora 5s? → 4 requests HTTP paralelos, cada um com overhead de auth+flag
  2. Por que cada request é lento? → Dentro de cada um, N queries sequenciais por olimpíada
  3. Por que N queries? → for (const olp of olimpiadas) { await supabase.from('fases')... } — loop sequencial

Resolução

  1. Action batch_init_resultados: Consolida 4 requests em 1 único
  2. .in('olimpiada_id', ids): Busca fases/inscrições/resultados de todas olimpíadas de uma vez
  3. Promise.all no backend para paralelizar queries independentes
  4. 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

  1. Por que mostra "sem olimpíadas"? → olimpiadas está []
  2. Por que []? → Query nunca executou (enabled: false)
  3. Por que enabled: false? → Depende de anoEdicao que 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: enabled nunca 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

  1. Por que a UI congela? → Loop infinito de re-renders saturando o event loop
  2. Por que loop infinito? → useEffect dispara a cada render porque deps mudam
  3. Por que deps mudam? → Funções retornadas pelo hook sem useCallback criam nova referência a cada render

Resolução

  1. useCallback em todas as 12 funções retornadas pelo hook
  2. Remoção do useEffect de mount redundante — React Query já auto-carrega via useQuery
  3. 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 como mensagens.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

text
Infra timeout (504) ──┬──→ auth-context: "sem sessão" → logout silencioso
                      └──→ banners: retorna [] → sem retry → tela vazia

1 falha de infra gerou 3 sintomas aparentemente independentes.

3 Porquês

  1. Por que logout silencioso? → auth-context trata qualquer falha do /me como sessão expirada
  2. Por que banners vazios? → queryFn retorna [] em erro → React Query acha que deu certo
  3. Por que tudo ao mesmo tempo? → Timeout de infra afetou ambos os endpoints simultaneamente

Resolução

  1. invokeEdge: retry automático (max 2) com backoff para 502/503/504
  2. auth-context: flag _transient diferencia timeout de sessão expirada; toast informativo + retry em 5s
  3. Banners: throw em erro dentro de queryFn → React Query faz retry
  4. Resultado: Timeout transitório é invisível para o usuário; sessão realmente expirada mostra toast explicativo

Regra Derivada

queryFn DEVE fazer throw em 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 diretor abre sem permissões, mesmo com features globais ativas
  • Botão "Salvar permissões" não produz feedback (silencioso)
  • Usuário logado como diretor vê sidebar/tabs inconsistentes

3 Porquês (Causa Raiz Múltipla)

  1. Por que permissões aparecem vazias?get_permissoes_usuario recebia só usuario_id + escola_id e pegava vinculos?.[0] (primeiro papel). Se coordenador veio primeiro, diretor recebia permissões do coordenador (ou zero se incompatíveis).

  2. Por que salvar não funciona? → Modelo de dados: usuarios_escola_permissoes com UNIQUE (usuario_id, escola_id, permissao) — sem papel_id. Salvar diretor sobrescrevia coordenador ou gerava conflito silencioso.

  3. Por que sem toast? → Wrappers em admin-usuarios.tsx engoliam erro no catch e retornavam { success: false } sem chamar olpToast. Frontend não reidratava após save.

Cascata de Falhas

text
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 feedback

1 falha estrutural (modelo de dados) gerou 4 sintomas aparentemente independentes.

Resolução (Estrutural — 5 camadas)

  1. Migration: Adicionou papel_id em usuarios_escola_permissoes e usuarios_escola_sub_permissoes. Novo UNIQUE: (usuario_id, escola_id, papel_id, permissao). Backfill de dados existentes por papel compatível.
  2. Backend CRUD: get_permissoes_usuario e update_permissoes_usuario recebem e filtram por papel_id explícito.
  3. Helpers: seedPermissionsForRole e removePermissionsForRole escopados por papel_id.
  4. Frontend admin: AdminPermissoesGrid recebe papel_id, implementa feedback com olpToast, rehydrata após save.
  5. /me: Resolve papel_id do papel ativo e retorna permissões isoladas.

Regras Derivadas

Toda tabela de permissão DEVE ter escopo de papel_id — nunca apenas usuario_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ãoConsequênciaCorreção
Permissões escopadas só por (usuario_id, escola_id)Papéis colidem na mesma escolaAdicionar papel_id ao modelo + queries
Backend pega "primeiro vínculo" sem papel_idUI mostra dados de outro papelReceber e filtrar por papel_id explícito
Wrapper com catch silenciosoUsuário sem feedback de saveolpToast em todo success/error path
Frontend mapeia papel_id: ''Backend não encontra vínculoPreservar UUID real do mapeamento
gestao-usuarios-escola INSERT sem papel_idRegistros órfãos → /me não encontra → AccessBlockedScreenResolver papel_id do vínculo ativo antes de INSERT
DELETE sem scoping por papel_idApaga permissões de todos os papéis.eq("papel_id", resolved) em todo DELETE
Falta seed de sub_permissoes no createTabs vazias até admin salvar manualmenteseedSubPermissoesForRole() após criação
Unique index com NULL (papel_id)Duplicatas ilimitadas no PostgreSQLALTER COLUMN papel_id SET NOT NULL
Backend não valida novo valor de enumFrontend envia valor válido, backend rejeita com 400 genéricoAtualizar 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

  1. Por que flash ao trocar tab? → Skeleton aparece por 1 frame antes do conteúdo
  2. Por que skeleton aparece? → Guard usa if (isLoading) return <Skeleton />, e isLoading é true no primeiro render do hook
  3. Por que isLoading é true se tem cache? → React Query inicializa useQuery com isLoading: true por 1 tick antes de detectar cache existente via queryKey

Resolução (2 Camadas)

Camada 1 — Sub-tabs: Trocar o guard de loading:

typescript
// ❌ 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:

text
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 aparece

Correção: mover setVisitedTabs para dentro do handleTabChange, no mesmo batch síncrono que setActiveTab:

typescript
// ❌ 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 useEffect para 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.memo aparentemente "não funciona"

3 Porquês

  1. Por que todas as rows re-renderizam ao editar uma?handleChange é chamado no useEffect de cada row a cada render
  2. Por que useEffect dispara? → Dependência instável (state local que muda a cada keystroke)
  3. 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:

typescript
// ❌ 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 via useEffect. Computar inline no save ou como useMemo. useEffect que chama setState/handleChange para campos derivados é anti-padrão — causa re-renders cascata que invalidam React.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

  1. Por que warning de ref?TooltipTrigger tenta passar ref para o filho, que não aceita
  2. Por que não aceita?SelectItem do Radix é gerenciado internamente e espera controle exclusivo de ref e focus
  3. 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:

tsx
// ❌ 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

  1. Por que o pisca?<SortableContext> re-renderiza com a ordem do cache (antiga) entre o setActiveId(null) e a chegada do refetch.
  2. 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.
  3. 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:

ts
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 o onSettled reconcilia 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
  • useShimmerProgress aparentemente "rodando" (em React DevTools)

Os 3 diagnósticos errados (anti-padrão)

#HipóteseAção tomadaPor que falhou
1setTimeout acumula drift entre framesReescrever para requestAnimationFrameMecanismo não era o problema — o loop rAF nunca era atingido
2Z-index escondendo a faixa atrás do gradienteisolate + 3 camadas explícitas (z-0/10/20)Empilhamento já estava OK; faixa estava VISÍVEL, só não se movia
3Race condition entre CSS transition e re-render ReactRemover transition, calcular transform por frameprefers-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:

tsx
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)

  1. Etapa que foi ignorada nas 3 tentativas: inspeção do DOM real no DevTools
  2. Atributo data-shimmer-phase="reduced" estava visível no elemento desde sempre
  3. Confronto com código: phase === 'reduced' só ocorre no early-return de acessibilidade
  4. Confronto com SO: System Settings → Accessibility → Reduce Motion: ON
  5. 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

text
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 consumidores

Por que o diagnóstico errou 3 vezes (causa-mãe)

Lacuna estrutural na documentação:

  • AUDIT.md (atual AUDIT.md) cobria conformidade pós-entrega — não diagnóstico de bug visual
  • PROBLEM_SOLVING.md tinha 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-motion DEVEM expor a política via data-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 transform está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ãoConsequênciaCorreção
Reescrever animação antes de inspecionar DOM no DevToolsMúltiplas tentativas inúteis, código mais complexoEtapas 1–3 de AUDIT_FRONTEND.md §2 antes de tocar código
prefers-reduced-motion retorna JSX com transform estático intermediárioAparenta 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 completodata-phase, data-state, data-motion-policy em todo componente animado
"Animação travada" sem checar Animations panel do DevToolsNão distingue "rodando lento" de "não rodando"DevTools → Animations OU Performance timeline (Etapa 4)
CSS transition: transform combinado com transform calculado por rAFRace condition entre paint e cálculo de frameEscolher um mecanismo só (rAF puro OU CSS puro)
Diagnóstico baseado em "o que faz sentido pelo código" sem evidência observávelHipóteses encadeadas, cada uma quebrando algo novoSempre 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 NULL em 121 dos 143 templates
  • nome_template preenchido 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):

  1. Inspeção do banco (não da tela): SELECT COUNT(*) WHERE assunto IS NULL → 121

  2. Conferência do payload (Network ou 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:"
  3. 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"

  4. Confronto: o ZIP é "corpo puro" (sem marcador Título:). O parser retorna titulo: '' 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)

  1. Parser (stripTxtExtension): regex /(?:\.txt)+$/i para tratar .txt.txt duplicado dos arquivos de origem.

  2. Wizard, inicialização do ParsedItem: quando parsed.titulo está vazio, cair no nomeTemplate (= fileName sem .txt):

    ts
    const tituloFinal = parsed.titulo.trim() || nomeTemplate;
  3. 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):

    ts
    assunto: (it.titulo && it.titulo.trim()) ? it.titulo : it.nomeTemplate,
  4. Migration de purga idempotente dos 143 templates corrompidos, mantendo template_hubs intactos para reimportação limpa.

Cascata de falhas anti-padrão (que NÃO ocorreu)

text
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 + DELETE condicional. Permite reexecução sem efeitos colaterais (ver MIGRATION_GUIDELINES.md §1).

Anti-padrões catalogados

Anti-padrãoConsequênciaCorreção
Confundir "campo vazio na UI" com "tela mal desenhada"Refactor inútil que não resolveConferir payload no Network ANTES de mexer na UI
Fallback aplicado só na inicialização do estadoUsuário apaga manualmente → NULL no bancoMesmo fallback também na borda do payload (defesa em profundidade)
regex /\.txt$/ para sanitizar extensão.txt.txt duplicado vira .txt no nomeregex /(?:\.txt)+$/i para múltiplos sufixos
Migration de seed/purga sem IF EXISTS guardFalha 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

  1. Conferir a policyprincipal_role IN ('coordenador','diretor') está presente. ✅
  2. Conferir a query manual no banco com a chave do JWT real — retorna a linha. ✅
  3. Conferir os logs da Edge Functionportal.slug=null (ok) em escola que tem slug. ❌ Anti-sinal.
  4. Inspecionar a orquestraçãoPromise.all com 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' (ou success: 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ãoConsequênciaCorreção
Promise.all com N queries e destructuring com N-1 variáveisResultado errado é silenciosamente atribuído à variável seguinteCode 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 UXEndurecer shape no backend; error em vez de ok quando shape inesperado
Pular para "deve ser RLS" sem conferir logs do backend primeiroMexer em policy correta, introduzindo regressão de segurançaSeguir 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)

  1. Conferir o DBSELECT pontuacao_maxima_por_nivel FROM fases_olimpiada WHERE id=… → confirma que o valor no banco é o antigo, não o novo.
  2. 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)
  3. Conferir o backendpersistirFasesPorNivelRelacional priorizava 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 modo uniforme e devem ser derivados do escalar no write — nunca consumidos como fonte.

"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.tsxonChange do 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.ts cobrindo R1 (regressão), R2 (escalar), R3 (mapa legado), R5 (range), R6 (insert), R7/R7b (delete vs warning).

Anti-padrão catalogado

Anti-padrãoConsequênciaCorreção
Endpoint de write aceita dado em dois shapes sem SSOT explícitoUI 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 derivadoRound-trip GET→PUT carrega mapa stale que sobrescreve edição do escalarDevolver só o SSOT; consumidor que precisa da outra forma deriva
Diagnosticar "não persiste" pela UI sem checar payload+DBCaça-fantasma em invalidação de cache, RLS, React QueryCaso 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:

  1. realtime_messages_no_rls — qualquer autenticado podia se inscrever em qualquer canal Realtime.
  2. gestor_escola_insert_usuario_papeis_escalation — gestor escola podia inserir papel administrador em usuario_papeis (privilege escalation).
  3. 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: error em 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

  1. 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.
  2. Por que listas espalhadas? → A tabela papeis tinha nome mas faltava classificação (escopo). Sem coluna, cada consumidor (DB trigger, edge function, frontend) inventou sua interpretação.
  3. 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 realDetecção
1Refactor 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
2Lista 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
4Memory 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
5Sintoma 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
6Discussã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
7Risco 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/ e index.ts da 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.messages com 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_escalationpapeis.escopo (não hardcoda).
  • Allowlist via escopo: gestor escola só atribui escopo='escola' em sua própria escola, nunca o papel escola.

Onda 3 (Task C — Fases B/B.2/B.3) — Refactor downstream:

  • fn_validar_escola_usuario_papel (DB trigger) reescrita lendo papeis.escopo.
  • _shared/gestao-usuarios-helpers.ts reescrito (Fase B.2).
  • gestao-usuarios-escola/index.ts:653 reescrito (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 (coluna escopo), 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é rg por 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.md sã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/ls no mesmo turnocode--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ãoConsequênciaCorreção
Tabela com classificação implícita (papéis sem coluna escopo) consumida por 3+ camadasCada camada hardcoda sua interpretação; drift entre N sites; auditoria descobre tardeColuna explícita + CHECK constraint + backfill + leitura SSOT
migration única que junta schema change + refactor de N consumidoresRollback complexo; revisão difícil; risco aumentadoSchema 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/backlogDívida silenciosa; auditoria descobre na próxima rodadaTODO no código + memory de feature + entrada em backlog antes de marcar done
rg apenas no arquivo "óbvio" depois de refactorCópia em outra action sobreviverg em supabase/ src/ retornar 0 matches antes de fechar
Reportar criação de memory sem verificar com ls/viewMemory fantasmaVerificação no mesmo turno é parte do contrato de write
Falar "risco aceito" só no chat, sem artefato com IDPróximo dev/scanner não reconcilia; risco volta como bugRISK_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)

  1. Após qualquer refactor de hardcoderg global do padrão é checagem mínima, não opcional.
  2. Antes de fechar uma task — confirmar que cada follow-up tem artefato concreto (TODO, memory, backlog).
  3. Após code--write mem://... — confirmar persistência com code--view no mesmo turno.
  4. Antes de aplicar fix em sintoma de segurança — perguntar "isso é guard de código ou falta de modelo?".
  5. Antes de manage_security_finding(ignore) — exigir entrada em RISK_ACCEPTED_LOG.md com 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 lockfile

bun run build é pulado em cascata. Localmente, o script roda sem erro.

Diagnóstico (camadas, na ordem):

  1. Tempo de falha = 0s → não é o script. É a inicialização do runtime Deno antes de avaliar o arquivo.
  2. deno --version local: 2.6.10. ci.yml: deno-version: v1.x.
  3. deno.lock no 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).
  4. 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-cli rodava 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:

  1. ci.ymldeno-version: "2.6.10" (pino exato, igual ao local).
  2. Adicionar --no-lock em deno run/deno test dos scripts de auditoria (não precisam resolver deps externas — leitura pura de FS).
  3. 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:

  1. 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.
  2. Lockfiles versionados são contrato CI ↔ local. Qualquer step que leia o lockfile precisa de versão pinada do runtime que o gerou.
  3. 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.
  4. 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.).

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>.mdBuild failed
  • The language 'env' is not loaded, falling back to 'txt' (warning ruidoso recorrente)
  • Error parsing JavaScript expression: Unexpected token (mustache literal)

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:

  1. Não relaxar ignoreDeadLinks em docs/.vitepress/config.ts — isso esconde o problema e desabilita a validação para casos legítimos futuros.
  2. Corrigir o markdown fonte seguindo o SSOT DOCUMENTATION_MAINTENANCE.md — Docs Build Guardrails.
  3. Validar localmente com bun run docs:build antes 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.