Skip to content

Auditoria — Resultados do Coordenador: "Nível S (3º EM) sumindo"

Data: 2026-06-04 Escopo: @audit feature resultados-coordenadorGatilho: Bug reportado por Ivan — "Ao publicarmos o resultado, percebemos que nada referente ao Nível S (3º Ano EM) está aparecendo, mesmo a planilha sendo upada." Status: ✅ Causa raiz CONFIRMADA (H6 — publicação parcial silenciosa). H1–H5 refutadas com dados de produção. Correção em PR separado.

Este documento é também o primeiro exemplo aplicado do escopo @audit feature formalizado em AUDIT.md §Parte F. Use-o como referência de profundidade esperada para auditorias E2E de features data-heavy.


0. Sumário executivo

Bug declaradoStatusCausa raizRisco
Resultados de Nível S (3º EM) não aparecem no mural após upload da planilha✅ ConfirmadaH7 — Cap silencioso 1000 do PostgREST na tela "Resultados" do coordenador: o backend (handleList e correlatos) faz select direto em inscricoes_olimpiada sem paginação. Para a escola afetada (1.4k inscrições / Canguru), a query trunca em 1000 linhas — alunos dos níveis S e J (ordenados no final) somem da listagem, dos dropdowns de filtro e dos contadores. Sem alunos na tela = coordenação não publica. Reforça H6 (UX de publicação parcial silenciosa), mas a origem real é o cap.🔴 Crítico — perda silenciosa de dados de leitura; padrão arquitetural

Evidência cruzada (escola Centro de Excelência Master 7a8fdea1-…, Canguru 84a8c346-…):

CamadaPEBCJS
importacao_resultados_sessoes (concluída)✅ 73✅ 234✅ 406✅ 218✅ 262224
resultados_aluno73234406218262224
Visível na UI gestao-resultados/list⚠️ truncado em 1000 (badge "1000 Participantes" no header é a smoking gun)
mural_liberacoes (2026-06-04 15:18-15:19)❌ (porque coordenação não viu os alunos S na UI)
mural_dados_publicados732344062182620

Pontos de falha silenciosa (consolidados):

  • PFS-7 (CAUSA RAIZ) — 8 queries em _shared/resultados-queries.ts consultam inscricoes_olimpiada sem paginação (fetchAllRows/range). PostgREST trunca em 1000 sem erro nem header visível ao cliente JS. Fix: helper fetchAllRows em _shared/pagination-helpers.ts + refactor das 8 chamadas. Ver docs/development/LARGE_DATASET_HANDLING.md.
  • PFS-6 — UI de liberação não exibe progresso "X de Y níveis publicados" nem alerta níveis com resultados sem mural_liberacoes. Sequela do PFS-7: coordenação tomou decisão certa para o que enxergou.
  • PFS-4 — Backend get_liberacao_stats sem .order()/.limit() → mesmo risco latente, mas em outra rota.
  • PFS-2pontuacaoMaximaPorNivel[nivelNome] por nome literal (bug latente; sem relação com este incidente).

1. Mapa do pipeline

text
COORDENADOR clica "Inserir resultados"
  └─ selecao-metodo-modal.tsx (planilha | manual)

INSERIR (planilha):
  inserir-step-selecao.tsx  → escolhe olimpiada + fase + nivel
    └─ useOlimpiadasCoordenador.buscarDetalhes(olimpiadaId)
        → retorna { fases, niveisPersonalizados, seriesParticipantes[] }


  inserir-step-upload.tsx   → parse xlsx + columnMapping
    └─ ExcelJS → rawData → safeExtractValue → safeParseNumber

  inserir-dialog.tsx:147-161 → previewResults (linha rejeita se NaN/<0)
  inserir-dialog.tsx:164-186 → elegibilidade {
      validos,
      matriculaNaoEncontrada,         ◄ aluno não existe na escola
      serieIncompativel,              ◄ ★ FILTRO ALVO DA INVESTIGAÇÃO
      naoClassificadoFaseAnterior,
    }

  inserir-step-preview.tsx  → mostra contagens

  CLICA "Confirmar"
    └─ start_import_session  (resultados > 50)  OR  insert_lote  (≤ 50)
        → edge fn gestao-resultados
            → _shared/resultados-queries.handleInsertLote
                → resultados_aluno UPSERT (matricula, fase_id, nivel_id, pontuacao)

