26 KiB
Impl: Relatório de cidade pré-viagem (agente + skill → PDF A4)
Status: executado Atualizado em: 2026-09-15 Issue: #1007 Intenção: docs/plans/relatorio-cidade-viagem.md Appetite restante: herdado — ~2–3 dias eng. Cortes explícitos se estourar: (a) emendas oficiais viram lacuna explícita; (b) página 1 fecha nos caps do E16 (8 lideranças / 5 sinais / 3+2 visitas); (c) skill-first: o subagente pode shipar como wrapper fino depois, sem bloquear o script→PDF.
Leitura da intenção
- Outcome: chamar o agente/skill com um município e sair um PDF A4 datado (+ companion
.mdcom números e proveniência), lendo a base de produção read-only no estado do momento; página 1 = resumo de uma olhada (conta eleitoral 2022, quem é quem, o que foi entregue, anunciar × não anunciar, riscos); páginas 2+ = aprofundamento com fonte por item e lacuna explícita onde não há dado. - O que NÃO negociar:
- "Sem fonte, não publica" — afirmação não trivial sem origem (base Teqo com data de leitura; web/fonte oficial com data + URL) não entra; ausência vira lacuna explícita, nunca inferência.
- Leitura relativa (% do próprio voto, rank, LQ/captura) — nunca % estadual absoluto.
- Empenho ≠ pagamento; bloco "anunciar × NÃO anunciar" com defeso/ano eleitoral.
- PII mínima: contatos completos (telefone/e-mail) fora; conteúdo completo para o candidato dentro do PDF (estimativas e nível N0–N4 entram, sem marca de restrição).
- Emendas lidas da fonte oficial em tempo de geração, sem persistir; nenhuma collection/migration/Consent novo; nenhum
db:pull/snapshot de banco.
- O que reavaliar (hipóteses da intenção que a execução deve confrontar):
- Visibilidade da entrega. A intenção resolveu "PDF +
.mdcommitados emdocs/research/"; o repositório é público (gh repo view:visibility: PUBLIC) e o relatório carrega dados internos de campanha (nomes de lideranças, pledges, estimativas staff-only, conjuntura). A entrega mantém o caminhodocs/research/relatorios-cidade/, mas gitignored (decisão D6); publicar exigiria um renderer sanitizado, fora do appetite. - "Notícias internas" não existe como entidade no codebase:
Postnão tem relação com município; o que existe émunicipalityUpdate(sinais). A seção vira "Sinais internos + imprensa local (web)". - Os caps do E16 (
DOSSIER_LEADERSHIP_LIMIT = 8,DOSSIER_SIGNAL_LIMIT = 5, 3+2 atividades,municipalityDossierData.ts:38-43) são o corte natural contra o rabbit hole "relatório de 40 páginas" — o relatório usa os caps e mostratotalCount. - A fonte oficial de emendas é decisão de implementação (ver D5): Portal da Transparência
/api-de-dados/emendas, com lacuna explícita como fallback. - O acervo de falas não tem loader "falas do município"; a query é do extrator, compondo o dono
buildSpeechListWhere.
- Visibilidade da entrega. A intenção resolveu "PDF +
Abordagem recomendada
flowchart LR
A[pedido: relatorio de um municipio] --> B[skill relatorio-cidade: checklist + contratos]
B --> C[ssh homeserver: extract-city-report-snapshot.mjs]
C -->|Payload Local API, read-only PGOPTIONS, ator role candidate| D[data/relatorios-cidade/slug-data.snapshot.json]
B --> E[pesquisa web do agente: checklist com URL + data]
E --> F[data/relatorios-cidade/slug-data.research.json]
D --> G[build-city-report.mjs local]
F --> G
G -->|fetch Portal da Transparencia, sem persistir| G
G --> H[docs/research/relatorios-cidade/slug-data.pdf + .md]
G --> I[guarda: pagina 1 em 1 pagina; sem fonte nao publica]
Opções consideradas: A (recomendada) extração no homeserver read-only + snapshot JSON + render local; B túnel SSH (ssh -L 5433:127.0.0.1:5433) e roda tudo na workstation; C roda tudo no homeserver, inclusive Chromium; D rota /campanha com print CSS (como no E16).
Recomendação: A — a extração roda onde a produção vive (mesma receita socat 127.0.0.1:5433 do runbook), o processo não carrega credencial de prod na workstation e o render fica no único lugar que já tem Chromium (precedente C157). O snapshot JSON é um intermediário explícito: revisável, re-renderizável sem tocar produção de novo e a prova de proveniência (data de leitura + SHA do código).
Rejeitadas:
- B porque a credencial de produção passa a viver no processo local e o guard local (
assertLocalDatabase) não distingue127.0.0.1do proxy socat de um Postgres Docker — o passe seria silencioso; além de manter uma lane viva para produção na workstation. - C porque instalar Chromium no homeserver polui o host de deploy e acopla o render ao servidor.
- D porque exige sessão autenticada e imprime a casca do app; já rejeitada em E16/C157 (precedente
docs/plans/relatorio-sobreposicao-solla-ceuci-salvador-impl.md).
Componentes / mudanças
scripts/extract-city-report-snapshot.mjs(novo): CLI--municipality=<slug> --out=<json>; setaPGOPTIONS='-c default_transaction_read_only=on -c statement_timeout=30000'antes de importarsrc/payload.config.ts(opglêPGOPTIONSemConnectionParameters—node_modules/pg/lib/connection-parameters.js; nenhuma mudança no config do app); exigeCITY_REPORT_CONFIRM=1sempre (opt-in explícito, padrão C155); imprime host/DB sem credenciais (reusadatabaseHostname/loadCliEnv/dieWithLabeldescripts/lib/cli.mjs); resolve o ator e chama o composer; grava o snapshot; nunca escreve no banco (qualquer write estoura no servidor).scripts/cityReportSnapshot.mjs(novo, camada de integração testada por int; mora ao lado do extrator, fora descripts/lib/puro):composeCityReportSnapshot({ payload, actor, slug })compondo os donos, sem duplicar:resolveAccessibleMunicipalityContext(src/utilities/municipality/municipalityPageData.ts:678) egetMunicipalityDetailViewModel(municipalityPageData.ts:700) →strategy(priority, expectedVotes, politicalTrend, engagementLevel N0–N4, strengths/risks, dobradinhas, budgetNotes, stateDeputies).loadMunicipalityDossierData(src/utilities/municipality/municipalityDossierData.ts:99) → baseline 2014/2018/2022 + tally 2022 + ticket, goalAccount (suggestedGoal, goalCoverage, territorialClass, territoryCaptureBenchmark), pledgeAggregate, lideranças (cap 8 + total), sinais (cap 5 + total, deliberação), visitas (3+2), demografia (demographicsForCode).getMunicipalityVoteRank(src/lib/municipalityVoteRank.ts:66) → rank/435 + share do próprio voto.- Falas:
buildSpeechListWhere({ page: 1, municipalities: [id] })(src/utilities/speech/speechListFilters.ts:42) numa query própria com select mínimo (speechAt,year,phase,officialTextUrl,summary,mentionedMunicipalities) +loadSegmentsForSpeeches(src/utilities/speech/speechPageData.ts:110) +municipalityIdsOfSpeech/buildWatchHref(src/utilities/speech/speechViewModels.ts). - Demandas: query própria
campaignDemandpormunicipality(não existe loader com esse filtro —buildDemandListWhere,src/utilities/demand/demandListUrl.ts:40, só cobre status/kind/q/activity; não vale criar dono para 1 call site). - Projeção anti-PII: descarta
phone/email/phonesdoLeadershipRowViewModel, contatos completos ecost/receiptsde demandas; nomes entram porque o produto os pede (página 1 "quem é quem"), contatos nunca.
scripts/build-city-report.mjs(novo): CLI--snapshot=<json> --research=<json> [--emendas=<json>] --out-dir=docs/research/relatorios-cidade; sem--emendasfaz o fetch oficial (D5) e cacheia o resultado emdata/relatorios-cidade/<slug>-<data>.emendas.json(replay sem rede); monta blocos → HTML (print CSS próprio) →page.pdf({ format: 'A4', printBackground: true })com header/footer paginado (mecânica do C157,scripts/build-solla-ceuci-salvador-report.mjs:1117-1136) → companion.mddos mesmos blocos; guarda de página 1 (medescrollHeight > clientHeightdo bloco de resumo e aborta se estourar); escreve HTML intermediário emdata/relatorios-cidade/(gitignored).scripts/lib/cityReportBlocks.mjs(novo, puro):buildReportBlocks({ snapshot, research, emendas })→ árvore de blocos (paragraphs/bullets/kpis/table/callout/gap) na ordem do contrato de conteúdo; aplica o checklist de pesquisa e converte item semsourceUrl/sourceDateem lacuna ("sem fonte, não publica"); sintetiza lacuna para checklist ausente; tetos por seção.scripts/lib/cityReportRender.mjs(novo, puro):renderReportHtml(blocks, meta, css)erenderReportMd(blocks, meta);PRINT_CSS(port do rascunho A4: A4 794×1123, micro-labels, acento âmbar para decisão/lacuna, Fira Sans + DejaVu fallback — sem Tailwind CDN);htmlEscape.scripts/lib/cityReportResearch.mjs(novo, puro): valida/normaliza o JSON de pesquisa contraRESEARCH_CHECKLISTfixo (prefeito/vice, federação/relação, vereadores/dobradas, disputa local, quem investe, notícias ≤90 dias, imprensa local), exige URL+data por item, normaliza gaps.scripts/lib/portalTransparenciaEmendas.mjs(novo, puro + fetch injetável): normaliza o payload de/api-de-dados/emendas(valores empenhado/liquidado/pago/resto pago, ano, tipo,codigoAutor) e aplica o filtro de autor exato em memória (D5); retorna{ status: 'ok', rows, sourceUrl, consultedAt }ou{ status: 'gap', reason, sourceUrl, consultedAt }.- Testes:
tests/unit/cityReportBlocks.unit.spec.ts,tests/unit/cityReportRender.unit.spec.ts,tests/unit/cityReportResearch.unit.spec.ts,tests/unit/portalTransparenciaEmendas.unit.spec.ts;tests/int/cityReportSnapshot.int.spec.ts(padrão detests/int/municipalityDossierData.int.spec.ts+installCampaignFixtures; mockamunicipalityElectoralBaselinecomo o teste do dossiê já faz) provando caps/totais/proveniência/PII fora. - Skill/agente:
.agents/skills/relatorio-cidade/SKILL.md(frontmattername/description; checklist de pesquisa, shape dos dois JSONs, comandos exatos de extração/SSH/proxy/confirm,PORTAL_TRANSPARENCIA_API_KEYem.env.local, guardrails literais, troubleshooting) e.opencode/agent/relatorio-cidade.md(mode: subagent, fino, apontando para a skill — semopencode.json, sem permissão nova). - Docs:
.gitignore(/data/relatorios-cidade/,/docs/research/relatorios-cidade/); subseção "Relatório de cidade (read-only)" emdocs/ops/teqo-1313-deploy.mdcom a receita do proxy;docs/changelog/2026-09-15-c163.md. - Sem alias em
package.json(precedente C157): invocação pornode, documentada na skill;package.json= high-risk noci-scopee forçaria suíte full/build por um relatório. - Migration: sem migration (a intenção veta; emendas não persistem).
- Access / Consent: nenhuma chave/Consent novo; todas as leituras do relatório usam
overrideAccess:falsecom um ator real —campaignUserrolecandidate(fallbackcoordinator), escolhido por query e fail-closed se não existir; a leitura da linha do ator usaoverrideAccess:true(leitura de serviço, precedente dos scripts). É o papel que legitimamente vê tudo (estimativas, N0–N4, acervo) — sem bypass novo nos loaders. - UI: Impeccable B já aprovado no gate (
docs/plans/relatorio-cidade-viagem-a4-draft.html). Nada de tela de app; o "shell" é o próprio rascunho A4, que vira CSS print (shape→craft→critique→polish: shape fechado no draft; craft = port do CSS; critique = página 1 em 1 página + legibilidade em impressão P&B; polish = acentos de decisão/lacuna). O print CSS do app ((campaign)/.../layout.tsx:92-100) não é reusado — caminho de render é outro (Playwright).
Dados → forma
- Forma escolhida: resumo de uma olhada em blocos compactos (KPI numérico + listas + barras simples de cobertura) com a leitura relativa (votos 2022, % do próprio voto, rank/435, meta/cobertura); séries 2014/2018/2022 em tabela; fonte em nota por seção; lacuna em callout âmbar; "anunciar × não anunciar" em dois cartões com o defeso explícito.
- Rejeitadas: % estadual absoluto (kernel do produto: leitura relativa); mapas/choropleth e gráficos ricos por cidade (fora de escopo declarado); voto por bairro/zona (não existe granularidade; o relatório não inventa); dossiê longo (anti-goal; caps do E16 e página 1 fechada).
Contrato de dados e reúso (loaders, shapes, puro testável)
- Snapshot (
data/relatorios-cidade/<slug>-<data>.snapshot.json, gitignored):{ "meta": { "readAt": "ISO", "codeSha": "…", "database": "host:porta/teqo_1313", "readOnly": true, "actorRole": "candidate", "actorId": 0, "source": "base Teqo (produção, leitura read-only)" }, "municipality": { "id", "slug", "name", "kind", "region", "ibgeCode", "tseCityCode", "tseZones" }, "electoral": { "series": [], "tally2022": {}, "ticket2022": {}, "rank": {}, "goal": {} }, "leaderships": { "rows": [], "totalCount": 0, "withoutOwnerCount": 0 }, "pledges": {}, "signals": { "rows": [], "totalCount": 0 }, "activities": { "upcoming": [], "recent": [] }, "conjuncture": { "priority", "politicalTrend", "engagementLevel", "strengths", "risks", "dobradinhas", "budgetNotes", "stateDeputies", "expectedVotes" }, "speeches": { "rows": [], "totalCount": 0 }, "demands": { "rows": [], "totalCount": 0 }, "demographics": {} } - Pesquisa (
…research.json, escrita pelo agente):{ municipalitySlug, researchedAt, items: [{ id, answer, details, sourceUrl, sourceDate, consultedAt }], news: [{ title, outlet, publishedAt, url, summary }], gaps: [{ id, reason }] }— cadaiddo checklist ou item com fonte, ou gap. - Emendas (resultado do fetch, cacheado):
{ status, rows: [{ year, type, empenhado, liquidado, pago, restoPago, authorCode }], sourceUrl, consultedAt }ou{ status: 'gap', reason, sourceUrl, consultedAt }. - Blocos = modelo único de conteúdo; HTML e MD saem do mesmo
blocks(o MD substitui visual por "(disponível no PDF)" e lista todas as fontes — padrão C157). - Puro/unit-testável:
buildReportBlocks, os dois renderers, validação do checklist, normalização de emendas, formatters. Integração (int):composeCityReportSnapshot(fixtures + mock de baseline). Não testável por unit: o CLI (testado ponta a ponta no tracer com banco local).
Fases verificáveis
- Tracer bullet (0,5–1 dia) — menor fatia ponta-a-ponta:
scripts/extract-city-report-snapshot.mjs+scripts/cityReportSnapshot.mjsmínimos (município + electoral + lideranças + meta) escripts/build-city-report.mjsrenderizando página 1 já com o CSS print do draft. Prova: na worktree,pnpm db:seed:minimal+CITY_REPORT_CONFIRM=1 … node scripts/extract-city-report-snapshot.mjs --municipality=<slug do seed> --out=data/relatorios-cidade/prova.snapshot.json→node scripts/build-city-report.mjs --snapshot=… --research=<mínimo> --out-dir=docs/research/relatorios-cidade→ PDF A4 datado abrível, com lacunas onde a base não tem (baseline TSE local pode não existir; é lacuna, não falha). - Conteúdo completo e guardrails (≈1 dia) — página 1 fechando em 1 página (guarda de overflow), seções 2+ (conta eleitoral completa, falas, demandas, demografia, TI, fontes e limites), checklist de pesquisa + validação, fetch de emendas com fallback, companion
.mddos mesmos blocos, unit tests. - Skill + agente + ops (≈0,5 dia) —
SKILL.md, subagente, subseção no runbook,.gitignore, int test, changelog. - Gates (≈0,5 dia) —
pnpm format,pnpm gate:fast(lint+typecheck+test:unit), int focado (pnpm test:int -- cityReport),pnpm gate:push(knip/cycles), push viapnpm push; PR comCloses #1007. E2E não é tocado (semsrc/); CI seleciona o que o classifier mandar.
Rabbit holes / Não escopo (engenharia)
- Não criar collection/migration/loader top-level em
src/utilities/(pin emtests/unit/codebaseConventions.unit.spec.ts:389); o script importa os donos existentes. - Não usar Tailwind CDN no template (render offline) nem instalar Chromium no homeserver (D1).
- Não persistir emendas/pesquisa/snapshot no banco; não fazer
db:pull/snapshot de banco (a intenção veta). - Não criar alias
pnpmpara os scripts (arrastaria suíte full/build; precedente C157). - Não sanitizar/publicar o relatório no repo público nesta entrega;
--publish-sanitizedfica como gatilho de revisitação (D6). - Não raspar vereadores/dobradas um a um nem perseguir toda a internet: checklist fixo, janela ≤90 dias, lacuna explícita.
- Não construir segundo cadastro de prefeito/vereador: isso vive na pesquisa web datada.
- Não mexer nos guards existentes (
assertLocalDatabase,assertTestDatabase,guard-dev-db).
Riscos e mitigação
- Checkout do homeserver em deploy concorrente (
~/teqo-deploy): o comando da skill fazgit fetch/checkoutdocumentado; se houver corrida, usar checkout de scratch (~/teqo-report) — só leitura, sem tocar o deploy. tsxausente no homeserver (install comNODE_ENV=production): mitigação na skill —pnpm install --prod=falsequandonode_modules/tsxnão existir; o guard de intent (CITY_REPORT_CONFIRM=1) e o banner deixam claro o alvo.- Credencial/túnel: eliminados por D1 (extração remota); a leitura é read-only por sessão (
PGOPTIONS) — fail-closed mesmo se um loader tentasse escrever. - Emendas (API oficial): chave ausente, rate limit (30 req/min — 4 anos = 4 requests), homônimos de autor. Mitigação: filtro de autor exato em memória; >1
codigoAutordistinto para o mesmo nome → lacuna; ausência/erro → lacuna com URL + motivo, nunca zero silencioso. - Alucinação da pesquisa web: o script rejeita item sem fonte e exige data; a revisão humana (CG) segue sendo o filtro final; o documento é datado e mostra o que não foi achado.
- Página 1 estourando: guarda de medição aborta o build (o corte é copy/caps, não layout quebrado).
- Repo público (LGPD/estratégia): entrega na intenção (PDF+MD) com diretório gitignored; contatos nunca entram; snapshot/pesquisa também gitignored.
- Fonte/caimento visual: Fira Sans pode não estar instalada (fallback DejaVu, como no C157); nenhum webfont remoto no PDF.
Aceite de engenharia
- Aceite de produto da intenção ainda coberto: comando com uma cidade → PDF A4 datado +
.md; página 1 responde o job; seções 2+; guardrails de fonte/lacuna/defeso/PII; estimativas e N0–N4 completos para o candidato. - Invariantes AGENTS/engineering-standards: sem collection/migration/Consent; leitura de produção read-only (fail-closed);
overrideAccess:falsecom ator real nos loaders; nada de PII completa; nada commitado do artefato interno;push:falseintocado; sem alias empackage.json. - Testes de domínio: unit da lib pura (blocos/render/pesquisa/emendas) + int do composer (caps, totais, proveniência, PII fora) onde as regras de acesso/leitura importam.
- Entrega: PDF+MD em
docs/research/relatorios-cidade/(gitignored), HTML/JSONs emdata/relatorios-cidade/(gitignored), skill+subagente, subseção no runbook, changelog2026-09-15-c163.md. - CI verde com PR
Closes #1007;pnpm push(gate) executado.
Decisões de engenharia
-
Como o gerador lê a produção. Opções: A extração no homeserver (Payload Local API +
PGOPTIONSread-only) → snapshot JSON → render local | B túnel SSH-L 5433e tudo local | C tudo no homeserver com Chromium. Recomendação: A — usa a receita do runbook (socat 127.0.0.1:5433), não move credencial para a workstation, separa leitura (remota) de render (local, C157) e deixa o snapshot como prova auditável. Rejeitadas: B porque o guard local não distingue o proxy de um DB Docker (passe silencioso) e mantém lane de prod aberta na workstation; C porque instala Chromium no host de deploy. -
Onde vive o código. Opções: A
scripts/*.mjs+scripts/lib/*.mjspuros, importando os donos desrc/utilities/**| B novo loader emsrc/utilities/municipality/| C rota em/campanha. Recomendação: A — o app não consome o relatório (1 call site), evita o pin de top-level e convenções deserver-only, e mantém a lib pura unit-testável; o reúso continua via import dos donos (E16, rank, filtros de falas). Rejeitadas: B porque cria superfície desrcsem consumidor de app (DRY <3 call sites) sujeita a knip/convenções; C já rejeitada em E16/C157. -
Invocação do agente/skill. Opções: A skill
.agents/skills/relatorio-cidade/SKILL.md+ subagente.opencode/agent/relatorio-cidade.md| B só skill | C só command. Recomendação: A — a intenção pede "agente + skill"; a skill guarda o conhecimento (checklist, shapes, comandos, guardrails) e o subagente é a casca de workflow; a pesquisa web se beneficia de contexto isolado. Rejeitadas: B porque o "agente" é pedido explícito e o subagente é o gatilho; C porque um command é atalho redundante (a descrição do subagente já é a entrada) — barato, mas sem consumidor. -
Template e render. Opções: A blocos puros + dois renderers (HTML/PDF e MD) + print CSS próprio | B template HTML one-off no script (estilo C157) | C HTML externo com placeholders. Recomendação: A — um modelo de conteúdo alimenta PDF e
.md, então o companion não drifta; os blocos e renderers são unit-testáveis; o visual é o port do draft aprovado. Rejeitadas: B porque o.mddivergiria do HTML por construção; C por escaping/fragmentação sem ganho de teste. -
Fonte oficial de emendas. Opções: A Portal da Transparência
/api-de-dados/emendas(headerchave-api-dados, filtroscodigoMunicipio+ano, autor filtrado em memória, fase empenhado/liquidado/pago/resto pago) | B Câmara Dados Abertos | C só web/manual. Recomendação: A, com fallback lacuna explícita (sem chave, erro, zero linhas após filtro de autor ou homônimo ambíguo) e sem persistir nada; URL da consulta +consultedAtentram no item; janela dos anos do mandato (2023–2026, 4 requests ≪ rate limit 30/min). Rejeitadas: B porque a API da Câmara não expõe emendas orçamentárias individuais (só emendas a proposições); C porque não é determinística nem datável/paginável. -
Companion
.md, proveniência e visibilidade da entrega. Opções: A commit literal do PDF+MD emdocs/research/| B escrever PDF+MD no caminho da intenção (docs/research/relatorios-cidade/) com o diretório gitignored e proveniência completa | C só PDF. Recomendação: B — mantém o entregável e o caminho da intenção, preserva a revisão em diff local e evita publicar dados internos de campanha num repo público; cada bloco carrega{kind: teqo|web|official, label, url?, date?}, o.mdfecha com "Fontes e limites" e o cabeçalho trazreadAt, "snapshot do momento" e SHA do código (a base envelhece — o PDF é datado). Rejeitadas: A porque viola o fail-closed de LGPD/estratégia do AGENTS (nomes/pledges/estimativas no GitHub público); C porque perde a revisão em diff pedida. Publicação sanitizada fica fora do appetite com gatilho: pedido explícito do CG por versão pública.
Self-score
- Decisões caras com rejeitadas: 5/5 — D1–D6 têm opções, recomendação e rejeitadas explícitas (inclusive a única reavaliação de entrega, D6).
- Cabe no appetite: 4/5 — 2 scripts + lib pura + skill/agente + testes em 2–3 dias; risco de estouro nomeado (API de emendas, fidelidade do print CSS) com cortes prontos (lacuna; port do draft).
- Rabbit holes nomeados: 4/5 — Tailwind CDN, Chromium no homeserver, alias
pnpm, sanitização/publicação, segundo cadastro, scrape de vereadores, PII — todos explicitamente fora. - Reusa shells/helpers: 5/5 — E16 (
loadMunicipalityDossierData, VM/detail), rank/share, filtros de falas, mecânica PDF do C157,scripts/lib/cli.mjs, fixtures de int, rascunho A4 como shell visual. - Aceite de produto satisfeito: 5/5 — página 1 fechada, guardrails literais, PDF datado e completo, lacuna explícita; única ressalva sinalizada é a visibilidade do artefato (D6), resolvida fail-closed.
Média 4,6/5 — gate ≥4 atendido.
Simplificação e débitos (triage 2026-09-15)
Aplicado na sessão (2 revisores + fixes): cityReportSnapshot.mjs saiu de scripts/lib/ (é integração, não lib pura) e reusa o dossier para atividades e campaignRoleLabels do dono; projeção enxuta (PII + campos sem leitor fora); exports mortos removidos (knip volta a 0); render sem duplo wrapper, sourceLabel único e throw em kind desconhecido nos dois formatos; formatMoneyCompact renomeado; formatFreshness reusa municipalitySignalAgeInDays; buildPageOne decomposto em builders; validação de shape do snapshot + aviso de divergência de SHA no builder; fallback — nos labels; fonte no card de oposição; constantes de truncamento; guarda de página 1 com orçamento nomeado; testes ajustados (contagem do contrato, região por label, PII tautológica removida — provada no int com contato real).
Adiado com gatilho / explicitamente fora:
- Filtro de município nas emendas aceita linha sem código (fail-open documentado: confia no filtro server-side
codigoMunicipio). Gatilho: primeira extração real comPORTAL_TRANSPARENCIA_API_KEY; se a API não expuser código por linha, decidir entre manter o filtro documentado ou fail-closed (virar lacuna). - Formatters próprios vs donos (
formatElectionNumber, ordinal de colocação, fuso Bahia): política de null/—própria e copy do PDF; reavaliar se uma 2ª superfície precisar dos mesmos formatos. tableOrGap/ nota "Mostrando X de Y" repetidos em 5 seções: DRY adiado (copy diverge de propósito); gatilho: 6ª seção.- Cobertura automatizada da guarda de página 1 e do fetch do Portal: provados por execução do CLI/skill; sem teste por natureza (Chromium/rede).