teqo-backup/docs/CHANGELOG-AGENTS.md

100 KiB
Raw Blame History

Changelog de contexto para agentes (fatias do AGENTS.md)

Conteúdo movido do AGENTS.md em 2026-07-30 (fatiamento do paradigma de agentes paralelos): "Known Gaps" e todos os blocos "Recently resolved". Histórico — leia só quando precisar do contexto de uma entrega passada; o sempre-presente fica no AGENTS.md. Itens novos de "Recently resolved" passam a ser registrados aqui (uma entrada curta), não no AGENTS.md.


Known Gaps (as of 2026-07-24 — resolve before relying on this file for onboarding new devs)

Backlog consolidado (bloqueadores, site, campanha, white-label): docs/roadmap.md.

  1. RESOLVED 2026-07-27 (users.roles): campo roles (admin | editor) em users — migration 20260727_032523_add_users_roles com backfill admin. isPayloadAdmin exige papel admin; editor entra em /admin e escreve post/tag/media; PII/petition/campanha seguem admin-only. Unificação com campaignUser rejeitada (ver roles-admin-payload.md). Matriz fina por collection e editor em petition ficam fora.
  2. RESOLVED 2026-07-25 (Pass 2 D3): the last hardcoded consent document id is gone — submitWhatsapp.ts now resolves Consent.key = 'whatsapp-inscricao' fail-closed (migration 20260725_170000_whatsapp_subscription_consent_key tags the live document; keys live in src/lib/campaignConsentKeys.ts).
  3. Post/Tag ship the news system, but there is still no Pages collection for institutional content. Partly resolved: the post/tag collections back the news/publications system, and the home "Últimas notícias" list plus the /[type], /[type]/[category], and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (HomePage global still exposes only a single image field — the text lives in src/app/(frontend)/(home)/page.tsx), and there is no Pages collection yet for institutional content such as the bio and propostas.
  4. Campaign LGPD consent texts are not yet configured in production. Invites and self-service leadership flows fail closed until an admin creates Consent.key = 'lideranca-autopreenchimento' with counsel-approved copy. The C2 supporter registry (merged to main, production-blocked) additionally requires Consent.key = 'apoiador-cadastro' and Consent.key = 'apoiador-intencao-voto' (vote intention is sensitive data) — same fail-closed behavior. All four keys are bundled in the single legal batch of Onda 0 (see deploy checklist).

Recently resolved (2026-07-18): the /campanha nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration 20260718_010733_consolidate_campaign_schema. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration 20260718_190559_territorio_multi_municipio_bairro), TSE 2022 election baseline collections + pnpm db:seed:tse (migration 20260718_195854_add_election_results), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable /campanha PWA. Still on 2026-07-18, A2 shipped: static Bahia municipality↔TSE zone map (src/lib/bahiaTseZones.ts from TSE 2024 detalhe_votacao_munzona BA) plus opt-in territory/zone suggestion chips in the nucleus form (territorySuggestions, NucleusTerritoryAndZonesFields) — no migration, no forced equality on save. Also the same day: C2 Cadastro nominal de apoiadores (supporter join Contact↔campanha with optional nucleus, migration 20260718_222656_add_supporter with UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id), consent keys apoiador-cadastro / apoiador-intencao-voto resolved via a generalized campaignConsent.ts that fails closed, /campanha/apoiadores vertical with list+KPIs/detail/CSV-import wizard; phone required in v1, lideranca has no access to the area; production use with real data still waits on Consent keys + legal sign-off). Also the same day: C3 Planos de Ação (actionPlan + /campanha/planos, migration 20260718_222832_add_action_plan, blocos "Próximos eventos") — renamed to activity / /campanha/atividades by C13 on 2026-07-25; the old names survive only in frozen migration history. Also the same day: B2 map foundation — static TopoJSON for Bahia municipalities + identity territories, bahiaMunicipalityCodes, helpers in bahiaGeometries, re-runnable pnpm build:geometries (no migration, no UI; Leaflet is B3). Post-/simplify scale follow-ups for that delivery are registered as B5 (docs/plans/escala-dry-pos-b2.md). Earlier (2026-07-15): local Postgres via docker-compose.yml (Postgres 17, teqo_test); Payload migrations baselined; dev/test database guards; post/tag news routes deployed (see "Posts & Tags" below).

Recently resolved (2026-07-18 evening / 2026-07-19): A4 Baseline no produto + Gap vs 2022 shipped and merged to main — getNucleusElectoralBaseline / loadNucleusBaseline2022Overview aggregate TSE 2022 by nucleus geography; computeGapVs2022 + NucleusInsights on the overview tab; NucleusElectoralBaseline card (candidate / president / governor via BASELINE_TICKET_2022); "Baseline 2022" card on the filtered nuclei-list overview. No migration (reads existing A3 collections). A /simplify pass applied targeted cleanup (role-generic ticket config, per-city zoneNumber in […], shared geography helpers). Scale/DRY debits larger than cleanup (aggregate federal ranking on detail instead of loading all nominal rows, filter by TSE cityCode, Alert confirmed + Progress reuse) are registered as A7 (docs/plans/escala-dry-pos-a4.md).

Recently resolved (2026-07-19): C6 Escala e DRY pós-C2 shipped and merged to main — bulk supporter import via payload.db.drizzle inside the existing Payload transaction (supporterImportBulk.ts, ON CONFLICT DO NOTHING on (contact_id, nucleus_id), 500-row chunks), skipContactPhoneInvariant opt-in on Contact.enforceUniqueContactPhone (fail-closed: only honored inside an active req.transactionID), single-SQL KPI aggregate (supporterListOverviewAggregate.ts, COUNT(*) FILTER mirroring buildSupporterListWhere + access constraint), HMAC-SHA256 single-use import token + ephemeral supporterImportBatch staging collection (migration 20260719_011015_add_supporter_import_batch, TTL 10 min, actor-bound, consumed on confirm), and shared shells (campaignListUrl.ts, CampaignListPagination, campaignFormFields.ts, mapCampaignFormActionError.ts) replacing the three per-list pagination components. A /simplify pass applied targeted cleanup (identity maps, dead branches, relationshipId/canManageCampaignUsers reuse, native base64url, rowCount ?? 0 bug fix). Scale/DRY debits the simplify reviewers flagged as larger than cleanup (locks in 1 round-trip, .returning(), pg_trgm, shared drizzleBulk.ts, column-name assertion, migrating the remaining formActions) are registered as C8 (docs/plans/escala-dry-pos-c6.md). Production use with real supporter data still waits on the Onda 0 legal batch (Consent keys apoiador-cadastro / apoiador-intencao-voto).

Recently resolved (2026-07-21): B6 BahiaMap setStyle incremental — layer GeoJSON estável entre troca de ano/métrica/escala; pathByKeyRef + restyle O(2) no hover/select; fitBounds só em mode/highlightKeys; helpers em src/lib/bahiaMapStyle.ts. Fecha débitos de perf de B10/B11. Plano: docs/plans/escala-dry-pos-b3.md.

Recently resolved (2026-07-21): B11 Escala % dos válidos no Mapa das Praças — validVotesByYear in MunicipalityMapBundle, scaleMode selector (absolute | percentValid, default %), computeValidVoteShares, readout em % com votos absolutos secundários; /simplify fix scaleMax (legenda 0–100% alinhada ao fill). Plano: docs/plans/escala-percentual-mapa-pracas.md.

Recently resolved (2026-07-21): B12 Aproximar mapa ao footprint filtrado + correção hover — fitToKeys / interactiveKeys em BahiaMap (municipalitiesByIbgeCode); canonicalMapKeysKey; hover stroke-only; clear síncrono no mouseout; fit só quando footprint muda. Plano: docs/plans/aproximar-mapa-pracas.md.

Recently resolved (2026-07-21): Discovery "Inteligência de campanha" — ciclo literatura→persona→entrevista sintética aprovado. Embasamento canônico de produto/domínio em docs/research/ (compêndio de ~67 fontes sobre campanha de DF na Bahia; relatório com kernel, playbook de 25 padrões dado→decisão, anti-goals): agentes devem consultá-lo antes de propor métricas/análises territoriais novas. Programa registrado no roadmap: E8–E16 + B13 + C12 (+ adjacentes A11/E17; sessão real com o CG em 2026-07-23 validou/calibrou o programa — docs/CUSTOMER.md; plano-mestre docs/plans/inteligencia-campanha.md) — conta da cadeira (metas derivadas sobre válidos projetados), fila de alocação, registro-fundação (versions em votePledge, sinais tipados, allocationDecision), classificação relativa (substitui limiares 35/20/10 p/ DF), escala relativa no mapa (quantis/LQ/rank + símbolo proporcional), níveis N0–N4 staff-only, motor de sugestões com humano no loop, camada TI (salvaguardas MAUP), planejador de giros, backtest pós-eleição. Anti-metas travadas: % estadual absoluto como KPI, contagem bruta de cadastros, cartograma, "caça ao alienado", sugestão automática sem decisão humana.

Recently resolved (2026-07-23): Export CSV de assinaturas e contatos no admin Payload — @payloadcms/plugin-import-export@3.82.0 habilitado em signature e contact (import: false, export CSV síncrono); flatten de contact/petition via toCSV em src/utilities/signatureExport.ts (só assinaturas); consent excluído do export de assinaturas; collections exports/imports com access isPayloadAdmin; migration 20260723_025513_add_import_export_plugin. Plano: docs/plans/exportar-csv-assinaturas.md.