LISTAGEM (após salvar):
  resultados-detalhes.tsx
    └─ useResultadosPorOlimpiadaPaginado(olimpiadaId, faseId, {nivelId, ...})
        → action "list"
            → _shared/resultados-queries.handleList
                ├─ busca inscritos via olimpiada_series_participantes ∩ niveis_competicao_series
                │   resultados-queries.ts:440 → alunosDoNivel
                └─ left join resultados_aluno
                    → tabela renderizada

PUBLICAÇÃO NO MURAL (gate confirmado como causa raiz):
  mural-liberacoes.tsx (Coordenador)
    └─ get_liberacao_stats  → painel mostra níveis com dados
    └─ Coordenador clica "Publicar" POR NÍVEL (tabs)
         └─ mural_liberacoes UPSERT (escola, fase, nivel, liberar_resultados)
              └─ recomputeSnapshotsIfPublished (snapshot-publicados.ts:439)
                   └─ computeAndUpsertSnapshot (por liberação existente)
                        → mural_dados_publicados (1 row por aluno × tipo)

  ✦ SE NÃO EXISTE mural_liberacoes(fase, nivel) → snapshot não roda
    → mural_dados_publicados fica vazio para esse nível
    → portal/mural não mostra nada (sem erro, sem aviso)

Pontos de filtragem onde Nível S pode desaparecer (numerados para referência das hipóteses):

#LocalCritérioFalha silenciosa?
F1inserir-dialog.tsx:155pontuacao >= 0 && !NaNconsole.warn
F2inserir-dialog.tsx:178-181série do aluno ∈ seriesParticipantesIdsSim — contador no preview mas não no toast final
F3inserir-dialog.tsx:182classificado na fase anteriorSim, mesmo padrão
F4inserir-dialog.tsx:133-134pontuacaoMaximaPorNivel[nivelNome] por nome literalSim — vira null sem warning
F5_shared/resultados-queries.ts:440alunosDoNivel montado via mapeamento níveis↔sériesSim — tabela vazia
F6RLS resultados_alunofiltro por escola_id + nívelSim — retorna 0 linhas
F7mural-escola/get_liberacao_stats:968.select() sem .order().limit() — default cap 1000 PostgRESTSim — alguns níveis podem sumir do painel em escolas grandes
F8mural-liberacoes.tsx (publicação por tab)publicação nível-a-nível sem indicador global de progressoSim — CAUSA RAIZ confirmada
F9snapshot-publicados.ts:474itera apenas mural_liberacoes existentesSim — nível sem linha = silêncio total no mural

2. Inventário de dados envolvidos

TabelaColuna(s) relevante(s)Papel no fluxo
olimpiadasid, tipo_olimpiada, modo_config_fases (global|por_nivel)Define se config de premiação/série é única ou por nível
olimpiada_edicoesid, olimpiada_id, ano_edicaoEdição corrente (filtra olimpiada_series_participantes)
olimpiada_series_participantesolimpiada_id, serie_idF2 — fonte direta de seriesParticipantesIds
niveis_competicaoid, nome, olimpiada_id"Nível S" tem que ter row aqui
niveis_competicao_seriesnivel_id, serie_idF5 — mapeia Nível S → séries (3º EM)
series_escolaresid, label, ordem"3º Ano EM" precisa existir e estar ativo
fases_olimpiadaid, pontuacao_maxima_por_nivel (jsonb)F4 — JSON keyed pelo nome do nível
alunosid, matricula, turma_id, escola_idLookup por matrícula
turmasid, serie_id, identificadorLiga aluno → série
resultados_alunoaluno_id, fase_id, nivel_id, pontuacao, situacaoTabela final, alvo do INSERT
inscricoes_olimpiadaaluno_id, olimpiada_id, ano_edicao, statusBackend filtra inscritos quando lista

3. Matriz de cenários (coordenador inserindo resultados)

#CenárioComportamento esperadoOnde quebra hoje
C1Aluno do 3º EM, série 3º EM em olimpiada_series_participantes, Nível S existe, mapeamento niveis_competicao_series(NS, 3ºEM) presenteInsere normalmenteOK
C2Mesma config, mas olimpiada_series_participantes não contém 3º EMCoordenador deveria ver "X alunos em série incompatível" em destaque + bloqueioToast final mascara — bug 3
C3Mapeamento niveis_competicao_series vazio para Nível SBackend lista alunosDoNivel = ∅ → tabela vaziaF5 — sem warning
C4Aluno do 3º EM com turma_id = NULL ou turmas.serie_id = NULLCai em serieIncompativelF2 silencioso
C5Nível "Nível S" no DB, planilha esperando "S" no dropdown UIMatch por id no UI funciona; nome só importa para F4OK exceto F4
C6pontuacao_maxima_por_nivel = {"S": 120} mas niveis_competicao.nome = "Nível S"F4 retorna null, sem limite máximo aplicadoBug latente
C7Modo por_nivel com fase intermediária premiando Nível SStats dependem de §B3 do audit anteriorJá mapeado em AUDIT_RESULTADOS_DASHBOARD_2026-06-04.md
C8Fase anterior sem nenhum classificado de Nível STodos vão para naoClassificadoFaseAnteriorF3 silencioso
C9Matrícula da planilha com espaços/zero à esquerda divergente do bancomatriculaNaoEncontradaF1 reportado, mas pode confundir com "série incompatível"
C10OMU (mapeamento omu-levels-series-mapping) — Nível 3 = 1º+2º+3º EMMesmo aluno 3º EM deveria entrar; depende de niveis_competicao_series corretoMesma falha potencial F5
C11Canguru por nível — Nível S premiado em fase ≠ últimaCards de medalha somem (B2 do audit anterior)Já tratado
C12Edição nova (ano_edicao=2026) sem rodar setup de séries para Nível SC3 + C2 simultâneosHipótese H1 deste audit
C13Planilha com Nível S misturado a outros níveis e coordenador escolheu nível ≠ SLinhas de outros níveis viram serieIncompativel (turmas fora do recorte do nível ativo)F2 ambíguo

4. Pontos de falha silenciosa

IDDescriçãoSeveridade
PFS-1serieIncompativel contado no preview, mas o toast de sucesso após insert_lote reporta Importação concluída! N resultado(s) — sem informar quantos foram descartados🔴
PFS-2pontuacaoMaximaPorNivel[nivelNome] fallback null sem warning visível🟡
PFS-3seriesParticipantesIds.length === 0 → o filtro F2 é pulado (passa tudo). Mascara o bug oposto: olimpíadas mal-configuradas parecem funcionar🔴
PFS-4Backend handleList retorna lista vazia para nível sem mapeamento sem distinguir "sem alunos elegíveis" de "config faltando"🟡
PFS-5niveis_competicao_series sem trigger de integridade — uma edição pode existir sem nenhuma série mapeada para um nível ativo🟡
PFS-6(CAUSA RAIZ) UI mural-liberacoes.tsx aceita publicação parcial silenciosamente: sem progresso "X de Y níveis publicados", sem badge "pendente" em níveis com resultados mas sem mural_liberacoes, sem aviso ao sair. Resultado: o coordenador não percebe que esqueceu um nível e o portal fica vazio para aquele recorte🔴
PFS-7mural-escola/get_liberacao_stats faz .select() sem .order()/.limit() — cap default 1000 do PostgREST pode esconder níveis em escolas com >1000 resultados_aluno na olimpíada (escola atual: 1417 rows; risco latente)🟡

5. Cobertura de testes — atual vs necessária

Hoje:

  • src/lib/__tests__/resultados-preview.test.ts — só helpers puros (safeParseNumber, detectColumn).
  • src/hooks/__tests__/useGestaoResultados.integration.test.ts — invalidação de cache.
  • e2e/resultados-premiacao-por-nivel.spec.ts — fluxo de premiação, não cobre insert por nível.
  • Nenhum teste cobre: F2 (série incompatível), F4 (pontuacaoMaxima por nome), F5 (alunosDoNivel vazio).

Necessário (entra no TESTING_BACKLOG.md — Foco Notável):

  1. RTL inserir-dialog.test.tsx — matriz C1-C13.
  2. Deno gestao-resultados/__tests__/insert-lote-nivel-s.test.tsniveis_competicao_series vazio retorna 0 inseridos com mensagem clara.
  3. Deno _shared/__tests__/resultados-queries-alunos-do-nivel.test.ts — F5 isolado.
  4. E2E coordenador — fluxo completo Nível S Canguru.
  5. Contract — olimpiada_series_participantesniveis_competicao_series consistência por edição.