Recently resolved (2026-07-23): Engineering-standards hardening (tracker: docs/IMPROVE-CODE-QUALITY-PLAN.md; ledger: docs/TECH-DEBT.md; safety net: docs/TESTING.md; standards rule: .cursor/rules/engineering-standards.mdc — READ IT before coding). Phases: (0) explicit access on every collection — users/signature/subscription/consent admin-only, CMS writes admin-only, leader route gates on demandas*/planos*, createLeaderSupporter scope fixed, candidate treated as unrestricted staff consistently; (1) tooling gate — knip in CI (.github/workflows/ci.yml on the GitHub mirror), pnpm lint --max-warnings=0, as never banned (typed helpers hookFilledCreateData / tests' stub<T>), tsconfig include consolidated + noUnusedLocals, ~12 dead modules deleted; (2) caching — committed TSE aggregate artifact (pnpm build:election-aggregates → src/lib/electionAggregates/, loader bahiaElectionAggregates.ts), loadMunicipalityScope React cache() dedup, unstable_cache + election-tse tag on elections-tab loaders; (3) "Feel the action" gaps closed — task-toggle revalidation bug, shared CampaignListPendingBoundary dimming results, map-compare transition, dashboard map + detail tabs stream behind Suspense; (4) state scoping — map hover isolated in MunicipalityMapSelection, scenario provider narrowed, LeaderContactsPanel server-rendered with a client form island; (5) structure — campaignAccess split into src/utilities/access/* (re-exported), supporter actions split (supporterImport.ts), runStaffEntityMutation + runCampaignRedirectFormAction dedupe the org/dobradinha twins and 4 formActions ladders, Plaza→Municipality identifier rename finished.

Recently resolved (2026-07-24): A11 Posição em votos do município — rank denso sobre as 435 unidades do catálogo + % da própria votação estadual (artefato bahiaElectionAggregates); helper src/lib/municipalityVoteRank.ts; UI no MunicipalityBaselineCard (por ano) e coluna/ordenação ?sort=votos na lista (sort/dir mínimo name|votos; B15 amplia as demais keys). Plano: docs/plans/ranking-votos-municipio.md.

Recently resolved (2026-07-24): E4R Import único da planilha de projeção — pnpm db:seed:projecao (scripts/import-projecao.mjs) lê o xlsx canônico em docs/sheets/, faz match via canonicalizeMunicipalityName + municipalityCatalog, e sempre sobrescreve municipality.expectedVotes + priority (re-seed quando a mesa manda planilha nova); Salvador pulado; --dry-run + guard ALLOW_REMOTE_DB; parser puro em src/lib/projectionSheetParse.ts (Bom→optimistic, Regular→central, Mínimo→pessimistic). Fase 2 (mesmo dia, decisão do produto — não é mais zero PII): o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→politicalTrend (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→stateDeputy+stateDeputies (união; célula crua em dobradinhaNotes), LIDERANÇAS→contact name-only sem telefone + leadership (dedup estadual por slug do nome; Contact.phone virou opcional via migration 20260724_175500_contact_phone_optional, UI continua exigindo), ASSESSOR RESPONSÁVEL→campaignUser+advisors (união; Edizio=coordinator, Solla=candidate — candidate agora é elegível como assessor em eligibleCampaignStaffWhere; novos usuários com email <slug>@planilha.invalid + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→nextSteps, OBSERVAÇÃO→strengths/risks (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. No mesmo dia, o grupo duplicado municipality.voteGoals ("Meta Bom/Regular/Mínimo") foi removido — collection, zod, view models, form/card e utilitário voteGoals.ts — via migration 20260724_133600_drop_municipality_vote_goals (backfill metas→estimativas onde a estimativa estava vazia): expectedVotes (pessimista/média/otimista) é a única série por cenário do município. Plano: docs/plans/import-planilha-projecao.md.

Recently resolved (2026-07-25): B21 Página dos Territórios de Identidade — nova rota staff-only /campanha/territorios com os 27 TIs no sistema de listas: sort/filtros/busca canônicos na URL, Metropolitano decomposto em sub-linhas, primeira coluna sticky no mobile e linha → /campanha/municipios?region=<TI>. O chrome rico agora é compartilhado por CampaignSortableHead/CampaignHeaderFilterPopover; wrappers de municípios mantêm a política de domínio. O painel de TI e territorySlot foram removidos do Início. Sem migration/collection/action. Plano: docs/plans/pagina-territorios-identidade.md.

Recently resolved (2026-07-25): Engineering Consolidation Pass 2 (tracker: docs/IMPROVE-CODE-QUALITY-PLAN.md § Pass 2; architecture: docs/ARCHITECTURE.md; agent map: .cursor/rules/codebase-map.mdc). Highlights: W1 sistema de listas — colunas como dado em CampaignTable (seams B17/B18), URL state compartilhado (campaignListUrl + módulos por domínio; o god module municipalityUi.ts virou municipalityListUrl/municipalityListFilters/municipalityLabels/municipalitySignal), shells (CampaignListFooter, CampaignListEmptyState), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (useOptimistic, hrefs por serializador canônico, facet por slugs, facets no mesmo Promise.all); W2 — fronteiras: slug/phone/wordStartFilter/voteEstimate movidos para lib/, 21 loaders server-only, campaignRoles.ts client-safe (sidebar sem o barrel de access), votePledgeViews (contract) vs votePledgeData (loaders), ciclo supporter quebrado, components/campaign em 15 subpastas de domínio; decisão D1: sem src/domains/, sem ports-and-adapters; W4a — electionInsights.ts (941 linhas, 2 exports vivos) deletado; sobreviventes em electionFormat.ts/voteTrend.ts; E10 nasce limpo; W4b — knip exports/types/enumMembers em ERROR no CI (0 findings; kit ui sem peças mortas); W4c — tabs das páginas de detalhe extraídas (MunicipalityDetailTabs, ActivityOverviewTab) + factory detailTabUi; W4d — runCampaignFormAction fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); W4e — tipos de fonte única; W4f — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); D3 — consent do WhatsApp por chave estável (whatsapp-inscricao, fail-closed). Suites: 46 unit / 48 int / 7 e2e.

Recently resolved (2026-07-30): B77 Simplificar wizard ajuste de votos — WizardExpectedVotesStep reescrito: três cenários numa tela (VoteEstimateScenarioInputs compact + strip + atalhos no foco); validação pessimistic ≤ central ≤ optimistic só no confirm com Alert + highlight do par conflitante (getWizardVoteViolationHighlights); POST batch inalterado (MUNICIPALITY_EXPECTED_VOTES_ENDPOINT); ?cenario= legado redireciona para URL canônica sem param (hasWizardScenarioParam + wizardActionHref sem 3º arg); órfãos do ritual B61 removidos de wizardVoteEstimate/campaignWizardCopy. VoteEstimateScenarioInputs compact ganhou activeScenario/onFocusScenario/errorScenarios/disabled (lista inalterada). Sem migration. Plano: docs/plans/simplificar-ajuste-votos-wizard.md.

Recently resolved (2026-07-30): B70 Wizard atualizar liderança — /campanha/acoes/atualizar-lideranca substitui o stub B60 por WizardLeadershipStep (grid 2/3/4 + tile Adicionar + info Drawer + Tooltip de notes no hover fine) e WizardLeadershipForm (6 campos incl. exclusive B69); loadWizardLeadershipTiles no RSC; writes updateLeadershipWizard/createLeadershipWizard em transação Contact+Leadership (não updateLeadershipInternal); WIZARD_ENTRY_ACTION_QUERY_KEY + skip via trailingAction quando entryAction !== 'update-leadership'; Continuar após Salvar dirty → Início + toast; truncateNameAtWordBoundary em lib/. Sem migration. Plano: docs/plans/wizard-atualizar-lideranca.md. Desbloqueia wiring A1 embutido. Recently resolved (2026-07-30): B71 Polimento visual dos resultados de busca no Início — src/lib/homeSearchUi.ts centraliza bleed (-mx-4 px-4 md:-mx-6 md:px-6), heading discreto (text-xs sentence-case) e ul list-none; HomeSearchHitRow pinta hover/focus na faixa full-bleed; todos os HomeSearch*Group alinhados. Sem migration/action/contrato JSON. Plano: docs/plans/polimento-resultados-busca-inicio.md.

Recently resolved (2026-07-30): B69 Simplificar modelo de liderança — leadership.exclusive boolean NOT NULL DEFAULT true ("Apoio exclusivo") via migration 20260730_043306_simplify_leadership_fields + DROP sector/sectorNotes/consentNote (collection, zod, forms, filtros/sort B29 da lista, invite autofill/redemption, dossiê; ?sector= legado canonicaliza fora). Decisão de produto 2026-07-30 reverteu o rascunho: organization FICA — mapeia ramificações políticas (SindMed com várias lideranças); vertical /campanha/organizacoes e todos os vínculos intactos. Lista ganha coluna "Apoio exclusivo" (badge outline "Não exclusivo" só quando false); forms com checkbox default-on + hidden "false" na criação (último valor vence; ausência cai no default do zod). Nota operacional: o prompt interativo de rename do migrate:create trava sem TTY — gerar drops e adds em passadas separadas (CI=1) e fundir à mão foi o caminho; o NOT NULL foi emendado sobre o SQL gerado. Gate: tsc/lint/format/cycles/build; int 518/518; unit com as mesmas 16 falhas pré-existentes do clean tree (drift B66/B71 na base); knip idêntico à base (P3 + drift); Aikido 0 findings. Plano: docs/plans/simplificar-modelo-lideranca.md. Desbloqueia B70. Recently resolved (2026-07-29): B62 Simplificar modelo de sinal — municipalityUpdate kind sinal is now signalType + body only: migration 20260730_010601_simplify_municipality_signal_fields drops signal_source and triangulated (the generator re-emitted ADD VALUE 'adiada' from snapshot drift — removed by hand; this migration's committed .json heals the chain), and the five signal-type labels/descriptions were rewritten for the field team (Rede esfriou, Adversário apareceu, Alguém pediu algo — no "broker" in the signal model; activity.origin's Pedido de broker and suggestionCatalog copy were explicitly left out by session decision). MunicipalitySignalFields (B26 list control) and the inline block in MunicipalityUpdateForm lost Fonte/Triangulado; municipalityUpdatePageData + MunicipalityUpdateFeed (detail + dossier) render only the type badge. E11 changed semantics: any adversary signal in the window reads as confirmed — P1 triage 1 (session decision, replacing the triangulated/single-source split), MunicipalityTriggerInput.adversarySignal: { present: true } | null, single factor copy, E18 glossary aligned. Bonus gate fixes: the campaignMunicipalities "advisor scopes…" e2e was stale since B43 (leader home → /campanha/contatos) and was updated, and the municipalityElectoralBaseline bypass pin dropped 9→8 (pre-existing main drift). Gate: 876 unit / 489 int / targeted e2e green / build; Aikido 0 findings. Plano: docs/plans/simplificar-modelo-sinal.md. Recently resolved (2026-07-29): B59 Chassis do wizard de campanha — rotas staff em /campanha/acoes/<slug> (campaignActionRoutes.ts, campaignActionEntryHref), layout acoes/layout.tsx (gate staff, coluna max-w-lg), CampaignWizardShell (voltar mobile, caption de município sticky, trailingAction, foco no h1 ao trocar passo), cinco páginas-ponte até B60 e strip do Início com href nos fluxos wizard. Sem migration. Plano: docs/plans/chassis-wizard-campanha.md.

Recently resolved (2026-07-29): B49 Busca global — resultados Lideranças no Início staff: o mesmo POST /campanha/home-search agora inclui searchHomeLeaderships em paralelo com municípios e assessores; word-start no contact.name com pré-filtro buildLeadershipListWhere (contains) refinado em memória, cap 25, municipalitiesSummary via truncatedNamesLabel, desempate por updatedAt; assessor vê só a carteira (overrideAccess: false). Client: HomeSearchLeadershipGroup entre Municípios e Assessores, HomeSearchHitRow compartilhado, empty state via homeSearchHasAnyHits; linha → /campanha/liderancas/[id]. Sem migration. Plano: docs/plans/busca-global-resultados-liderancas.md.

Recently resolved (2026-07-29): B48 Busca global — resultados Municípios (+ TIs) no Início staff: POST /campanha/home-search (campaignJsonMutationRoute + homeSearchBodySchema) chama searchHomeMunicipalities — municípios via loadMunicipalityScope + matchesAtWordStart + readout A11; territórios no mesmo grupo via loadTerritoryOverview (contexto regional E17, não carteira do assessor). Client: HomeSearchResultsContext (fetch único, extensível B49+), HomeSearchMunicipalityGroup, empty “Nenhum resultado.”; MunicipalityVotePositionReadout extraído de MunicipalityList. Sem migration. Plano: docs/plans/busca-global-resultados-municipios.md.

Recently resolved (2026-07-28): E11 Motor de sugestões v1 — the 8 highest-confidence data→decision patterns of the research report §6.1 (P1 reduto dormente/ameaçado, P2 ataque, P3 correligionário na frente, P5 expansão, P6 grande sem rede, P7 pledges estagnados, K-A não responde, K-B perdida vestida) now run as a human-in-the-loop decision queue: a "Sugestões" panel on the staff dashboard (top-5 by triage + "pauta do silêncio") and a card on the municipality Overview, with aceitar/adiar/descartar writing allocationDecision. Three layers: src/lib/suggestionCatalog.ts (client-safe — pure predicates over MunicipalityTriggerInput, a numbers-only input type, plus the exact card copy: probable reading, the "descartes" that become the dismiss form's quick picks, cheap-before-expensive menu, contraindication, statute; anchors are illustrative and private, calibration = E15); src/utilities/municipalityTriggers.ts (server-only, on the E13 planner's mold — own municipality read because the shared scope select lacks engagementLevel/lastUpdateAt, six reads in one Promise.all, and per-year LQ using E10's exact formula rather than a third LQ writing); resolveSuggestionRecord which RE-EVALUATES the pattern server-side before writing — the snapshot records decision-time factors, never render-time ones, and a pattern that left the queue refuses with the safe SUGGESTION_STALE_MESSAGE; advisory lock municipality-suggestion:{id}:{patternId} is taken before re-reading the latest decision. The audit killed the plan's "sem migration": the real outcome enum was aceita|descarta|movimento (the plan's descartada|adiada values never existed), so "Adiar" cost migration 20260728_041547_add_allocation_decision_adiada_outcome (E14's movimento precedent), with postponeDays derived from the recomputed triage (7d; 14d on levels 4–5) and suppressUntil in the snapshot instead of a column. Suppression is pure and unit-pinned (isSuggestionSuppressedByDecision): aceita 14d, descarta 30d, adiada until snooze, malformed snapshot falls back to the shortest postpone — and "nível 1 fura a fila": a trigger returning as confirmed risk pierces a suppression recorded at lower urgency. Structural differential is code (§6.3): P2 yields to P3/K-B, P7 yields to K-A when effort was recorded. P3 fires from the artifact alone (runningAgain2026 is 100% desconhecido with no read path — "concorre de novo?" is the menu's first item, not a trigger). Silence review = alta/N2+ with no FIRED pattern (a suppressed one was answered, not silence) and no signal ≥30d; includes priority: alta because E14 shipped without backfill. UI: explicit submit on all three outcomes (the campanha-edit-where-you-see exception for note/confirmation flows), native radios (accent-primary — first radio precedent in the repo), managed focus across the trigger↔form swap, level 1 is the only red. Two facts worth keeping: the public-site base ul { my-6 ml-6 list-disc } leaks into campaign surfaces (the factors list needed B20's reset), and artifact-anchored patterns cannot be staged from DB rows — int tests that need an artifact property claim municípios through the fixture allocator until one qualifies (measured: 109/435 top-quartile, 205/435 quiet, and reduto+coreBlock is only 13/435, which moved P1's trigger coverage to the 45-case unit spec; explicit slugs would race the allocator's wrapping sequence). E18 concepts triagem-de-sugestoes + pauta-do-silencio; /campanha First Load JS 268 → 277 kB. Adjacent follow-ups live in the plan: wiring E13's "não vá" override to resolveSuggestion; catalog fase 2 (T/I/J, P4/P8–P12, K-C) after 2 weeks of real use + 20 decisions; a dedicated route if >15 recurring active suggestions. Plano: docs/plans/motor-de-sugestoes.md.

Recently resolved (2026-07-28): B32+ Escala e DRY pós-B32 — and one line of it is a convention every agent needs: every JSON POST route under /campanha is now created by campaignJsonMutationRoute(config, handler) (src/utilities/campaignJsonMutationRoute.ts), which exports the POST already same-origin-checked, body-parsed and error-mapped, and a convention guard in tests/unit/codebaseConventions.unit.spec.ts fails the build on a route.ts that exports POST without it — the guard sweeps all of src/app, not just (campaign), because a cookie-authenticated endpoint written under api/ would be exactly as exposed and exactly as invisible; the three exceptions (Payload's two re-exports, the secret-header revalidate) are declared with their reason. That wrapper replaced the helper the plan asked for, because the helper would still have been a line someone could forget: isSameOriginRequest was copied into five routes, so the CSRF policy it claims to own was failing open by omission. Two details not to undo: bodySchema.parse runs inside the same try as the handler (that is what keeps a malformed body answering with the route's existing genericMessage instead of a new one), and engagement-level keeps its 409 inside its handler so the wrapper never grows a "blocked" concept. parseCampaignJsonRequestBody and campaignJsonMutationErrorResponse are no longer exported. Client side: postCampaignJson (src/lib/campaignJsonRequest.ts) is the only place that spells credentials: 'same-origin' + JSON headers, and useCampaignCellAutosave (src/components/campaign/shared/) is the auto-save machine of the quick-edit cells (B24 tendência, B27 votos estimados, B32 status de apoio). Three audit findings worth keeping: display and draft never diverged — they were set together on every path, one value under two names, so the hook has one value and only the trend's raw noteDraft (raw text vs. normalized note) stays in the caller; reportFailure had not been born in all four controls as the plan claimed (MunicipalityListAdvisorsControl never imported sonner, so a failure after closing was mute); and the trend's inFlightRef was a workaround for having two code paths into one save, so it became an idempotent flush() that protects all three. The hook owns open because closing is the commit, and valueRef advances synchronously — the same "state captured by the render that scheduled the work" bug B33+ and B34 both paid for. Shell: CampaignCellEditOverlay took a statusMessage prop and renders the aria-live region outside the Popover/Drawer, because closing is what triggers the save and it was unmounting the node that would announce it; and variant="sheet" took a footer slot, which lifted "Registrar movimento" out of a scrollable body (E14) and let MunicipalityListSignalControl (B26) drop the Popover/Drawer it only kept for its footer — the planned formId prop turned out unnecessary, since <form id> in the caller plus <button form={id}> in the footer is plain HTML, and campaignCellEditTriggerClassName became module-private for lack of anyone re-spelling the trigger. /simplify found two P1s the gate could not see, both verified red against the pre-fix hook: a revert issued while a different payload was in flight was dropped silently — "nothing to do" compared against the last confirmation, so with B unanswered going back to A looked settled and B's success then repainted the cell as B; "settled" now means equal to what the server will end up with (the in-flight payload when there is one). And change() did not clear errorMessage, which outranks everything in statusMessage, so after a failure every later keystroke was announced as the old error. change also takes an updater now, because a select and a keystroke batched into one tick each built their payload from the render that scheduled them. The two reviewers disagreed on one thing, and the performance side won on counts: the live region is no longer mounted unconditionally (~250 polite regions on a 25-row município page — 5 controls × the card and table trees) but from the first open, which is early enough for "Salvando…" and outlives the close that commits; the consequence is that MunicipalityPortfolioCell keeps its own region, since on a fine pointer its editor IS the cell and the Drawer never opens. Also: engagement-level echoed updated.engagementLevel ?? movement.level right under a comment forbidding exactly that, and a município born without a level would have reported a dropped write as saved — it now fails. /simplify then closed three duplications the gate could not see: the auth throw is now CAMPAIGN_AUTH_REQUIRED_MESSAGE (it was a bare literal in five places, and safeMessages matches on exact string, so a reworded throw would silently collapse a 401 into the generic message); useCampaignCellFailureChannel owns the closing unmounts the Alert, so the failure changes channel policy that had been written out three times; and the live region was still hand-rolled in two cells — LeadershipStateDeputyRelationCell had it inside its Popover, the exact F4 bug, and MunicipalityPortfolioCell adopted statusMessage, whose role="status" the shell took from it. Reusing useCampaignCellAutosave in the set-membership editors was rejected with measurement: they fire one request per delta with no debounce, several in flight, reconciled by sequence number, and the hook aborts the superseded request — which for independent deltas drops a save. New specs: campaignJsonMutationRoute.unit.spec.ts and campaignCellAutosave.unit.spec.ts (jsdom + fake timers) — that machine had only e2e coverage before. Worth knowing for jsdom: form={id} does submit there, but native required validation runs too, so a footer-submit test must fill the required fields or it silently never submits. No migration, no collection, no network-contract change. Plano: docs/plans/escala-dry-pos-b32.md.

Recently resolved (2026-07-27): E13 Planejador de presença/giros — visit eligibility as five conditions ✓/— (volume, headroom, rede, janela, encaixe) plus three advisory contraindications, each carrying a counter-offer and never blocking; the calendar phase (construcao|consolidacao|ativacao) changes the suggested "produto da visita". Pure math in src/utilities/visitEligibility.ts, cut points in src/lib/visitPlannerAnchors.ts (illustrative, on the territorialClassAnchors.ts precedent; the phase turns at midnight in Bahia via the new formatBahiaCivilDate), candidates grouped by identity territory in src/utilities/visitPlannerData.ts (server-only). Surfaces: an "Elegibilidade para visita" card on the municipality OverviewTab, and a composer at /campanha/atividades/giros (staff-only, reached from a "Planejar giro" button — no new sidebar entry) that turns TI → âncora/satélites/semente into draft activity rows in one transaction. Three plan premises died in the pre-implementation audit and should not come back: kind: 'visita' never shipped with C12 and is not wanted — activity.deputyPresent already IS the candidate-presence marker, so a giro is derivable from deputyPresent + território + date window and each stop keeps its real kind; allocationDecision still has no application write path, so the "não vá" is advisory text and recording an override is E11's first job; and "satélites contíguos" was never possible — bahiaTerritories.ts carries membership only (polygon adjacency exists solely inside the build scripts), so encaixe = same TI, said in those words in the UI. Two implementation facts worth keeping: the uncaptured field ceiling and the catalog medians moved out of E10's classifier into municipalityPotential.ts (memoized), so the card and the "Classe" column cannot compute the same number twice; and createTourDraftActivitiesRecord re-reads every stop's município id with overrideAccess: false inside the transaction and aborts if one is missing, because activity create access is a plain staff boolean and Payload cannot express a per-município constraint on create — without it an advisor could seed drafts statewide by posting ids the composer never offered. Caps: 3 satellites, 8 stops. No migration, no collection, no Consent, no persisted tour entity. Plano: docs/plans/planejador-de-giros.md.

Recently resolved (2026-07-27): B34 Chips editáveis de municípios na lista de lideranças — the "Municípios" column of /campanha/liderancas was municipalityNames.join(', '), dead text with no link; it is now chips that navigate to /campanha/municipios/[slug], with a territory chip (MapPinIcon + count) when the leadership covers a whole TI, and it is editable in place. The decision that generalizes beyond this item: what picks inline vs. drawer is the pointer: media query, not viewport width. On pointer-fine the editor is always mounted — the "×" sits at the chip's corner revealed on hover/focus (absolutely positioned, so revealing it moves nothing) and a search input trails the chip row; on pointer-coarse a tap anywhere in the cell opens a Drawer with 44 px targets, because a thumb scrolling a table must not be asked to distinguish "open the município" from "delete the link". A tablet with a mouse gets the inline editor; a touch laptop gets the Drawer. This amends the anti-goal "always-mounted inputs on every list row" (PRODUCT.md principle 3 + .cursor/rules/campanha-edit-where-you-see.mdc) for chip-based relations on fine pointers — the input IS the readout there, and a per-row Popover would add a click to a target already on screen. It does not reopen B28/B31/B32: nothing here is a table mode, and AdvisorsTable's toggle was renamed "Editar nome e contato", which is all it still unlocks. Engineering, all divergences from the plan measured: AdvisorMunicipalityCell was promoted to src/components/campaign/shared/MunicipalityPortfolioCell.tsx (the plan rejected it — but the ResizeObserver + "Ver mais…" machinery it called dead weight became the requirement the moment the cell was always visible), and /campanha/assessores migrated onto the shared piece in the same PR, so the 2nd call site was born with the 1st; no context provider (the catalog index and addableIds travel as props of the column factory the RSC builds once per table); one batch action only — setLeadershipMunicipalitiesMembership (repeated municipalityIds + assigned), the single-id twin never existed, and the advisor twin (setAdvisorMunicipalityMembershipRecord + its schema) was deleted with its int tests migrated to the batch path; LeadershipRowViewModel.municipalityNames → municipalityIDs: number[] (names/slugs resolve client-side against the catalog the cell already holds); the floor of 1 uses aria-disabled + the reason in a toast rather than disabled, because disabled drops the button from the tab order and the reason lived only in its aria-label; the cap of 30 is refused client-side before the optimistic apply, so a TI batch does not bounce back whole with a message that never says which limit blew. /impeccable closed two real P0/P1s: suggestions were mouse-only (now a full ARIA 1.2 combobox — arrows/Home/End/Enter, aria-activedescendant, portaled listbox with its own name, "Nenhum resultado." kept outside the listbox) and auto-save was mute (aria-busy + dimmed row + sr-only live region, rollback scoped to the failed delta so a neighbouring saved toggle is not reverted, plus undo on batch removal); target size of the "×" went 16 → 24 px via before:-inset-1 (SC 2.5.8 has no mouse exemption, and the 44 px Drawer equivalent is pointer-fine:hidden), the "×" border got real contrast (the token's is 1.17:1 on a secondary chip), and the chips are links inside the Drawer again. /simplify caught two real bugs the gate could not see, both in the same shape — state captured by the render that scheduled the work. Every optimistic delta is now applied functionally, because the success path computed from the assignedIds of its own render: the toast's "Desfazer", pressed after a later add, dropped that add from the UI while the server kept it, and the reconcile effect only clears the optimistic value when both id lists match exactly, so the divergence outlived every later revalidation. That fix was half a fix, and the second half is the thing to remember: making the updater functional is not enough if its fallback is not. withDelta(current ?? municipalityIds, …) still read the render that created the closure whenever current was null — which is the normal state by the time an undo is pressed, since the server reconcile clears it — so the identical bug survived under a shape that reads as correct. The baseline is now a ref (latestIds) tracking effectiveIds, advanced synchronously in the draft branch so two toggles batched into one tick cannot drop each other, and the whole sequence is pinned in municipalityPortfolioCell.unit.spec.ts (verified red against the old code). Worth knowing that the two reviewers disagreed here — the performance pass read the functional withDelta and cleared it; only tracing the fallback branch finds it. And the saved/errorMessage pair was sticky, so from the first save onward the live region said "Municípios salvos." forever and never announced the result count again — the one thing an ARIA combobox needs; they collapsed into one feedback, cleared on typing, with an explicit priority (pending > error > searching > saved). Also: the pointer policy had been encoded twice (a matchMedia('(pointer: fine)') in the root onClick next to the pointer-* variants — the coarse overlay now stops propagation and the policy lives only in CSS); MunicipalityPortfolioIndexEntry.city/zoneNumber were read by nothing (TI/ZE resolution runs against the static catalog by slug) and were shipping in 435 entries of two routes' RSC payload; the advisor route's local parseMunicipalityIds (which swallowed invalid ids silently) gave way to the shared repeatedRelationshipFormValues; the advisor-scope refusal became LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE (a bare literal in 4 files, matched by safeMessages on exact string); the membership helper returns { next, added } instead of letting the caller re-derive added for the scope check; and onPersisted died because setAdvisorMunicipalitiesBatch already revalidates. One fix was created by the simplification itself: unifying the suggestion gate would have opened the inline list — which is portaled, so pointer-coarse:hidden cannot reach it — on top of the Drawer, hence separate inlineSuggesting / drawerSuggesting, which also closes the pre-existing case of opening the Drawer with the inline popover already up. Post-rebase /simplify, where a premise of this delivery died: the rebase brought B42's CampaignCellEditOverlay, judged inapplicable because "it switches on viewport and B34 switches on pointer" — false in the code. The container switches on nothing: variant is a prop, there is no matchMedia in it, and LeadershipListSupportStatusControl in this same table already hardcodes "popover" because a caller that knows its own policy just states it. B34's Drawer half now passes variant="sheet" unconditionally with pointer-fine:hidden on triggerClassName, so the pointer policy stays in CSS — and the point is not the ~25 lines (14 of them verbatim DrawerHeader/DrawerFooter) but initialFocus={titleRef}: the hand-rolled Drawer handed focus to the first chip, a link that navigates away from the row being edited, or to the search field on a draft row, which is the virtual-keyboard-over-the-sheet failure B42 had already fixed once. The trigger also gained the aria-haspopup="dialog"/aria-expanded/aria-busy every other cell trigger in both lists has, and the cell became the 5th case of the shared table-driven spec — the Drawer half had no coverage at all, since e2e runs on a fine pointer. Adopting cost one real bug, worth knowing before adopting the shell elsewhere: an always-mounted Drawer reports onOpenChange(false) when a dismissal anywhere on the page sweeps the layer stack, not only when it closes — so the new "clear the search when the sheet closes" handler was erasing what the fine pointer had just typed inline (caught by e2e, which passes only in file order; guard is if (!next && drawerOpen)). Fixed in the same pass, and it is behaviour rather than style: setAdvisorMunicipalitiesBatch revalidated only the last município of a batch (lastSlug) — pre-existing from B19, but B34 made the batch the only advisor write path and put a território chip within thumb reach, so dropping a 30-município TI left 29 detail pages stale; it now returns slugs: string[]. Rejected with reason: the container's Popover branch must not be adopted — it yields role="dialog" behind a PopoverTrigger, and a dialog cannot be the popup of an ARIA 1.2 combobox.

Latent bug fixed for free: CampaignTableHead's description seam (B22) broke any RSC caller — campaignHoverTooltipAlign/campaignHoverExplanationClassName lived in the 'use client' module of CampaignHoverTooltip, so the server was calling a client reference; they moved to src/lib/campaignHoverTooltip.ts, and the leaderships list is the first RSC header with an explanation. Post-merge /simplify follow-up (same day), all measured: the perf pass cut real CPU — buildMunicipalityPortfolioChips 4.2 → 0.78 ms cold per 25-row table and 0.043 ms on every later render (a WeakMap keyed on the index array, which one RSC builds once and all rows share, so it survives re-renders where a per-row useMemo would not), and search 4.59 → 0.077 ms per keystroke (normalize the query once instead of inside the matcher for each of ~660 candidates; matchesNormalizedAtWordStart is the new shared entry point and matchesAtWordStart delegates to it). Also: sameIdSet replaced a hand-rolled sorted-key comparison (3rd call site of a single-purpose helper), scrollIntoView is keyboard-only (the mouse is already where it points — it was a forced layout per option the pointer crossed), and revalidatePath now uses the real delta (changed), so removing a município that was never linked no longer busts its route. One reviewer suggestion was rejected by measurement, and it looked obvious: dropping sort: 'name' from the loader ("nothing depends on the DB ordering") broke the suggestion list — searchMunicipalityPortfolio walks the index in arrival order and stops at limit, so the alphabetical suggestions were alphabetical only because of that ORDER BY. The B34 e2e caught it; the sort is back with the reason written beside it. The finding that did NOT get fixed, because both fixes oppose each other: the delivery costs +58 kB First Load JS on /campanha/liderancas (246 → 304) and +42 kB on /campanha/assessores (253 → 295), measured with two full builds against a stashed clean main. Assessores already imported the static Bahia catalog, so its +42 kB is the cell, not the catalog — only ~16 kB of lideranças' delta is the catalog reaching a new route. Pulling the catalog out of the browser fattens the payload of every chip toggle; shrinking the payload to {id,slug} keeps the bundle. Registered as B34+ with the recommendation (payload-minimal) and its one real caveat. No migration, collection, Consent or URL change. Debt with a trigger: LeadershipStateDeputyRelationCell (B31) keeps its own Popover and measuring machinery in the same table — it, not B37, is the extraction trigger for a relation-agnostic chip editor (B37 plugs the existing shared cell into a third relation without new machinery). Measured, and now P1 in the ledger: two e2e tests fail deterministically at one worker on a clean main tree (verified by stashing this whole delivery) — campaignNearestMunicipality:156 and campaignMunicipalities:394. That is not the fichado 2-worker flake; do not dismiss a red pnpm test:e2e as flakiness without re-checking those two by name. Plano: docs/plans/chips-municipios-lista-liderancas.md. Recently resolved (2026-07-27): users.roles — Payload admin RBAC. Field roles (admin | editor) on users (migration 20260727_032523_add_users_roles, backfill admin); isPayloadAdmin requires the admin role; editors open /admin and write post/tag/media; PII/petition/campaign collections stay admin-only. Unifying users + campaignUser was rejected (25 FKs; structural isolation). Plano: docs/plans/roles-admin-payload.md. Recently resolved (2026-07-29): B51 Busca global — grupo Atividades no Início staff: searchHomeActivities (staff-only; word-start no title; access activity com overrideAccess: false; rascunhos incluídos; cap 25; desempate startAt desc); campo activities no POST /campanha/home-search; HomeSearchActivityGroup após Assessores; secundário "{município} · {quando}"; empty via homeSearchHasAnyHits. Int homeSearchActivities.int.spec.ts. WA = B55. Plano: docs/plans/busca-global-resultados-atividades.md.

Recently resolved (2026-07-29): B50 Busca global — grupo Assessores no Início staff: searchHomeAdvisors (isUnrestrictedCampaignRole — assessor recebe []; word-start no nome; municipalityIdsByAdvisorIds exportado de advisorData); campo advisors no POST /campanha/home-search; HomeSearchAdvisorGroup + HomeSearchHitRow compartilhado; empty via homeSearchHasAnyHits; secundário = contagem de municípios (sem e-mail). Int homeSearchAdvisors.int.spec.ts. WA = B55. Plano: docs/plans/busca-global-resultados-assessores.md.

Recently resolved (2026-07-28): B17 Seletor de colunas — a "Colunas" picker shipped as a capability of CampaignTable, so it landed on all seven list surfaces (municípios, lideranças, dobradinhas, organizações, demandas, apoiadores, territórios) rather than only on /campanha/municipios: pass columnVisibility (one value, from readCampaignColumnVisibility(listId)) and the table mounts CampaignColumnPicker and filters through resolveVisibleColumns. /campanha/assessores stays out — AdvisorsTable is still a raw ui/Table with table-fixed, and migrating it to the list system is separate work. Two premises of the 2026-07-24 plan died, and the first one died on a type, not a preference: CampaignTableColumn.cell is a function, so the column array never crosses the server/client boundary and the client cannot rebuild the table without it — hiding client-side would mean tagging every cell with data-column and injecting a <style> per list. Persistence is therefore the cookie campaign_columns (path /campanha, SameSite=Lax, one year, client-readable because the client writes it), carrying a listId → hidden ids map in a compact form (municipios:kind~trend|demandas:requester) with no JSON and no percent-encoding — document.cookie and Next's cookies() disagree about decoding, and every character used is legal in a cookie-value under RFC 6265. The server filters, so a hidden column does not reach the RSC payload either; the price is one router.refresh() per editing session inside the shared transition, dimming the results like every other filter on those screens. Closing the menu is the commit, not a debounce: the first cut used 400 ms, which batches a burst rather than a reading pace, so unchecking four columns while reading their labels was four full-route refreshes — and on /campanha/liderancas each one re-pays the ~229 ms / 371-statement render already ledgered P1. The timer survives at 3 s, but a /simplify pass split what it does: it writes the cookie only, without refreshing, because any timer that also refreshed just recreates the per-toggle cost at a slower pace — three seconds is an ordinary gap between clicks when the labels are being read, which is the same failure the 400 ms cut was rejected for. Durability and repaint are separate questions, so they get separate triggers, and the accepted cost is stated because it is real: the table behind the menu no longer updates per checkbox. Two guards came with the split — a session that ends where it started refreshes nothing (sameIdSet against the rendered set), and a menu still open when the route unmounts writes the cookie from the cleanup, since Radix reports no close event for a sidebar navigation and that choice used to vanish silently. The second casualty is the defaultVisible seam, which is gone: it was true on 10 columns and false on none, and storing only the hidden set is what makes a newly shipped column visible to everyone who never touched the picker — the contract E9 and E14 already assumed. In exchange, label: string is now required on CampaignTableColumn (the picker needs a serializable label), filled in across ~55 column definitions — and since 24 of those columns then had a head that only restated the new label, head became optional, defaulting to <CampaignTableHead>{label}</CampaignTableHead>; declare it only for an alignment, a B22 description, or a head island of its own. Each surface declares one mandatory column, listed in the menu disabled with its reason and re-filtered on the server, so a stale cookie cannot leave a row without its only linked cell. The real bug review caught is this codebase's recurring one in new clothing: the RSC payload of toggle A arrives while B is still inside the debounce window, and adopting the server there undid B and then committed the set without it, losing it silently — reconciliation now runs only when no write is pending, pinned in tests/unit/campaignColumnPicker.unit.spec.ts (verified red against the previous code). Worth knowing operationally: two e2e rounds during the session suggested "router.refresh() does not apply the cookie". That was the machine at load average 40 with another worktree compiling — the picker's own spec passes at one and at two workers, and the lesson is that expect's 5 s default does not cover a dev-mode RSC round-trip on a heavy list route with two workers, so the refresh assertions carry their own budget. The P1 red-e2e ledger row also got wider and the way to read it changed: campaignMunicipalities fails five tests at one worker on a stashed clean tree (:56, :125, :292, :394, :446) against four with this delivery applied, and the failing set differs between runs — so "check those two by name" no longer settles anything; compare the count against a stashed tree. No migration, collection, server action or Consent. Plano: docs/plans/seletor-colunas-lista-municipios.md.

Recently resolved (2026-07-27): E14 Níveis de envolvimento N0–N4 — municipality.engagementLevel (staff-only select n0…n4) records what the campaign decided to invest in a município, next to the E8/E10 metrics that describe what it knows. It is also the first production writer of allocationDecision (C12 shipped 2026-07-24 with access and tests but no caller). Migration 20260727_161752_add_municipality_engagement_level adds engagementLevel (indexed, no default), levelNote (textarea 2000) and levelChangedAt (indexed date, readOnly, stamped by a beforeChange hook only when the level actually changes — and cleared when the level is), plus the value movimento on the allocationDecision.outcome enum, because a level move is neither aceita nor descarta and pretending otherwise would poison E11's triage. No backfill: the field is born null on all 435 municípios — stamping N2 would assert a decision nobody made — so null renders "Sem nível" and filtering by it is the triage queue. Moving a município is a reallocation decision, so writes are isCampaignUnrestricted only (canManageMunicipalityEngagementLevel in utilities/access/municipalities.ts); the advisor reads it and proposes through a signal. setMunicipalityEngagementLevelRecord takes the advisory lock municipality-engagement-level:{id} before reading the current level (otherwise the snapshot's from is a guess under concurrency) and writes both documents in one transaction, with snapshot = { from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt } — the values the decision was made on, never the document. The hysteresis rules from research §6.8 live in the pure, client-safe src/lib/engagementLevel.ts (two-level jump needs a triangulated shock; three-week protection window after a promotion, against demotion only; one move per calendar month, derivable from levelChangedAt alone — no extra query), so the Popover shows the reason before submitting and the server re-runs them anyway. The route has three states, not two: blocked (409) returns the typed violations instead of collapsing into "could not save", because a blocked move is a decision the coordination may still take — the Popover keeps the draft and offers the override, which is recorded with exactly the rules it contradicted. Explicit submit, not auto-save — the exception campanha-edit-where-you-see already carves out for flows needing a note/confirmation, which also keeps this control out of the debounce machine B32+ will audit. On the list: an 11th staff column with a numbered outline badge, plus filter ?level=, which — unlike E10's derived classe — is native, because the field is stored and reaches the where; "sem nível" cannot ride inside an in and becomes an or branch with { exists: false }. The sort is not native, though n0…n4 does order lexicographically: Postgres puts NULLs FIRST on DESC, so with no backfill "N4 primeiro" would open on a page of "Sem nível". nivel joins applyDerivedMunicipalitySort and reuses sortByNullableValue, which sinks the absent level in both directions — the same treatment sem_base gets in classe. New E18 concept nivel-de-envolvimento. Worth knowing: with overrideAccess: false Payload silently drops a field the actor cannot write instead of rejecting the update, so the advisor int test asserts the level did not change rather than expecting a throw. Plano: docs/plans/niveis-de-envolvimento.md.

Recently resolved (2026-07-27): Build resiliente ao global metadata ausente — resolveSiteMetadata in src/utilities/seo.ts (chain global.URL → NEXT_PUBLIC_SITE_URL → omit canonical/OG/JSON-LD url; textual defaults for empty global). All public SEO consumers (layout, /abaixo-assinado/[id], /[type] tree) go through it; convention guard in codebaseConventions.unit.spec.ts. Plano: docs/plans/build-resiliente-metadata-ausente.md.

Recently resolved (2026-07-26): B8 F2 Polígonos dos Municípios-zona de Salvador (ZE 1–19) — Salvador used to be one polygon carrying the whole city's votes, so the choropleth could not answer "which piece of Salvador is weak, or mine"; the only per-zone view was the textual zoneBreakdown. Two halves shipped. (1) The mesh: a new committed artifact src/lib/geometries/bahia-municipality-zones.topo.json (72,310 bytes, ~20 KB gzip, 19 features keyed by municipalitySlug) built by pnpm build:municipality-zone-geometries — see "Campaign map geometries" for the source, the parser and the three curated reconciliation tables. The interesting part is not the dissolve, it is that the curation lives in the script, declared line by line with its evidence, and the build fails closed: an orphan polygon or a TRE neighbourhood with no polygon aborts it, so "170/170 → 19/19" is a tested invariant. Periperi, which the resolution splits mid-street between two ZE, goes whole to ZE 17 — the neighbourhood is the smallest geometry available and cutting a polygon from a prose description of a street was the rabbit hole the plan already refused. The 5 polygons the resolution never names are assigned by longest shared boundary, and that rule was validated against the 4 it names under another label before being trusted. (2) The key: the choropleth is no longer keyed by codarea but by a map key (mapKeyForMunicipality: slug for kind === 'zona', ibgeCode otherwise) — see the "Map" bullet in the Município model for the contract, the non-interactive Salvador base and the per-city rank limitation. Because each map key now maps to exactly one catalog entry, the rollups stopped accumulating with += and computeAggregateTerritorialClass gave way to computeMunicipalityTerritorialClass; resolveMunicipalityMapNavigation became 1:1, and with it the kind: 'zones' branch, its "toque de novo" readout copy and the panel's scroll-anchor all died. buildMunicipalitiesByIbgeCode survives untouched: B14's list href is per city on purpose. Two measurements, so nobody re-derives them: Salvador's municipal polygon is ~690 km² against ~324 km² for the 19 zones summed — the difference is the bay water the municipal limit encloses and a neighbourhood mesh never draws, which is why the int test asserts a land-area range instead of a ratio against the município (the first version demanded ≥95% and was comparing land to land+water); and centroid-inside-another-zone is not a valid overlap check here, because ZE 16 is concave and wraps ZE 14, so 16's centroid legitimately falls in 14 — non-overlap is guaranteed structurally at build time plus the area sum. New e2e campaignZoneMap.e2e.spec.ts pins 436 paths / 435 interactive (the one non-interactive path is Salvador's base) and that a zone opens /campanha/municipios/salvador-ze-N. No migration, no collection, no server action, no Consent. Open debts with triggers (in the plan, not silent): the B14 "Onde estou" card still routes Salvador to the filtered list even though resolving the exact ZE is now possible, and per-ZE competitive rank needs a new dimension in the TSE artifact. Plano: docs/plans/poligonos-pracas-zona.md.

Recently resolved (2026-07-27): B8+ Escala e DRY pós-B8 F2 — the four /simplify debts of the ZE polygons, and the headline finding changed owner. The plan called the target "ground resolution implemented via quantization digits"; measuring the committed artifact re-snapped to coarser grids showed quantization alone barely cuts (~25 m → 90% of the original vertices, and even on the state mesh's ~103 m grid the zones stayed at 203 points/feature, ~9× the sibling). The lever is the absolute Visvalingam threshold, so the policy was inverted: absolute weight in m² of ground is the policy, digits are derived per topology. See the ground-scale policy bullet under "Campaign map geometries" for the numbers that matter; the outcome is the zone mesh at 574 arc points / 30.2 per feature / 7,687 bytes (from 9,120 / 480 / 72,310), bahia-municipalities byte-identical, and bahia-identity-territories off by one byte because its grid is now derived from its bbox (102.730 → 102.822 m). zoneNumber left the artifact and the type. The other three phases: featureMapKey is now exercised on real features of all three committed meshes, each inside the loop that already sweeps it whole (417 codarea + 27 code + 19 slug) — the /simplify caught the first version asserting the city inside the zone loop, which covered one municipal feature nineteen times because the 19 ZE share Salvador's code — and the synthetic precedence test died (the invariant is real — see the Map bullet); a zone-chunk failure degrades instead of killing the map (with the reason logged, since a ChunkLoadError and a corrupt artifact are not the same event), and the approximation caveat left the !comparisonActive gate for a paragraph conditioned on the new bundle field hasZoneMunicipalities, published by the loader from kind === 'zona' — the /simplify rejected deriving it on the client from the key's shape (a second encoding of mapKeyForMunicipality, in negative space) and rejected gating on zoneBreakdown, which the zone-list fill-in deletes; and the delivery's orphans are gone — two accessors deleted, MunicipalityMapNavigation collapsed to string | undefined, MunicipalitiesByMapKey to Record<string, string> (435 dead strings out of every Início RSC payload), polygonRingsOf exported, geometry helpers retyped on PolygonalFeature. One planned deletion was rejected by measurement: city/zoneNumber on MunicipalityZoneNeighborhoodEntry stay — no runtime reads them, but both feed the rows hashed by canonicalEvidenceRows and are columns of the RA 02/2017 provenance fixture (an independent transcription), so deleting them would weaken the verification for nothing. No migration, collection, action or Consent; e2e still 436 paths / 435 interactive. Plano: docs/plans/escala-dry-pos-b8f2.md.

Recently resolved (2026-07-28): B37 Chips editáveis de municípios na lista de dobradinhas — the "Municípios" column of /campanha/dobradinhas was row.municipalityCount, a dead number; it now uses the same MunicipalityPortfolioCell (B34 ✓) unchanged, writing the inverted side of the relation (municipality.stateDeputies) through a single batch action (setStateDeputyMunicipalitiesBatch, transactional, per-município advisory lock, all-or-nothing on a partial-scope batch), with no floor (a dobradinha can hold zero municípios) and no assertMunicipalitiesWithinScope (canUpdateMunicipality already scopes the advisor). stateDeputiesArraySchema's cap moved 20 → 435 to fit a whole território batch from the município side. The post-implementation critique found two P1s, and the answer was to generalize immediately instead of registering a fourth "deferred with trigger": MunicipalityPortfolioCell and LeadershipStateDeputyRelationCell (B31 ✓/B36 ✓) had grown the same interaction machine (optimistic delta, undo, ARIA 1.2 combobox, floor/ceiling, pointer-fine/coarse split) twice, with incompatible interfaces — this item was the 3rd call site B34+ was waiting for, so src/components/campaign/shared/RelationChipCell.tsx (813 lines) became the single engine and both cells shrank to thin, domain-specific wrappers (chip/search building, FormData, copy — nothing interaction-specific left in either). This closes B34+'s useChipRowOverflow extraction trigger ahead of schedule: the ~105 duplicated lines it named are gone, not just deduplicated into a helper. LeadershipStateDeputyRelationCell also gained an ownerName prop (for the Drawer description and aria-labels, matching the município cell) and its first dedicated test suite — it shipped with zero coverage of its own before this. The second P1 — an advisor could see, and attempt to remove, a município chip outside the portfolio they administer, and the removal silently no-oped at the document level with no way for the cell to explain why — was closed by hiding the chip instead of only explaining the failure: MunicipalityPortfolioCell now filters municipalityIds by addableIds when present (visibleIds), so an advisor sees and can only touch municípios they administer; staff without addableIds still sees and can remove every link. Accepted trade-off: in a rare portfolio-reassignment race, the client-side floor could refuse a removal the server would actually accept — acceptable because the server remains the source of truth and the explicit ask was to hide, not merely explain. Gate: tsc/lint/format:check/check:cycles clean, unit 715/715, int 452/452 (full suites, not just touched files), Aikido 0 findings on edited first-party files; knip blocked by the pre-existing, already-ledgered payload.config.ts load failure (P3 in TECH-DEBT.md), not a regression of this delivery. The closing /simplify found a latent bug rather than a deduction, and it is the reason one convention here is now load-bearing: mapCampaignFormActionError matches a thrown message against safeMessages by exact string, so a cap message spelled as a bare literal in the throw and in two independent allowlists is three chances to reword one of them and silently collapse a real refusal into "tente novamente". Those messages are constants next to their caps now (LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, MUNICIPALITY_ADVISORS_CAP_MESSAGE, plus STATE_DEPUTY_STAFF_MESSAGE — renamed, since it guards create/update too, and actions/stateDeputy.ts was importing the constant while restating it as a literal one line below). What surfaced them: B37's delta helper was the third byte-for-byte copy of the same single-id calculation, differing only in which cap it reads, so the algorithm lives once in src/lib/relationMembershipDelta.ts (nextIdsAfterMembership, cap as data) with the three domain names as one-line wrappers, and the three cloned specs became one describe.each with more coverage than before (every relation now gets the "can go to zero" and no-mutation cases only B37's clone had). The set-with-floor form (nextMunicipalityIdsAfterLeadershipMembership) stayed out on purpose: B34+ had already inverted that recommendation on a second reading, and seeing the collapsed code confirmed it — a generic covering both forms needs four callbacks to save ~20 lines, while the new helper is 12 lines with no optional parameter. Two memoization gaps closed, both restoring invariants the codebase had elsewhere: the advisor's scoped .filter() returned a fresh array per row, defeating the very WeakMap that exists to share derivations across rows (now scopedPortfolioIndex, memoized on (index, addableIds) in the lib — this closes B34+ F9), and the leadership cell's search called matchesAtWordStart per candidate, paying an NFD pass plus three Unicode regexes on both sides per keystroke, the cost wordStartFilter's own doc comment warns about. Also: the batch action stopped resolving the dobradinha slug before its loop (the slug exists only to target the revalidate, which the caller already skips on a no-op — the guard its leadership twin already had), its redundant second dedup is gone, and the bare 435 shared with the advisor batch is MAX_MUNICIPALITIES_PER_BATCH. Rejected with reason: maxItems on the dobradinhas cell "for parity" — a dobradinha has no product ceiling on municípios (which is why the bound is the catalog), so a "generous 30" would invent a rule that refuses a legitimate statewide dobradinha. Two debts absorbed into B34+ as F10 rather than opening an ID: both município-side batch actions write one município at a time (findByID + update each — ~870 round trips at the 435 ceiling this delivery raised, but ~52 at the largest real TI, and no test above a batch of 2), and they are twins at 2 call sites; the common trigger is a "select all"/multi-TI affordance or a third batch on that side. No migration, collection, or Consent. Plano: docs/plans/chips-municipios-lista-dobradinhas.md.

Recently resolved (2026-07-28): B18 Filtros salvos na lista de Municípios — a "Salvar filtro" button beside "Limpar" names the current URL recorte, and the shortcut becomes a second-level submenu under Municípios in the sidebar (chevron, persisted disclosure, active item, delete with undo). Three layers: src/utilities/municipalitySavedFilters.ts (localStorage, snapshot cache for useSyncExternalStore, cap 12, alphabetical order on purpose — a named shortcut that reorders itself under the cursor every time it is re-saved is a navigation bug; recency is Visitados' job), the pure src/lib/listQueryMatch.ts, and two islands synchronized by useMunicipalitySavedFilters. The identity is the href, not a UUID: the entry is { href, name } — a UUID would exist only to allow two shortcuts navigating to the same place, which is exactly what the upsert prevents, and that is what makes "renomear" (deferred in the plan) cost a string instead of a phase, since the button just reads "Renomear" when the current recorte is already saved. Five premises of the 2026-07-24 plan died in the audit, and the first cost work outside the appetite: SidebarMenuSub/SidebarMenuSubItem/SidebarMenuSubButton/SidebarMenuAction did not exist (Pass 2 W4b put knip exports in ERROR and the kit was pruned), so they were re-added from upstream — and doing so exposed a latent kit bug worth knowing before touching that file: React renders data-active={false} as data-active="false" and Tailwind's data-active: selector matches [data-active], so every non-active item was getting the active style; the attribute is now emitted only when true (data-active={isActive || undefined}) on both SidebarMenuButton and SidebarMenuSubButton. The others: municipalityUi.ts no longer exists (W1 split it, so buildMunicipalitySavedFilterHref lives in municipalityListFilters.ts); the URL state grew to 14 params, absorbed by serializing the whole state minus page/compare rather than by a hand-written param list; B17 persists in a cookie, not localStorage; and hover disclosure died with B38 ✓ — collapsible="offcanvas" takes the whole sidebar off screen when collapsed, so there is no rail to hover. One seam came free: formatMunicipalityActiveFiltersSummary was already the "has a filter?" gate the Limpar button uses and already produced a readable label of every filter, so it is simultaneously the mount condition and the suggested default name. The central risk was bundle, and it was measured against a stashed clean tree: the sidebar lives in the (app) layout, so importing buildMunicipalityListHref there would cost the 21 kB AGENTS.md already records (it drags bahiaTerritories + municipalityCatalog) across every /campanha route — with the pure comparator instead, /campanha stayed unchanged at 277 kB and only /campanha/municipios moved (328 → 330 kB); ignoring page also settled a case the plan never considered, that being on page 3 of a saved filter is still being in it. Two real bugs only the e2e caught. The auto-open re-opened the group on every mount, which made the persisted preference unhonorable for exactly the person who uses saved filters most (collapse it, reload, it is back) — and the cause is not obvious: the trigger was activeHref appearing, but the store answers empty until the client has read localStorage, so a match surfacing out of hydration read as a navigation; the trigger is now the URL changing. And deleting a row dropped focus to <body>, because emptying the group unmounts the whole component, chevron included — the successor is resolved before the removal. The critique also found an SC 2.5.3 (Label in Name) failure: visible label "Salvar filtro" with aria-label "Salvar este filtro", which voice control cannot match, so the accessible name has to contain the visible one. Gate: importmap unchanged, tsc/lint/format:check/check:cycles clean, unit 787/787, int 478/478, e2e 3/3, local pnpm build green, Aikido 0 findings; knip blocked by the pre-existing ledgered payload.config.ts load failure (P3), so every new export was verified by hand. Worth knowing operationally: pnpm build cleans .next and the e2e dev server uses NEXT_DIST_DIR=.next/e2e, so running a build between two e2e rounds destroys the dev compile cache and makes the setup project blow its 90 s budget — that is not a test flake. No migration, collection, server action or Consent. Plano: docs/plans/filtros-salvos-municipios.md.

Recently resolved (2026-07-26): E12 Camada TI — extends the E17/B21 rollup (territoryOverview.ts + loadTerritoryOverview.ts) with E8 goal coverage (central), MAUP-safe regional capture (aggregate ratio Σ own votes ÷ Σ field ceiling — never the mean of municipal ratios; median, min–max, named critical municipality and beacon), and aggregated territorial class via computeAggregateTerritorialClass (same rule as B13 for TI groups). Staff surface: three new columns on /campanha/territorios (Cobertura, Captura, Classe) with B22 header descriptions and sorts cobertura/captura/classe; municipality detail gets intra-TI benchmark T4 on MunicipalityGoalAccountCard (territoryIntraCaptureBenchmark.ts — Salvador zone slugs compare among the 19 zones, other Metropolitano peers among RMS municipalities). Two E18 concepts (captura-regional, benchmark-intra-ti). v1 cuts kept: no ?group=territorio on the municipality list, no TI map mode. No migration. Plano: docs/plans/camada-territorios-identidade.md.

Recently resolved (2026-07-26): B24 Auto-save + justificativa no popover de Tendência — MunicipalityListTrendControl grava sozinho via POST /campanha/municipios/political-trend (status ~150 ms, nota 600 ms, flush blur/fechamento), Textarea de justificativa no Popover, sem revalidatePath da lista; /editar mantém submit explícito. Plano: docs/plans/autosave-tendencia-lista-municipios.md.

Recently resolved (2026-07-26): B14 Município mais próximo (atalho geo no Início staff) — a new client island NearestMunicipalityCard ("Onde estou") answers "open the município I am standing in", and the answer is point-in-polygon, not a nearest-centroid guess. The source plan had rejected polygons as a bundle rabbit hole; the premise was false by the time it shipped, because MunicipalityMapPanel always mounts in mode="municipality" and BahiaMap calls loadMunicipalityGeometryModule() on mount, so the 417 polygons (132 KB raw, 41 KB gzip, own memoized chunk) are already in the browser of anyone on /campanha — the card reuses that promise and adds no geometry. So no centroid artifact, no build script, no migration, no collection, no server action and no Consent key: the position is read by navigator.geolocation and matched in the browser, and never leaves the device. Pure math in src/lib/municipalityProximity.ts (haversine, ray casting with holes, area-weighted centroid, discriminated union inScope | zoneCity | outOfScope | outsideBahia; measured, so nobody re-derives it: the mesh decodes to 417 plain Polygons with zero holes and zero MultiPolygons, a full containment scan costs 0.106 ms and the whole fallback 0.32 ms — 81% of it centroids — so there is no hot path here and a centroid artifact would still not pay for itself; building a fresh Map per call measured slower, and the module's existing getMunicipalityFeature index is passed in only because the map already built it); browser plumbing in src/utilities/campaignGeolocation.ts (sibling of recentVisits.ts: session flag teqo:campaign:geo-prompted-session so the automatic prompt fires at most once per tab, guarded permission read where Safari's missing descriptor legitimately answers unknown, typed failures). Centroid distance survives only to rank the fallback: an advisor standing outside his portfolio is told where he is and offered the closest município he can open, up to NEAREST_IN_SCOPE_MAX_KM = 150 km. Salvador still gets no guessed ZE — it links to the filtered list, with the zone count limited to what the actor can read. StaffDashboardView gained accessibleMunicipalities (~6.6 KB gzip of RSC payload for all 435), and the zone-list href reaches the island as a zoneCityHrefs prop serialized by CampaignDashboard (the RSC that already builds list hrefs) rather than by the Payload loader: the first version imported buildMunicipalityListHref into the island and cost 21 kB of First Load JS for a single link, because the canonical serializer drags bahiaTerritories + municipalityCatalog in — the "client components must not import server data modules for values" rule applies to URL serializers too, so the href is serialized on the server. /campanha First Load JS: 265 → 268 kB (measured against a stashed baseline, not against the number in a previous note). A coarse fix (accuracy > 10 km, typical of a desk) renders the result with an explicit "localização aproximada" caveat rather than silently naming the wrong município. The shortcuts row hides itself in CSS (hidden … has-[>*]:grid, written inline in CampaignDashboard): having the children report visibility upward cost two state slots, a callback prop, an effect and a layout shift on every load, because the server rendered the row hidden and the effect only unhid it after first paint — and with that state gone, the DashboardShortcuts wrapper was a named div, so it is gone too. loadMunicipalityGeometryModule memoizes the dynamic import's rejection, so the card catches it and falls into an actionable failure state — otherwise a ChunkLoadError left a spinner with a retry button that could never recover. Plano: docs/plans/municipio-mais-proximo.md. Recently resolved (2026-07-26): B13 Escala relativa no mapa dos Municípios — the dashboard choropleth was nearly monochrome because 0–100% of valid votes compresses a race whose statewide ceiling is 4.71%. It now offers three scales relative to the candidate himself, all as discrete classes: quantis (the new default), padrão próprio (LQ) and posição no município, next to the existing "% dos válidos" (B11) and "Total (votos)". Pure math in src/lib/mapScaleClasses.ts (new file — bahiaMapStyle.ts already owned path styling); resolvePathStyle gained a fillByKey path that replaces the continuous ramp when present, where a missing key is "no data", which is not a value of zero. LQ is 5 named classes cut on the E10 anchors, not diverging — the source plan asked for both and contradicted itself; red×blue keeps meaning "Solla × adversário" and stays exclusive to the compare mode. TERRITORIAL_CLASS_ANCHORS / AT_STANDARD_LQ moved to src/lib/territorialClassAnchors.ts because the map panel is a client component and importing the classifier there would drag the ~600 KB TSE artifact into the browser bundle; numbers only, so recalibration (E15) stays a one-line diff in one file. Quantiles are computed over the user's access scope (435 for coordinator/candidate, the portfolio for an advisor) and degrade to 3 classes below 10 municípios, with the legend stating the effective count. "Posição no município" existed nowhere, so it went into the committed artifact (v3) rather than a request-time query — see "Election baseline data" above. New in E10: computeAggregateTerritorialClass(slugs) classes a GROUP as one territory by summing inputs and running the same classifyMunicipalityTerritory, never averaging per-slug classes (LQ is a ratio, and the mean of ratios is not the ratio of the sums) — E12's TI rollup must inherit this helper instead of growing a second one. The map loader fills statewideShareByYear, territorialClassByCode, competitiveRankByYear and projectedValidVotesByCode, all pure over the artifact: no new query per request. (B8 F2 later re-keyed the last two to territorialClassByMapKey / projectedValidVotesByMapKey.) UI: discrete MapScaleLegend with real ranges tied to the selector via aria-describedby, a readout that always spells out the reason ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"), and an opt-in proportional-symbol layer (area ∝ projected valid votes, E10 class colour, interactive: false so the bubble does not stack a second hover target on its own município) — 417 markers were measured before optimizing and did not drop the frame, so the top-K fallback was not built. Two new concepts in /campanha/conceitos. No migration, no collection, no server action; /campanha's First Load JS is byte-identical (9.46 kB / 265 kB) because the map panel already ships in its own ssr: false chunk — that chunk itself grew, and the RSC payload grew ~64 KB uncompressed (~10 KB gzipped), dominated by competitiveRankByYear. Known flake (pre-existing, reproduced on a clean tree): campaignMunicipalities.e2e.spec.ts fails intermittently with 2 workers — the LeaderContactsPanel consent checkbox clicked before hydration, and a getByRole regex that matches both "Conde" and "Condeúba" when the fixture picks the former. Plano: docs/plans/escala-relativa-mapa.md.

Recently resolved (2026-07-25): C13 "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection actionPlan → activity (src/collections/Activity.ts, labels "Atividade"/"Atividades"), campaignDemand.actionPlan → activity, route /campanha/planos → /campanha/atividades (create at /campanha/atividades/nova), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum values (kind/status/origin) are unchanged. Schema moved by a hand-written data-preserving migration (20260725_213000_rename_action_plan_to_activity): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric down(). Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index action_plan_upcoming_start_at_idx, so the object list came from pg_indexes/pg_constraint/pg_type/pg_sequences and the .json snapshot was written by transforming its predecessor — validated by pnpm migrate:create __probe reporting no schema changes. The same migration aligned the 8 fossil %plaza% index names the Município remodel left behind (it renamed the plaza_id columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the codebaseConventions.unit.spec.ts "banned campaign terminology" describe is now table-driven and sweeps src + tests + scripts (migrations stay out — frozen history), banning the actionPlan compounds, the pt-BR copy and the old route, but not bare plan/plano (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, scripts/generate-remodel-municipalities-migration.mjs and the historical comment in campaignMigrationReconciliation.int.spec.ts. Plano: docs/plans/renomear-plano-acao-para-atividade.md.

Recently resolved (2026-07-25): E10 Classificação territorial relativa — src/utilities/municipalityTerritorialClass.ts classifies each of the 435 catalog municipalities as reduto | expansao | manutencao | marginal | sem_base (labels "Reduto · Expansão · Manutenção · Marginal · Sem base"). This vocabulary comes from the discovery research and replaces the majoritarian-era Defesa/Ataque/Indecisa/Perdida — don't reintroduce it. The classifier is a pure function over the committed TSE artifact (no DB, no campaignGoals, no user, no migration): location quotient against the candidate's own statewide standard (TERRITORIAL_CLASS_ANCHORS.strongLq 2 / weakLq 0.5), share of his own statewide vote, uncaptured field ceiling (E8's projectedFieldCeiling) compared against the catalog median, plus membership in the block holding 50% of his vote (which is what keeps a big low-penetration município out of "Marginal") — that block is built by walking A11's already-memoized computeVoteRankByYear ordering, not by re-sorting the catalog. Cuts are ILLUSTRATIVE and versioned in one object — calibration is E15. getStatewideFederalTotals(year) was extracted and memoized into bahiaElectionAggregates.ts; municipalityVoteRank.ts reuses it instead of summing the state itself. The UI never renders the label alone (research §6.4 — a bare verdict buys false confidence): the class always travels with its two dominant factors as real text — never a title attribute, which reaches neither keyboard nor touch. In the list that text is sr-only behind the badge and shown on hover/focus/tap through the B23 cellTooltip seam (spelling it out under the pill made Classe the widest cell in a table that cannot scroll horizontally); on the detail card and the dossier it is a visible paragraph. Surfaces: MunicipalityGoalAccountCard (E8 — the class opens the diagnostics block it summarizes, no new card), the MunicipalityDossier cover (self-labeling badge "Classe reduto" + factors; print CSS forces print-color-adjust: exact on [data-slot='badge'], otherwise the printer flattens priority/trend/class into identical grey), and a new "Classe" column in /campanha/municipios right after "2022" — the 10th staff column, accepted without B17 by explicit product decision. Badge palette is documented in DESIGN.md § Status Badge; sem_base deliberately renders as an em dash in the list (matching the neighboring "2022" column) and keeps the flattest pill on the detail card and the dossier, where it sits in a labeled "Classe …" field. List filtering is the one derived filter in the system: state.classes is NOT part of buildMunicipalityListWhere — municipalityPageData.ts forces the limit: 0, pagination: false path whenever a class is selected (otherwise a 25-row page would drop rows and totalDocs would lie) and applies the same predicate to staffScope.municipalities before the overview rollup and to all three column facets. URL: repeatable ?class= (all selected canonicalizes to absent, same as trend) and sort key classe (ordinal via territorialClassSortWeight, desc default = reduto first, sem_base last either way). Two new diagnostico concepts in /campanha/conceitos (E18). NEC/competition stayed out of v1 (would need a new SQL aggregate; trigger is E11 needing the competition axis beyond the intra-field proxy). Unblocks B13, which should consume computeMunicipalityTerritorialClass as-is rather than reclassify. Plano: docs/plans/classificacao-territorial-relativa.md.

Recently resolved (2026-07-25): E16 Dossiê do município (pré-agenda, field request O6) — staff-only dossie tab on the municipality detail page (6th value in municipalityDetailTabs, same query-param pattern): src/utilities/municipalityDossierData.ts composes the existing loaders in one pass (TSE baseline + A11 rank via municipalityElectionGeographyForSlug, E8 goal account, pledges, leaderships sorted by a new LeadershipRowViewModel.updatedAt freshness field, updates feed, two scoped actionPlan reads with overrideAccess: false, and A8 demographics — the previously consumer-less bahiaMunicipalityDemographics.ts re-enters through the "Perfil" section, with an explicit whole-city caveat for Salvador zona municipalities). Section caps 8 lideranças / 5 sinais / 3 upcoming + 2 realizado plans, each with a "ver tudo" link to its tab. New staff-only municipality.budgetNotes textarea ("Emendas aportadas", G11 manual-first) via migration 20260725_022155_add_municipality_budget_notes — its committed .json snapshot also heals the drizzle snapshot chain after the snapshot-less campaign_goals migration; strategy form/zod/action extended; the section renders only when filled. Print view is CSS-only: app chrome gets print:hidden, the h-svh/overflow-hidden shells get print:h-auto print:overflow-visible (otherwise only page 1 prints), and styles.css gains A4 print typography; the dossier cover is self-contained (name, TI, badges, "Gerado em") so the printed page keeps its identity. Int test mocks loadMunicipalityElectoralBaseline (its unstable_cache wrapper needs the Next runtime). Plano: docs/plans/dossie-municipio.md.

Recently resolved (2026-07-24): E8 Conta da cadeira (metas derivadas, potencial por município, cobertura) — committed TSE artifact extended to v2 (pnpm build:election-aggregates now writes campoFederalVotesByYear, federalTallyByYear, and 2022 majoritarian2022 presidente/governador T1 per municipality, alongside the existing votesByYear/validVotesByYear; byte budget raised 400→700 KB in the unit test); src/lib/campoParties.ts (curated field-party siglas per election year, unit-tested against electionPartySpectrum.ts's esquerda bucket — not a second party classifier); global campaignGoals (src/globals/CampaignGoals.ts, migration 20260724_180000_add_campaign_goals_global, stateGoal default 150,000 + margin/baseYear/note, read = admin/staff, update = admin/isCampaignUnrestricted, no afterChange hook — /campanha is dynamic with auth so nothing caches it); pure derived utilities src/utilities/municipalityPotential.ts (projected valid votes, field ceiling, capture rate, intra-field share, 2022-only roll-off, per-scenario suggested goal + TI sanity check) and src/utilities/goalCoverage.ts — locked semantics: meta = expectedVotes[scenario] ?? suggestedGoal[scenario], comprometido = aggregate.effectiveByScenario[scenario] (pledges only — the mesa's own expectation never substitutes for the pledge sum in the coverage denominator, unlike resolveMunicipalityStaffVoteTotal); orchestration in src/utilities/municipalityGoalAccount.ts (cache()-deduplicated across dashboard/list/detail). UI (Impeccable class B): "Cobertura da meta" row on the dashboard and the list overview CampaignMetricStrip, a new list column (renaming the old "Cobertura" column to "Assessoria" to remove ambiguity) via a shared MunicipalityListGoalCoverageCell (compact/default layouts), and a new "Conta da cadeira" card on the municipality detail page (fixed to the central scenario, replacing the "Votos estimados" block that used to live in MunicipalityStrategyCard). Unblocks roadmap items E9/E10/E12/E13. Plano: docs/plans/conta-da-cadeira.md. As-built correction (same day, during E9): the suggested goal originally split the state goal proportionally to the field ceiling alone, which ignored the candidate's own vote and produced absurd targets (2,911 for Vitória da Conquista, which gave him 5,005 votes in 2022; 813 for Campo Formoso, which gave him 47). decomposeStateGoal was deleted in favour of deriveSuggestedGoalsByScenario, anchored on the municipality's own 2022 vote — pessimistic = base × (1 − margin), central = base, optimistic = base × (stateGoal ÷ Σ base), clamped at max(central, optimistic) — so stateGoal now defines the optimistic scenario and margin the pessimistic haircut (10% fallback). Descriptions only, no migration.

Recently resolved (2026-07-24): E9 Fila de alocação — the municipality list became the allocation queue, with no new route, no new column and no migration (the column picker is B17 and is not shipped, so every new signal folds into an existing cell). Ordering: two new sort keys in municipalityUi.ts — deficit (the new staff default, descending) and frescor — both derived in memory in applyDerivedMunicipalitySort over the already-loaded bundle, fixed to the central scenario server-side (moving the scenario into the URL is the "Cenário junto aos filtros" fill-in). Freshness: aggregatePledgesByMunicipality now selects declaredAt/estimatedAt and exposes lastPledgeAt; resolveMunicipalityLastSignalAt (max of municipality.lastUpdateAt and lastPledgeAt) feeds both the comparator and the view model's lastSignalAt, so server ordering and rendered copy cannot drift; cold threshold MUNICIPALITY_COLD_SIGNAL_DAYS = 21. UI: "há N dias" in the existing "Última atualização" column (amber text-estimate-pending-foreground, deliberately not destructive — the priority and "sem responsável" badges already compete for red on the same row), a "sem responsável" badge when priority === 'alta' with zero advisors, the "coluna da vergonha" as the advisor-coverage metric's detail linking ?priority=alta&coverage=sem_assessor, a scenario-named coverage tooltip, and the leftover "Praças" copy fixed to "Municípios". Cut and re-homed: votes at stake → B13, LQ/capture → E10, a dedicated deficit column → unnecessary (the E8 cell already shows % and signed deficit). Plano: docs/plans/fila-de-alocacao.md.

Recently resolved (2026-07-24): E18 Documentação dos conceitos de inteligência — staff-only route /campanha/conceitos (isCampaignStaff; leader redirected to /campanha) rendering src/lib/campaignIntelligenceConcepts.ts. The glossary is curated content in code, not a Payload collection: each entry (what it measures, the formula in plain text, a hand-written example, why it matters, where it appears) sits next to the formulas it documents (municipalityPotential.ts / goalCoverage.ts) and is reviewed in the same PR that changes them; a unit test guards anchor-safe unique IDs and content completeness. Seven E8 concepts in three groups — base do cálculo (válidos projetados, teto do campo), diagnóstico (captura, share intracampo, roll-off), meta e cobertura (meta/meta sugerida, cobertura da meta). Reading surface, not cards: one article per concept with hairline dividers, max-w-prose column, sticky index at lg only, article:target tint so anchor arrivals land oriented. Two discovery paths from MunicipalityGoalAccountCard: per-metric "Saiba mais" links inside the hover tooltips (MetricExplanation takes a conceptID) and a whole-page link in the title CampaignInfoHint Popover — the Popover is the keyboard path because Radix Tooltip content is not tabbable; for touch, MunicipalityHoverTooltip now treats its own content as "inside" in the pointerdown dismiss handler (the link used to unmount before the tap landed). Sidebar entry lives in staffSecondaryNav (nav.ts) rendered in its own SidebarGroup with mt-auto at the foot of the navigation — deliberately outside staffNav (the seven work destinations) and outside the mobile bottom bar, since it is reference material; getCampaignSecondaryNav returns nothing for leader. Fixed an E8 defect found while writing the docs: formatElectionNumber rendered a fractional suggested goal as "100,968" (read as a hundred thousand in pt-BR) — now maximumFractionDigits: 0. No migration, no collection, no server action; later slices of the intelligence program (E9/E10/B13/E11/E12/E13/E14) each append their section to the array. Plano: docs/plans/documentacao-conceitos-campanha.md.

Recently resolved (2026-07-24): B19 Gerenciar assessores — /campanha/assessores (lista + novo + detalhe) for coordinator/candidate only (isCampaignUnrestricted); create/edit advisor accounts, municipality portfolio (per-municipality auto-save + advisory lock), password-reset email (blocks @planilha.invalid); aligned canUpdateCampaignUser / phone field access / canAssignMunicipalityAdvisors / assignMunicipalityAdvisorsRecord to unrestricted; privileged email read in advisorData loaders after the route gate; reloadUnrestrictedActor. No migration. Plano: docs/plans/gerenciar-assessores.md.

Recently resolved (2026-07-23): A10 Cenários de estimativa (pessimista/média/otimista) — votePledge.estimatedVotes e municipality.expectedVotes viram groups (pessimistic|central|optimistic); migration 20260723_124200_add_vote_estimate_scenarios (backfill scalar→central); agregação por cenário em votePledgeData com default central; mapa 2026 com values2026ByScenario + seletor local; lista/overview/dashboard com média + faixa secundária; liderança continua com um declaredVotes sem ver estimativas. Plano: docs/plans/cenarios-estimativa-votos.md. Desbloqueia E8.