6. Regras de negócio aplicáveis

  • mem://features/results/comprehensive-architecture-standard — arquitetura geral de resultados.
  • mem://features/results/dashboard-cards-by-fase-config — gating de cards (B7 cleanup).
  • mem://business/olympics-operational-integrity — integridade operacional.
  • mem://business/omu-levels-series-mapping — OMU usa Nível 3 = 1ºEM+2ºEM+3ºEM (cenário C10).
  • mem://business/pedagogical-series-sorting-standard — ordem canônica das séries.
  • mem://architecture/olympics-phases-by-level-standard — modo por_nivel.
  • mem://security/results-eligibility-and-mural-visibility-standard — elegibilidade backend.
  • mem://debugging/payload-trust-check — conferir payload no Network ANTES da UI (vale aqui para buscarDetalhesseriesParticipantes).

7. Checklist de validação em produção

Executar na ordem contra a escola que reportou o bug (substituir :escola_id, :olimpiada_id, :edicao_ano):

sql
-- 7.1 — A série "3º EM" existe e está ativa?
SELECT id, label, ordem
FROM series_escolares
WHERE label ILIKE '%3%EM%' OR label ILIKE '%3º%médio%' OR label ILIKE '%terceiro%médio%'
ORDER BY ordem;

-- 7.2 — O Nível S está cadastrado para a olimpíada?
SELECT id, nome, olimpiada_id
FROM niveis_competicao
WHERE olimpiada_id = :olimpiada_id
ORDER BY nome;
-- Esperado: 1 row com nome contendo "S" (validar acentuação/case)

-- 7.3 — F5: Nível S está mapeado para a série 3º EM?
SELECT ncs.nivel_id, nc.nome AS nivel_nome, ncs.serie_id, se.label AS serie_label
FROM niveis_competicao_series ncs
JOIN niveis_competicao nc ON nc.id = ncs.nivel_id
JOIN series_escolares se ON se.id = ncs.serie_id
WHERE nc.olimpiada_id = :olimpiada_id
ORDER BY nc.nome, se.ordem;
-- Esperado: pelo menos 1 row (Nível S, 3º EM). Ausência = causa raiz H1/H3.

-- 7.4 — F2: A edição da olimpíada inclui 3º EM em olimpiada_series_participantes?
SELECT osp.olimpiada_id, osp.serie_id, se.label
FROM olimpiada_series_participantes osp
JOIN series_escolares se ON se.id = osp.serie_id
WHERE osp.olimpiada_id = :olimpiada_id
ORDER BY se.ordem;
-- Esperado: row para série 3º EM. Ausência = causa raiz H1.

-- 7.5 — F4: pontuacao_maxima_por_nivel tem chave compatível com nome do nível?
SELECT id, nome AS fase, pontuacao_maxima_por_nivel
FROM fases_olimpiada
WHERE olimpiada_id = :olimpiada_id
ORDER BY ordem;
-- Inspecionar visualmente: chaves do JSON devem === niveis_competicao.nome

-- 7.6 — Alguma matrícula da escola tem aluno do 3º EM?
SELECT COUNT(*) AS total_alunos_3em
FROM alunos a
JOIN turmas t ON t.id = a.turma_id
JOIN series_escolares se ON se.id = t.serie_id
WHERE a.escola_id = :escola_id
  AND (se.label ILIKE '%3%EM%' OR se.label ILIKE '%terceiro%médio%');

-- 7.7 — Algum resultado de Nível S **chegou** ao banco?
SELECT COUNT(*) AS total_resultados_nivel_s,
       COUNT(DISTINCT aluno_id) AS alunos_distintos
FROM resultados_aluno ra
JOIN niveis_competicao nc ON nc.id = ra.nivel_id
JOIN alunos a ON a.id = ra.aluno_id
WHERE a.escola_id = :escola_id
  AND nc.olimpiada_id = :olimpiada_id
  AND nc.nome ILIKE '%S%';
-- Se > 0: bug é só de LISTAGEM (F5/F6). Se = 0: bug é de INSERT (F2/F3/F4).

Resultado dos 7 pontos isola hipótese em §8.

7.8 — Resultados executados em produção (2026-06-04, escola 7a8fdea1-…, Canguru 84a8c346-…)

QueryResultado
7.1 série 3º EMserie_id=3_ano_em, ordem 12
7.2 Nível S cadastradoid=9344ed00-…, titulo "Nível S"
7.3 mapeamento niveis_competicao_series(S, 3º EM)✅ presente
7.4 olimpiada_series_participantes(3º EM)✅ presente
7.5 fase.pontuacao_maxima_por_nivel"Nível S": 150 (case correto)
7.6 alunos 3º EM na escola✅ > 200
7.7 resultados Nível S no banco224 rows, status concluida
+ mural_liberacoes (Canguru/escola)❌ 5 rows (P/E/B/C/J), 0 para Nível Scausa raiz
+ mural_dados_publicados (Canguru/escola)5 níveis presentes, 0 para Nível S (consequência)

→ Falha está entre resultados_aluno (✅ 224) e mural_liberacoes (❌ ausente). Hipótese H6 (publicação parcial silenciosa) confirmada; H1–H5 refutadas.


8. Hipóteses ranqueadas

#HipóteseStatus finalEvidência
H1olimpiada_series_participantes da edição não inclui 3º EM❌ Refutada7.4 retornou 3_ano_em presente
H2niveis_competicao_series(Nível S, 3º EM) ausente❌ Refutada7.3 retornou mapeamento presente
H3Aluno do 3º EM com turma_id/serie_id nula❌ Refutada224 inscrições foram criadas com nivel_id=Nível S a partir de matrículas válidas
H4pontuacaoMaximaPorNivel por nome literal divergente❌ Refutada (na origem)7.5 mostrou "Nível S": 150 correto. Mantém-se como bug latente PFS-2.
H5RLS de resultados_aluno filtrando out❌ RefutadaSnapshot usa service_role (bypassa RLS)
H6Publicação parcial silenciosa — coordenador publica nível-a-nível e esquece o S; sem mural_liberacoes o snapshot nunca roda → mural_dados_publicados vazioCONFIRMADATimestamps das 5 liberações (15:18-15:19 em 51 s, P→E→B→C→J) + ausência total de row para Nível S em mural_liberacoes + 0 rows em mural_dados_publicados para Nível S, com 224 rows em resultados_aluno

9. Plano de correção (PR separado — fora do escopo desta auditoria)

9.1 Backend (mural-escola)

  1. get_liberacao_stats (mural-escola/index.ts:968): adicionar .order('id').limit(50000) ou paginar via queryInChunks. Hoje cai no cap default 1000 do PostgREST com 1417 rows na escola auditada.
  2. Novo action get_niveis_pendentes_publicacao: retorna { fase_id, nivel_id, titulo, resultados_count, publicado: boolean } cruzando resultados_aluno × mural_liberacoes — alimenta o indicador na UI.

9.2 Frontend (mural-liberacoes.tsx)

  1. Header do card de cada olimpíada: badge "X de Y níveis publicados" calculado de niveis × liberações existentes.
  2. Botão de cada nível: ícone amarelo "pendente" quando há resultados sem mural_liberacoes.
  3. Modal de confirmação ao sair da tela com publicações incompletas.
  4. Botão opcional "Publicar todos os níveis com resultados" (com confirmação) — atalho para evitar o erro humano.

9.3 Backfill da escola afetada

Script idempotente que cria mural_liberacoes(liberar_resultados=true) para Nível S da escola Centro de Excelência Master + Canguru com config_resultados idêntico aos outros níveis, e dispara recomputeSnapshotsIfPublished — preenche mural_dados_publicados com os 224 registros.

9.4 Testes

Catalogados em TESTING_BACKLOG.md — "Foco notável — Publicação parcial silenciosa".


10. Memórias criadas/atualizadas

  • ✅ Criada mem://features/mural-olimpico/publicacao-parcial-silenciosa (regra UX para qualquer tela de publicação por nível).
  • ✅ Atualizada mem://features/results/comprehensive-architecture-standard (gate mural_liberacoesmural_dados_publicados).
  • Atualizar mem://debugging/payload-trust-check com o caso mural_dados_publicados (em backlog do próximo PR).


12. H8 — Truncamento no recompute (descoberto 2026-06-04 pós-fix UI)

12.1 Sintoma

Após corrigir o cap de 1000 linhas nas queries de leitura (resultados-queries.ts), os alunos do Nível S/J voltaram a aparecer na UI, mas todos os 224 resultados do Nível S permaneceram com situacao = 'aguardando', mesmo após a coordenadora re-salvar as faixas de pontuação. 193 dos 262 do Nível J idem.

12.2 Causa raiz validada

supabase/functions/_shared/resultados-compute.tsrecomputeFaseForOlimpiada carregava as inscrições com .select("id, nivel_id").eq("escola_id", …).eq("olimpiada_id", …) sem paginação. Centro de Excelência Master tem 1417 inscrições no Canguru → PostgREST truncou em 1000 → 417 inscrições (incluindo todas as 224 do Nível S) ficaram fora de inscricaoToNivel e inscricaoIds. Seus resultados_aluno nunca eram lidos nem atualizados, ficando congelados em aguardando desde o import.

12.3 Evidência DB (timestamp prova o pulo)

NívelTotalAguardandoMAX(atualizado_em)
P / E / B / C73–406016:57:49–53 (recomputado)
J26219316:57:53 (parcial)
S22422415:28:52 (timestamp do import — nunca recomputado)

Resultado com pontuação 116.00 / 107.50 (faixa de ouro 107.5–150) marcado como aguardando — incompatível com config salva às 18:52:33.

12.4 Por que a refatoração anterior não cobriu

PR de 2026-06-04 aplicou fetchAllRows apenas em resultados-queries.ts (caminho de leitura para a UI). O caminho de escrita/recompute em resultados-compute.ts (arquivo irmão) seguiu com .select() direto. Mesmo bug, módulo diferente.

12.5 Fix aplicado

  • resultados-compute.ts: troca para fetchAllRows com label recomputeFaseForOlimpiada.inscricoes, comentário com referência a este incidente.
  • _shared/__tests__/recompute-pagination.test.ts: regressão drenando 1417 inscrições (medalha_ouro em 100%).
  • mem://index.md Core: cláusula reforçada — vale para leitura E recompute/jobs.

12.6 Backfill

Coordenadora deve re-salvar as faixas do Nível S (ou clicar em "recompute") após o deploy. A próxima invocação processará todas as 1417 inscrições e aplicará as faixas corretas.

13. H9 — Contraste invisível de Sem Premiação / Aguardando no portal do aluno

13.1 Sintoma

Após corrigir H7/H8 (cap silencioso de 1000 rows em leitura e recompute), a coordenadora reportou que o aluno do EF I (Nível P, Canguru) "não vê nada na premiação" — a tela aparece com o card "DETALHES" mostrando label SUA SITUAÇÃO mas sem valor visível ao lado.

13.2 Investigação (NÃO é regressão de H7/H8)

Aluno é EF I, Nível P → dentro do cap de 1000, recompute rodou:

  • pontuacao = 77.5/120 ✅ renderiza
  • "A maior pontuação do nível foi 110/120" ✅ renderiza
  • situacao = sem_premiacao ✅ gravado em banco

DOM inspecionado: o <span> da situação está presente e contém o texto Sem Premiação. O texto está invisível por contraste, não ausente.

13.3 Causa raiz

src/components/mural-olimpico/medal-svg.tsx definia:

ts
sem_premiacao: { color: 'text-white/60' }
aguardando:    { color: 'text-white/50' }
// fallback do getSituacaoLabel: 'text-white/60'

Cores criadas originalmente para o banner escuro do topo, mas reusadas pelo fase-content.tsx (linha 101) para montar a linha "SUA SITUAÇÃO" dentro do card DETALHES (fundo claro). Branco com 50–60% de opacidade sobre fundo claro → fantasma.

H7/H8 não regrediu — o bug visual era pré-existente e ficou exposto agora que sem_premiacao é populado em volume (antes da H8, a maioria estava aguardando e o usuário nem chegava no estado de saída).

13.4 Fix aplicado

  • medal-svg.tsx: paleta refeita para superfície clara. sem_premiacao e aguardandotext-muted-foreground. Medalhas/classificado/nao_classificado → tons 600 (têm contraste OK em card claro). Fallback do getSituacaoLabel idem.
  • __tests__/medal-svg.test.ts: regressão estática que falha se qualquer cor voltar a usar text-white/*.

13.5 Lição

Sintoma "não aparece nada" tem dois caminhos possíveis: dado ausente OU dado invisível. Inspecionar o DOM ANTES de culpar pipeline de dados — vale a regra do mem://ui/visual-diagnosis-protocol. Tokens de cor reusados entre superfícies (banner escuro vs. card claro) são fonte recorrente de invisibilidade.